Browse Source

Use Web Cache API for extra layer of resources caching (#5379)

Use Web Cache API for all resources 

The Cache is reset on every app launch (page refresh).
The initial idea was to reset the Cache only when a new session starts,
but we risk to have an outdated resources state (it can be incompatible
with the app logic expectations and lead to crashes).

Fixes https://youtrack.jetbrains.com/issue/CMP-7996 

## Testing
This should be tested by QA

## Release Notes
### Fixes - Resources
- Use Web Cache API for all resources to avoid repeated and redundant
HTTP requests
pull/5460/head v1.10.0-alpha04+dev3119
Oleksandr Karpovich 2 months ago committed by GitHub
parent
commit
ff72d5f385
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      components/gradle.properties
  2. 2
      components/gradle/libs.versions.toml
  3. 3
      components/resources/library/build.gradle.kts
  4. 6
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResourcesUtils.kt
  5. 6
      components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.js.kt
  6. 3
      components/resources/library/src/jsTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.js.kt
  7. 7
      components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt
  8. 3
      components/resources/library/src/wasmJsTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.wasmJs.kt
  9. 86
      components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceWebCache.web.kt
  10. 62
      components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.kt

2
components/gradle.properties

@ -9,7 +9,7 @@ android.useAndroidX=true @@ -9,7 +9,7 @@ android.useAndroidX=true
#Versions
kotlin.version=2.2.20
agp.version=8.9.0
compose.version=1.9.0-rc01
compose.version=1.9.0
deploy.version=9999.0.0-SNAPSHOT
#Compose

2
components/gradle/libs.versions.toml

@ -4,9 +4,11 @@ androidx-appcompat = "1.6.1" @@ -4,9 +4,11 @@ androidx-appcompat = "1.6.1"
androidx-activity-compose = "1.8.2"
androidx-test = "1.5.0"
androidx-compose = "1.6.1"
kotlinx-browser = "0.5.0"
[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-browser = { module = "org.jetbrains.kotlinx:kotlinx-browser", version.ref = "kotlinx-browser" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }

3
components/resources/library/build.gradle.kts

@ -149,6 +149,9 @@ kotlin { @@ -149,6 +149,9 @@ kotlin {
}
val webMain by getting {
dependsOn(skikoMain)
dependencies {
implementation(libs.kotlinx.browser)
}
}
val jsMain by getting {
dependsOn(webMain)

6
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResourcesUtils.kt

@ -24,9 +24,9 @@ internal suspend fun getStringItem( @@ -24,9 +24,9 @@ internal suspend fun getStringItem(
key = "${resourceItem.path}/${resourceItem.offset}-${resourceItem.size}"
) {
val record = resourceReader.readPart(
resourceItem.path,
resourceItem.offset,
resourceItem.size
path = resourceItem.path,
offset = resourceItem.offset,
size = resourceItem.size,
).decodeToString()
val recordItems = record.split('|')
val recordType = recordItems.first()

6
components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.js.kt

@ -5,6 +5,7 @@ import kotlinx.coroutines.await @@ -5,6 +5,7 @@ import kotlinx.coroutines.await
import org.khronos.webgl.ArrayBuffer
import org.khronos.webgl.Int8Array
import org.khronos.webgl.Uint8Array
import org.w3c.fetch.Response
import org.w3c.files.Blob
import org.w3c.xhr.XMLHttpRequest
import kotlin.js.Promise
@ -33,7 +34,10 @@ internal object DefaultJsResourceReader : ResourceReader { @@ -33,7 +34,10 @@ internal object DefaultJsResourceReader : ResourceReader {
private suspend fun readAsBlob(path: String): Blob {
val resPath = WebResourcesConfiguration.getResourcePath(path)
val response = window.fetch(resPath).await()
val response = ResourceWebCache.load(resPath) {
// TODO: avoid js(...) calls here after https://github.com/Kotlin/kotlinx-browser/issues/24
js("window.fetch(resPath)").unsafeCast<Promise<Response>>().await()
}
if (!response.ok) {
throw MissingResourceException(resPath)
}

3
components/resources/library/src/jsTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.js.kt

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
package org.jetbrains.compose.resources
internal actual fun DefaultWebResourceReader(): ResourceReader = DefaultJsResourceReader

7
components/resources/library/src/wasmJsMain/kotlin/org/jetbrains/compose/resources/ResourceReader.wasmJs.kt

@ -30,6 +30,7 @@ internal actual fun getPlatformResourceReader(): ResourceReader { @@ -30,6 +30,7 @@ internal actual fun getPlatformResourceReader(): ResourceReader {
}
@ExperimentalResourceApi
@OptIn(ExperimentalWasmJsInterop::class)
internal object DefaultWasmResourceReader : ResourceReader {
override suspend fun read(path: String): ByteArray {
return readAsBlob(path).asByteArray()
@ -47,7 +48,9 @@ internal object DefaultWasmResourceReader : ResourceReader { @@ -47,7 +48,9 @@ internal object DefaultWasmResourceReader : ResourceReader {
private suspend fun readAsBlob(path: String): Blob {
val resPath = WebResourcesConfiguration.getResourcePath(path)
val response = window.fetch(resPath).await<Response>()
val response = ResourceWebCache.load(resPath) {
window.fetch(resPath).await()
}
if (!response.ok) {
throw MissingResourceException(resPath)
}
@ -127,4 +130,4 @@ private fun requestResponseAsByteArray(req: XMLHttpRequest): Int8Array = @@ -127,4 +130,4 @@ private fun requestResponseAsByteArray(req: XMLHttpRequest): Int8Array =
}""")
private fun isInTestEnvironment(): Boolean =
js("window.composeResourcesTesting == true")
js("window.composeResourcesTesting == true")

3
components/resources/library/src/wasmJsTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.wasmJs.kt

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
package org.jetbrains.compose.resources
internal actual fun DefaultWebResourceReader(): ResourceReader = DefaultWasmResourceReader

86
components/resources/library/src/webMain/kotlin/org/jetbrains/compose/resources/ResourceWebCache.web.kt

@ -0,0 +1,86 @@ @@ -0,0 +1,86 @@
package org.jetbrains.compose.resources
import kotlinx.browser.window
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.w3c.fetch.Response
import org.w3c.workers.Cache
import org.w3c.workers.CacheQueryOptions
import kotlin.coroutines.resumeWithException
import kotlin.js.ExperimentalWasmJsInterop
import kotlin.js.JsAny
import kotlin.js.Promise
import kotlin.js.asJsException
/**
* We use [Cache] APIs to cache the successful strings.cvr and other 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.
*
* Cache limits:
* https://developer.mozilla.org/en-US/docs/Web/API/Storage_API/Storage_quotas_and_eviction_criteria#other_web_technologies
*/
@OptIn(ExperimentalWasmJsInterop::class)
internal object ResourceWebCache {
// 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()) {
sessionStarted = true
resetCache()
}
}
}
val mutex = mutexes.getOrPut(path) { Mutex() }
return mutex.withLock {
val cache = window.caches.open(CACHE_NAME).await()
val response = (cache.match(path, CacheQueryOptions()) as Promise<Response?>).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()
}
// In this case it's not true session as browsers mean it.
// Here a new session is created on every page refresh.
private var sessionStarted = false
private fun isNewSession(): Boolean {
return !sessionStarted
}
}
// Promise.await is not yet available in webMain: https://github.com/Kotlin/kotlinx.coroutines/issues/4544
// TODO(o.karpovich): get rid of this function, when kotlinx-coroutines provide Promise.await in webMain out of a box
@OptIn(ExperimentalWasmJsInterop::class)
private suspend fun <R : JsAny?> Promise<R>.await(): R = suspendCancellableCoroutine { continuation ->
this.then(
onFulfilled = { continuation.resumeWith(Result.success(it)); null },
onRejected = { continuation.resumeWithException(it.asJsException()); null }
)
}

62
components/resources/library/src/webTest/kotlin/org/jetbrains/compose/resources/DefaultWebResourceReaderTest.kt

@ -0,0 +1,62 @@ @@ -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…
Cancel
Save