Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ fun getResourceUri(path: String): String = DefaultResourceReader.getUri(path)
interface ResourceReader {
suspend fun read(path: String): ByteArray
suspend fun readPart(path: String, offset: Long, size: Long): ByteArray
suspend fun readStringItem(path: String, offset: Long, size: Long): ByteArray = readPart(path, offset, size)
fun getUri(path: String): String
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ internal suspend fun getStringItem(
): StringItem = stringItemsCache.getOrLoad(
key = "${resourceItem.path}/${resourceItem.offset}-${resourceItem.size}"
) {
val record = resourceReader.readPart(
resourceItem.path,
resourceItem.offset,
resourceItem.size
val record = resourceReader.readStringItem(
path = resourceItem.path,
offset = resourceItem.offset,
size = resourceItem.size,
).decodeToString()
val recordItems = record.split('|')
val recordType = recordItems.first()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ internal object DefaultJsResourceReader : ResourceReader {
return part.asByteArray()
}

override suspend fun readStringItem(path: String, offset: Long, size: Long): ByteArray {
val res = JsResourceWebCache.load(path) {
window.fetch(path).await()
}
if (!res.ok) {
throw MissingResourceException(path)
}
return res.blob().await().slice(offset.toInt(), (offset + size).toInt()).asByteArray()
}

override fun getUri(path: String): String {
val location = window.location
return getResourceUrl(location.origin, location.pathname, path)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package org.jetbrains.compose.resources

import kotlinx.browser.window
import kotlinx.coroutines.await
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.w3c.fetch.Response
import org.w3c.workers.Cache

/**
* We use [Cache] and [org.w3c.dom.WindowSessionStorage] APIs to cache the successful strings.cvr responses.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we use it specifically for Strings.CVR then let's rename it to StringsWebCache. Otherwise, we mustn't mention cvr in the kdoc

* We can't rely on the default browser cache because it makes http requests to check if the cached value is not expired,
* which may take long if the connection is slow.
*
* With session storage, we avoid resetting the cache on page refresh.
* The cache is reset only when a tab is re-opened.
* TODO: Do we need to provide a way to reset the cache on the user side?
* (it's already possible, but relying on the implementation details such as CACHE_NAME value)
*
* NOTE: due to unavailability of k/js + k/wasm shared w3c API,
* we duplicate this implementation between k/js and k/wasm with minor differences.
*/
internal object JsResourceWebCache {
// This cache will be shared between all Compose instances (independent ComposeViewport) in the same session
private const val CACHE_NAME = "compose_web_resources_cache"

// A collection of mutexes to prevent the concurrent requests for the same resource but allow such requests for
// distinct resources
private val mutexes = mutableMapOf<String, Mutex>()

// A mutex to avoid multiple cache reset
private val resetMutex = Mutex()

suspend fun load(path: String, onNoCacheHit: suspend (path: String) -> Response): Response {
if (isNewSession()) {
// There can be many load requests, and there must be 1 reset max. Therefore, using `resetMutex`.
resetMutex.withLock {
// Checking isNewSession() again in case it was just changed by another load request.
// I avoid wrapping withLock in if (isNewSession()) check to avoid unnecessary locking on every load request
if (isNewSession()) {
resetCache()
}
}
}


val mutex = mutexes.getOrPut(path) { Mutex() }
return mutex.withLock {
val cache = window.caches.open(CACHE_NAME).await()
val response = cache.match(path).await() as Response?

response?.clone() ?: onNoCacheHit(path).also {
if (it.ok) {
cache.put(path, it.clone()).await()
}
}
}.also {
mutexes.remove(path)
}
}

suspend fun resetCache() {
window.caches.delete(CACHE_NAME).await()
window.sessionStorage.setItem(CACHE_NAME, "1")
}

private fun isNewSession(): Boolean {
return window.sessionStorage.getItem(CACHE_NAME) == null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package org.jetbrains.compose.resources

internal actual fun DefaultWebResourceReader(): ResourceReader = DefaultJsResourceReader
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ internal object DefaultWasmResourceReader : ResourceReader {
return part.asByteArray()
}

override suspend fun readStringItem(path: String, offset: Long, size: Long): ByteArray {
val res = WasmResourceWebCache.load(path) {
window.fetch(path).await()
}
if (!res.ok) {
throw MissingResourceException(path)
}
return res.blob().await<Blob>().slice(offset.toInt(), (offset + size).toInt()).asByteArray()
}

override fun getUri(path: String): String {
val location = window.location
return getResourceUrl(location.origin, location.pathname, path)
Expand Down Expand Up @@ -127,4 +137,4 @@ private fun requestResponseAsByteArray(req: XMLHttpRequest): Int8Array =
}""")

private fun isInTestEnvironment(): Boolean =
js("window.composeResourcesTesting == true")
js("window.composeResourcesTesting == true")
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package org.jetbrains.compose.resources

import kotlinx.browser.window
import kotlinx.coroutines.await
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.w3c.fetch.Response
import org.w3c.workers.Cache

/**
* We use [Cache] and [org.w3c.dom.WindowSessionStorage] APIs to cache the successful strings.cvr responses.
* We can't rely on the default browser cache because it makes http requests to check if the cached value is not expired,
* which may take long if the connection is slow.
*
* With session storage, we avoid resetting the cache on page refresh.
* The cache is reset only when a tab is re-opened.
* TODO: Do we need to provide a way to reset the cache on the user side?
* (it's already possible, but relying on the implementation details such as CACHE_NAME value)
*
* NOTE: due to unavailability of k/js + k/wasm shared w3c API,
* we duplicate this implementation between k/js and k/wasm with minor differences.
*/
internal object WasmResourceWebCache {
// This cache will be shared between all Compose instances (independent ComposeViewport) in the same session
private const val CACHE_NAME = "compose_web_resources_cache"

// A collection of mutexes to prevent the concurrent requests for the same resource but allow such requests for
// distinct resources
private val mutexes = mutableMapOf<String, Mutex>()

// A mutex to avoid multiple cache reset
private val resetMutex = Mutex()

suspend fun load(path: String, onNoCacheHit: suspend (path: String) -> Response): Response {
if (isNewSession()) {
// There can be many load requests, and there must be 1 reset max. Therefore, using `resetMutex`.
resetMutex.withLock {
// Checking isNewSession() again in case it was just changed by another load request.
// I avoid wrapping withLock in if (isNewSession()) check to avoid unnecessary locking on every load request
if (isNewSession()) {
resetCache()
}
}
}


val mutex = mutexes.getOrPut(path) { Mutex() }
return mutex.withLock {
val cache = window.caches.open(CACHE_NAME).await<Cache>()
val response = cache.match(path).await<Response?>()

response?.clone() ?: onNoCacheHit(path).also {
if (it.ok) {
cache.put(path, it.clone()).await<JsBoolean>()
}
}
}.also {
mutexes.remove(path)
}
}

suspend fun resetCache() {
window.caches.delete(CACHE_NAME).await<JsBoolean>()
window.sessionStorage.setItem(CACHE_NAME, "1")
}

private fun isNewSession(): Boolean {
return window.sessionStorage.getItem(CACHE_NAME) == null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package org.jetbrains.compose.resources

internal actual fun DefaultWebResourceReader(): ResourceReader = DefaultWasmResourceReader
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package org.jetbrains.compose.resources

import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds

@OptIn(ExperimentalTestApi::class)
class DefaultWebResourceReaderTest {

private val reader = DefaultWebResourceReader()

private val appNameStringRes = TestStringResource("app_name")

@Test
fun stringResource() = runComposeUiTest {

var appName: String by mutableStateOf("")

setContent {
CompositionLocalProvider(
LocalResourceReader provides reader,
LocalComposeEnvironment provides TestComposeEnvironment
) {
appName = stringResource(appNameStringRes)
Text(appName)
}
}

awaitUntil {
appName == "Compose Resources App"
}

assertEquals("Compose Resources App", appName)
}

private suspend fun ComposeUiTest.awaitUntil(
timeout: Duration = 100.milliseconds,
block: suspend () -> Boolean
) {
withContext(Dispatchers.Default) {
withTimeout(timeout) {
while (!block()) {
delay(10)
awaitIdle()
}
}
}
}
}

// Until we have common w3c api between k/js and k/wasm we need to have this expect/actual
internal expect fun DefaultWebResourceReader(): ResourceReader
Loading