From 703d8051192c8e0bf40cd44e7edc82ac273368e7 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Mon, 4 Aug 2025 17:40:47 +0200 Subject: [PATCH 1/3] Implement StringResourceWebCache for k/wasm --- .../resources/ResourceState.blocking.kt | 4 ++ .../compose/resources/ResourceReader.kt | 2 + .../compose/resources/StringResourcesUtils.kt | 8 +-- .../resources/ResourceReader.wasmJs.kt | 58 +++++++++++++++++-- .../compose/resources/ResourceReader.web.kt | 9 +++ 5 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceReader.web.kt diff --git a/components/resources/library/src/blockingMain/kotlin/org/jetbrains/compose/resources/ResourceState.blocking.kt b/components/resources/library/src/blockingMain/kotlin/org/jetbrains/compose/resources/ResourceState.blocking.kt index 4b0aa69932e..6464cb900ef 100644 --- a/components/resources/library/src/blockingMain/kotlin/org/jetbrains/compose/resources/ResourceState.blocking.kt +++ b/components/resources/library/src/blockingMain/kotlin/org/jetbrains/compose/resources/ResourceState.blocking.kt @@ -63,4 +63,8 @@ internal actual fun rememberResourceState( runBlocking { block(environment) } ) } +} + +internal actual suspend inline fun ResourceReader.readStringItem(path: String, offset: Long, size: Long): ByteArray { + return this.readPart(path, offset, size) } \ No newline at end of file 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 619daabb626..8437b6fe97e 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 @@ -33,6 +33,8 @@ interface ResourceReader { internal expect fun getPlatformResourceReader(): ResourceReader +internal expect suspend inline fun ResourceReader.readStringItem(path: String, offset: Long, size: Long): ByteArray + @ExperimentalResourceApi internal val DefaultResourceReader = getPlatformResourceReader() 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 5013468f5bf..b4e878c6067 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/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt b/components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt index d99000eedd1..7107a87aab8 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 @@ -2,10 +2,13 @@ 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.khronos.webgl.ArrayBuffer import org.khronos.webgl.Int8Array import org.w3c.fetch.Response import org.w3c.files.Blob +import org.w3c.workers.Cache import org.w3c.xhr.XMLHttpRequest import kotlin.js.Promise import kotlin.wasm.unsafe.UnsafeWasmMemoryApi @@ -30,7 +33,7 @@ internal actual fun getPlatformResourceReader(): ResourceReader { } @ExperimentalResourceApi -internal object DefaultWasmResourceReader : ResourceReader { +internal object DefaultWasmResourceReader : WebResourceReader { override suspend fun read(path: String): ByteArray { return readAsBlob(path).asByteArray() } @@ -40,6 +43,16 @@ internal object DefaultWasmResourceReader : ResourceReader { return part.asByteArray() } + override suspend fun readStringItem(path: String, offset: Long, size: Long): ByteArray { + val res = StringResourceWebCache.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) @@ -74,13 +87,13 @@ internal object DefaultWasmResourceReader : ResourceReader { } // It uses a synchronous XmlHttpRequest (blocking!!!) -private object TestWasmResourceReader : ResourceReader { +private object TestWasmResourceReader : WebResourceReader { override suspend fun read(path: String): ByteArray { return readByteArray(path) } override suspend fun readPart(path: String, offset: Long, size: Long): ByteArray { - return readByteArray(path).sliceArray(offset.toInt() until (offset + size).toInt()) + return readPartSync(path, offset, size) } override fun getUri(path: String): String { @@ -88,6 +101,15 @@ private object TestWasmResourceReader : ResourceReader { return getResourceUrl(location.origin, location.pathname, path) } + // No Web Cache API used for tests, because it's async and we need synchronous execution + override suspend fun readStringItem(path: String, offset: Long, size: Long): ByteArray { + return readPartSync(path, offset, size) + } + + private fun readPartSync(path: String, offset: Long, size: Long): ByteArray { + return readByteArray(path).sliceArray(offset.toInt() until (offset + size).toInt()) + } + private fun readByteArray(path: String): ByteArray { val resPath = WebResourcesConfiguration.getResourcePath(path) val request = XMLHttpRequest() @@ -127,4 +149,32 @@ private fun requestResponseAsByteArray(req: XMLHttpRequest): Int8Array = }""") private fun isInTestEnvironment(): Boolean = - js("window.composeResourcesTesting == true") \ No newline at end of file + js("window.composeResourcesTesting == true") + +internal object StringResourceWebCache { + private val CACHE_NAME = "compose_cvr_cache" + private val mutex = Mutex() + + suspend fun load(path: String, onNoCacheHit: suspend (path: String) -> Response): Response { + // With session storage, we avoid resetting the cache on page refresh. + // The cache is reset only when a tab is re-opened. + val isNewSession = window.sessionStorage.getItem(CACHE_NAME) == null + + if (isNewSession) { + window.caches.delete(CACHE_NAME).await() + window.sessionStorage.setItem(CACHE_NAME, "1") + } + + val cache = window.caches.open(CACHE_NAME).await() + + return mutex.withLock { + val response = cache.match(path).await() + + response?.clone() ?: onNoCacheHit(path).clone().also { + if (it.ok) { + cache.put(path, it.clone()).await() + } + } + } + } +} \ No newline at end of file diff --git a/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceReader.web.kt b/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceReader.web.kt new file mode 100644 index 00000000000..ba63cda0df5 --- /dev/null +++ b/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceReader.web.kt @@ -0,0 +1,9 @@ +package org.jetbrains.compose.resources + +internal actual suspend inline fun ResourceReader.readStringItem(path: String, offset: Long, size: Long): ByteArray { + return (this as WebResourceReader).readStringItem(path, offset, size) +} + +internal interface WebResourceReader : ResourceReader { + suspend fun readStringItem(path: String, offset: Long, size: Long): ByteArray +} \ No newline at end of file From 3a0a05457cd0ebd06a717625c58c197f5cf45045 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Tue, 5 Aug 2025 11:11:01 +0200 Subject: [PATCH 2/3] unique mutex per path --- .../org/jetbrains/compose/resources/ResourceReader.wasmJs.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 7107a87aab8..adb71b533bc 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 @@ -153,7 +153,7 @@ private fun isInTestEnvironment(): Boolean = internal object StringResourceWebCache { private val CACHE_NAME = "compose_cvr_cache" - private val mutex = Mutex() + private val mutexes = mutableMapOf() suspend fun load(path: String, onNoCacheHit: suspend (path: String) -> Response): Response { // With session storage, we avoid resetting the cache on page refresh. @@ -167,6 +167,7 @@ internal object StringResourceWebCache { val cache = window.caches.open(CACHE_NAME).await() + val mutex = mutexes.getOrPut(path) { Mutex() } return mutex.withLock { val response = cache.match(path).await() @@ -175,6 +176,8 @@ internal object StringResourceWebCache { cache.put(path, it.clone()).await() } } + }.also { + mutexes.remove(path) } } } \ No newline at end of file From 708601664219d4e9ac0683845527a2e1d24d9468 Mon Sep 17 00:00:00 2001 From: "Oleksandr.Karpovich" Date: Thu, 7 Aug 2025 15:00:55 +0200 Subject: [PATCH 3/3] refactoring + add a test --- .../resources/ResourceState.blocking.kt | 4 -- .../compose/resources/ResourceReader.kt | 3 +- .../compose/resources/ResourceReader.js.kt | 10 +++ .../compose/resources/ResourceWebCache.js.kt | 70 +++++++++++++++++++ .../DefaultWebResourceReaderTest.js.kt | 3 + .../resources/ResourceReader.wasmJs.kt | 51 ++------------ .../resources/ResourceWebCache.wasm.kt.kt | 70 +++++++++++++++++++ .../DefaultWebResourceReaderTest.wasmJs.kt | 3 + .../compose/resources/ResourceReader.web.kt | 9 --- .../resources/DefaultWebResourceReaderTest.kt | 62 ++++++++++++++++ 10 files changed, 223 insertions(+), 62 deletions(-) create mode 100644 components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceWebCache.js.kt create mode 100644 components/resources/library/src/jsTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.js.kt create mode 100644 components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceWebCache.wasm.kt.kt create mode 100644 components/resources/library/src/wasmJsTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.wasmJs.kt delete mode 100644 components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceReader.web.kt create mode 100644 components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.kt diff --git a/components/resources/library/src/blockingMain/kotlin/org/jetbrains/compose/resources/ResourceState.blocking.kt b/components/resources/library/src/blockingMain/kotlin/org/jetbrains/compose/resources/ResourceState.blocking.kt index 6464cb900ef..4b0aa69932e 100644 --- a/components/resources/library/src/blockingMain/kotlin/org/jetbrains/compose/resources/ResourceState.blocking.kt +++ b/components/resources/library/src/blockingMain/kotlin/org/jetbrains/compose/resources/ResourceState.blocking.kt @@ -63,8 +63,4 @@ internal actual fun rememberResourceState( runBlocking { block(environment) } ) } -} - -internal actual suspend inline fun ResourceReader.readStringItem(path: String, offset: Long, size: Long): ByteArray { - return this.readPart(path, offset, size) } \ No newline at end of file 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 8437b6fe97e..4dbdfcc7b56 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,13 +28,12 @@ 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 } internal expect fun getPlatformResourceReader(): ResourceReader -internal expect suspend inline fun ResourceReader.readStringItem(path: String, offset: Long, size: Long): ByteArray - @ExperimentalResourceApi internal val DefaultResourceReader = getPlatformResourceReader() 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 eac5c8552b1..1c4ad772d74 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 00000000000..30aaaabcb1d --- /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 00000000000..b1a889e45e2 --- /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 adb71b533bc..96fc97b0774 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 @@ -2,13 +2,10 @@ 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.khronos.webgl.ArrayBuffer import org.khronos.webgl.Int8Array import org.w3c.fetch.Response import org.w3c.files.Blob -import org.w3c.workers.Cache import org.w3c.xhr.XMLHttpRequest import kotlin.js.Promise import kotlin.wasm.unsafe.UnsafeWasmMemoryApi @@ -33,7 +30,7 @@ internal actual fun getPlatformResourceReader(): ResourceReader { } @ExperimentalResourceApi -internal object DefaultWasmResourceReader : WebResourceReader { +internal object DefaultWasmResourceReader : ResourceReader { override suspend fun read(path: String): ByteArray { return readAsBlob(path).asByteArray() } @@ -44,7 +41,7 @@ internal object DefaultWasmResourceReader : WebResourceReader { } override suspend fun readStringItem(path: String, offset: Long, size: Long): ByteArray { - val res = StringResourceWebCache.load(path) { + val res = WasmResourceWebCache.load(path) { window.fetch(path).await() } if (!res.ok) { @@ -87,13 +84,13 @@ internal object DefaultWasmResourceReader : WebResourceReader { } // It uses a synchronous XmlHttpRequest (blocking!!!) -private object TestWasmResourceReader : WebResourceReader { +private object TestWasmResourceReader : ResourceReader { override suspend fun read(path: String): ByteArray { return readByteArray(path) } override suspend fun readPart(path: String, offset: Long, size: Long): ByteArray { - return readPartSync(path, offset, size) + return readByteArray(path).sliceArray(offset.toInt() until (offset + size).toInt()) } override fun getUri(path: String): String { @@ -101,15 +98,6 @@ private object TestWasmResourceReader : WebResourceReader { return getResourceUrl(location.origin, location.pathname, path) } - // No Web Cache API used for tests, because it's async and we need synchronous execution - override suspend fun readStringItem(path: String, offset: Long, size: Long): ByteArray { - return readPartSync(path, offset, size) - } - - private fun readPartSync(path: String, offset: Long, size: Long): ByteArray { - return readByteArray(path).sliceArray(offset.toInt() until (offset + size).toInt()) - } - private fun readByteArray(path: String): ByteArray { val resPath = WebResourcesConfiguration.getResourcePath(path) val request = XMLHttpRequest() @@ -150,34 +138,3 @@ private fun requestResponseAsByteArray(req: XMLHttpRequest): Int8Array = private fun isInTestEnvironment(): Boolean = js("window.composeResourcesTesting == true") - -internal object StringResourceWebCache { - private val CACHE_NAME = "compose_cvr_cache" - private val mutexes = mutableMapOf() - - suspend fun load(path: String, onNoCacheHit: suspend (path: String) -> Response): Response { - // With session storage, we avoid resetting the cache on page refresh. - // The cache is reset only when a tab is re-opened. - val isNewSession = window.sessionStorage.getItem(CACHE_NAME) == null - - if (isNewSession) { - window.caches.delete(CACHE_NAME).await() - window.sessionStorage.setItem(CACHE_NAME, "1") - } - - val cache = window.caches.open(CACHE_NAME).await() - - val mutex = mutexes.getOrPut(path) { Mutex() } - return mutex.withLock { - val response = cache.match(path).await() - - response?.clone() ?: onNoCacheHit(path).clone().also { - if (it.ok) { - cache.put(path, it.clone()).await() - } - } - }.also { - mutexes.remove(path) - } - } -} \ No newline at end of file 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 00000000000..919a8ec3fcc --- /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 00000000000..fc558149fc9 --- /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/webMain/kotlin/org/jetbrains/compose/resources/ResourceReader.web.kt b/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceReader.web.kt deleted file mode 100644 index ba63cda0df5..00000000000 --- a/components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceReader.web.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.jetbrains.compose.resources - -internal actual suspend inline fun ResourceReader.readStringItem(path: String, offset: Long, size: Long): ByteArray { - return (this as WebResourceReader).readStringItem(path, offset, size) -} - -internal interface WebResourceReader : ResourceReader { - suspend fun readStringItem(path: String, offset: Long, size: Long): ByteArray -} \ 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 00000000000..cca8e9ff412 --- /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