Browse Source
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 requestspull/5460/head v1.10.0-alpha04+dev3119
10 changed files with 173 additions and 7 deletions
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
internal actual fun DefaultWebResourceReader(): ResourceReader = DefaultJsResourceReader |
||||
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
internal actual fun DefaultWebResourceReader(): ResourceReader = DefaultWasmResourceReader |
||||
@ -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 } |
||||
) |
||||
} |
||||
@ -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…
Reference in new issue