diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt index 619daabb62..4dbdfcc7b5 100644 --- a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt @@ -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 } diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResourcesUtils.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResourcesUtils.kt index 5013468f5b..b4e878c606 100644 --- a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResourcesUtils.kt +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResourcesUtils.kt @@ -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() diff --git a/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.js.kt b/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.js.kt index eac5c8552b..1c4ad772d7 100644 --- a/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.js.kt +++ b/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.js.kt @@ -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) diff --git a/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceWebCache.js.kt b/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceWebCache.js.kt new file mode 100644 index 0000000000..30aaaabcb1 --- /dev/null +++ b/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceWebCache.js.kt @@ -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 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() + + // 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 + } +} \ No newline at end of file diff --git a/components/resources/library/src/jsTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.js.kt b/components/resources/library/src/jsTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.js.kt new file mode 100644 index 0000000000..b1a889e45e --- /dev/null +++ b/components/resources/library/src/jsTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.js.kt @@ -0,0 +1,3 @@ +package org.jetbrains.compose.resources + +internal actual fun DefaultWebResourceReader(): ResourceReader = DefaultJsResourceReader \ No newline at end of file diff --git a/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt b/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt index d99000eedd..96fc97b077 100644 --- a/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt +++ b/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt @@ -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().slice(offset.toInt(), (offset + size).toInt()).asByteArray() + } + override fun getUri(path: String): String { val location = window.location return getResourceUrl(location.origin, location.pathname, path) @@ -127,4 +137,4 @@ private fun requestResponseAsByteArray(req: XMLHttpRequest): Int8Array = }""") private fun isInTestEnvironment(): Boolean = - js("window.composeResourcesTesting == true") \ No newline at end of file + js("window.composeResourcesTesting == true") diff --git a/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceWebCache.wasm.kt.kt b/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceWebCache.wasm.kt.kt new file mode 100644 index 0000000000..919a8ec3fc --- /dev/null +++ b/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceWebCache.wasm.kt.kt @@ -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() + + // 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() + + 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 + } +} \ No newline at end of file diff --git a/components/resources/library/src/wasmJsTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.wasmJs.kt b/components/resources/library/src/wasmJsTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.wasmJs.kt new file mode 100644 index 0000000000..fc558149fc --- /dev/null +++ b/components/resources/library/src/wasmJsTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.wasmJs.kt @@ -0,0 +1,3 @@ +package org.jetbrains.compose.resources + +internal actual fun DefaultWebResourceReader(): ResourceReader = DefaultWasmResourceReader \ No newline at end of file diff --git a/components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.kt b/components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.kt new file mode 100644 index 0000000000..cca8e9ff41 --- /dev/null +++ b/components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.kt @@ -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 \ No newline at end of file