Merge pull request #2857 from vitorpamplona/claude/fix-notecompose-scroll-jitter-8lw3R

Optimize Compose state management and flow subscriptions
This commit is contained in:
Vitor Pamplona
2026-05-12 09:24:25 -04:00
committed by GitHub
15 changed files with 276 additions and 139 deletions
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.richtext.UrlParser
object CachedRichTextParser {
private val richTextCache = LruCache<Int, RichTextViewerState>(50)
private val isMarkdownCache = LruCache<Int, Boolean>(200)
private fun hashCodeCache(
content: String,
@@ -69,6 +70,25 @@ object CachedRichTextParser {
newUrls
}
}
// Shared across every RichTextViewer instance so that the same content quoted in multiple
// notes only pays for the scan once. The decision is purely a function of `content`.
fun isMarkdown(content: String): Boolean {
val key = content.hashCode()
isMarkdownCache[key]?.let { return it }
val result = computeIsMarkdown(content)
isMarkdownCache.put(key, result)
return result
}
private fun computeIsMarkdown(content: String): Boolean =
content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("**") ||
content.contains("```") ||
content.contains("](")
}
object CachedUrlParser {
@@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
@@ -52,10 +54,8 @@ fun observeNote(
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val flow = remember(note) { note.flow().metadata.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@Suppress("UNCHECKED_CAST")
@@ -187,10 +187,8 @@ fun observeNoteReplies(
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.flow()
.replies.stateFlow
.collectAsStateWithLifecycle()
val flow = remember(note) { note.flow().replies.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -225,10 +223,8 @@ fun observeNoteReactions(
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.flow()
.reactions.stateFlow
.collectAsStateWithLifecycle()
val flow = remember(note) { note.flow().reactions.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -265,10 +261,8 @@ fun observeNoteZaps(
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.flow()
.zaps.stateFlow
.collectAsStateWithLifecycle()
val flow = remember(note) { note.flow().zaps.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@Composable
@@ -280,10 +274,8 @@ fun observeNoteReposts(
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.flow()
.boosts.stateFlow
.collectAsStateWithLifecycle()
val flow = remember(note) { note.flow().boosts.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -365,27 +357,36 @@ fun observeNoteOts(
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.flow()
.ots
.stateFlow
.collectAsStateWithLifecycle()
val flow = remember(note) { note.flow().ots.stateFlow }
return flow.collectAsStateWithLifecycle()
}
// Resolves the actual modification list off the main thread and filters identical results,
// so the caller's LaunchedEffect only fires when the list of edits truly changes.
// `sample(500)` collapses bursts — a heavily-edited note can emit hundreds of times during
// initial relay sync, and we only need the last state per ~half second.
// Returns `null` until the first IO resolution completes — callers should treat that as
// "still loading" and not flip their UI to "no edits".
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeNoteEdits(
fun observeNoteModifications(
note: Note,
accountViewModel: AccountViewModel,
): State<NoteState?> {
): State<List<Note>?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
return note
return produceState<List<Note>?>(initialValue = null, note) {
note
.flow()
.edits
.stateFlow
.collectAsStateWithLifecycle()
.sample(500)
.mapLatest { LocalCache.findLatestModificationForNote(note) }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
.collect { value = it }
}
}
@Composable
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
@@ -76,10 +77,12 @@ class MainActivity : AppCompatActivity() {
setContent {
StringResSetup()
AmethystTheme {
NowProvider {
AccountScreen(Amethyst.instance.sessionManager)
}
}
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onResume() {
@@ -122,15 +122,6 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
fun isMarkdown(content: String): Boolean =
content.startsWith("> ") ||
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("**") ||
content.contains("```") ||
content.contains("](")
@Composable
fun RichTextViewer(
content: String,
@@ -145,7 +136,7 @@ fun RichTextViewer(
nav: INav,
) {
Column(modifier = modifier) {
if (remember(content) { isMarkdown(content) }) {
if (remember(content) { CachedRichTextParser.isMarkdown(content) }) {
RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav)
} else {
RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, authorPubKey, accountViewModel, nav)
@@ -59,7 +59,8 @@ fun WatchBlockAndReport(
nav: INav,
normalNote: @Composable (canPreview: Boolean) -> Unit,
) {
val isHidden by accountViewModel.createIsHiddenFlow(note).collectAsStateWithLifecycle()
val isHidden by remember(note) { accountViewModel.createIsHiddenFlow(note) }
.collectAsStateWithLifecycle()
val showAnyway =
remember {
@@ -66,8 +66,8 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCommunityApprovalNeedStatus
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEdits
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteModifications
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
@@ -489,30 +489,31 @@ fun calculateBackgroundColor(
): MutableState<Color> {
val defaultBackgroundColor = MaterialTheme.colorScheme.background
val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor
// Only fade in/out the "new item" highlight for items that track read state.
// Inner notes (reposts/quotes) pass routeForLastRead = null and reuse the parent color directly,
// so the LaunchedEffect would just park a coroutine for 5s per item during scroll.
val isNew =
remember(createdAt, routeForLastRead) {
routeForLastRead != null && accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt)
}
val bgColor =
remember(createdAt) {
mutableStateOf(
if (routeForLastRead != null) {
val isNew = accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt)
if (isNew) {
if (parentBackgroundColor != null) {
newItemColor.compositeOver(parentBackgroundColor.value)
newItemColor.compositeOver(parentBackgroundColor?.value ?: defaultBackgroundColor)
} else {
newItemColor.compositeOver(defaultBackgroundColor)
}
} else {
parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
}
} else {
parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
parentBackgroundColor?.value ?: Color.Transparent
},
)
}
if (isNew) {
LaunchedEffect(createdAt) {
delay(5000)
bgColor.value = parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
bgColor.value = parentBackgroundColor?.value ?: Color.Transparent
}
}
return bgColor
@@ -1782,26 +1783,28 @@ fun observeEdits(
)
}
val updatedNote by observeNoteEdits(baseNote, accountViewModel)
// Upstream resolves on IO and `distinctUntilChanged`s, so this LaunchedEffect only
// fires when the actual modification list changes — no more recomputing + reassigning
// editState on every unrelated emission of the edits flow.
val modifications by observeNoteModifications(baseNote, accountViewModel)
LaunchedEffect(key1 = updatedNote) {
updatedNote?.note?.let {
val newModifications = accountViewModel.findModificationEventsForNote(it)
if (newModifications.isEmpty()) {
LaunchedEffect(modifications) {
val mods = modifications ?: return@LaunchedEffect
if (mods.isEmpty()) {
if (editState.value !is GenericLoadable.Empty) {
editState.value = GenericLoadable.Empty()
}
} else {
if (editState.value is GenericLoadable.Loaded) {
(editState.value as? GenericLoadable.Loaded<EditState>)?.loaded?.updateModifications(newModifications)
val current = editState.value
if (current is GenericLoadable.Loaded) {
current.loaded.updateModifications(mods)
} else {
val state = EditState()
state.updateModifications(newModifications)
state.updateModifications(mods)
editState.value = GenericLoadable.Loaded(state)
}
}
}
}
return editState
}
@@ -59,7 +59,11 @@ import com.vitorpamplona.amethyst.ui.theme.Size17Modifier
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.noteComposeRelayBox
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.flow.mapNotNull
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.sample
@Composable
fun RelayBadges(
@@ -81,7 +85,7 @@ fun RelayBadges(
}
}
@OptIn(ExperimentalLayoutApi::class)
@OptIn(ExperimentalLayoutApi::class, FlowPreview::class)
@Composable
fun RenderAllRelayList(
baseNote: Note,
@@ -90,16 +94,26 @@ fun RenderAllRelayList(
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteRelays by baseNote
val flow =
remember(baseNote) {
baseNote
.flow()
.relays.stateFlow
.collectAsStateWithLifecycle()
.sample(500)
.map { it.note.relays }
.distinctUntilChanged()
}
val relays by flow.collectAsStateWithLifecycle(baseNote.relays)
FlowRow(modifier, verticalArrangement = verticalArrangement) {
noteRelays.note.relays.forEach { RenderRelay(it, accountViewModel, nav) }
relays.forEach { RenderRelay(it, accountViewModel, nav) }
}
}
// Single sampled subscription instead of one per slot: emits the first 3 relays from the note.
// Throttled to 500ms because relay arrivals can churn a list of an actively-fanned-out note.
@OptIn(FlowPreview::class)
@Composable
fun RenderClosedRelayList(
baseNote: Note,
@@ -108,32 +122,32 @@ fun RenderClosedRelayList(
accountViewModel: AccountViewModel,
nav: INav,
) {
val flow =
remember(baseNote) {
baseNote
.flow()
.relays.stateFlow
.sample(500)
.map { it.note.relays.take(3) }
.distinctUntilChanged()
}
val initial = remember(baseNote) { baseNote.relays.take(3) }
val relays by flow.collectAsStateWithLifecycle(initial)
Row(modifier, verticalAlignment = verticalAlignment) {
WatchAndRenderRelay(baseNote, 0, accountViewModel, nav)
WatchAndRenderRelay(baseNote, 1, accountViewModel, nav)
WatchAndRenderRelay(baseNote, 2, accountViewModel, nav)
RenderRelaySlot(relays.getOrNull(0), accountViewModel, nav)
RenderRelaySlot(relays.getOrNull(1), accountViewModel, nav)
RenderRelaySlot(relays.getOrNull(2), accountViewModel, nav)
}
}
@Composable
fun WatchAndRenderRelay(
baseNote: Note,
relayIndex: Int,
private fun RenderRelaySlot(
relay: NormalizedRelayUrl?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val flow =
remember(baseNote, relayIndex) {
baseNote
.flow()
.relays.stateFlow
.mapNotNull {
it.note.relays.getOrNull(relayIndex)
}
}
val relay by flow.collectAsStateWithLifecycle(baseNote.relays.getOrNull(relayIndex))
CrossfadeIfEnabled(targetState = relay, label = "RenderRelay", modifier = Size17Modifier, accountViewModel = accountViewModel) {
if (it != null) {
RenderRelay(it, accountViewModel, nav)
@@ -99,7 +99,8 @@ fun ShouldShowExpandButton(
accountViewModel: AccountViewModel,
content: @Composable () -> Unit,
) {
val showExpandButton by accountViewModel.createMustShowExpandButtonFlows(baseNote).collectAsStateWithLifecycle()
val flow = remember(baseNote) { accountViewModel.createMustShowExpandButtonFlows(baseNote) }
val showExpandButton by flow.collectAsStateWithLifecycle()
if (showExpandButton) {
content()
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.elements
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.State
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
private const val TICK_INTERVAL_MS = 30_000L
// Shared coarse-grained "now" ticker. One coroutine refreshes the value at TICK_INTERVAL_MS,
// every TimeAgo on screen reads from it. Because TimeAgo wraps the formatted string in
// `derivedStateOf`, the Text only recomposes when the displayed string actually changes
// (e.g. crossing 1m → 2m) — not on every tick.
val LocalNowSeconds = compositionLocalOf<State<Long>> { mutableStateOf(TimeUtils.now()) }
@Composable
fun NowProvider(content: @Composable () -> Unit) {
val now =
produceState(TimeUtils.now()) {
while (true) {
delay(TICK_INTERVAL_MS)
value = TimeUtils.now()
}
}
CompositionLocalProvider(LocalNowSeconds provides now, content = content)
}
@@ -25,7 +25,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -46,7 +45,16 @@ fun TimeAgo(note: Note) {
@Composable
fun TimeAgo(time: Long) {
val context = LocalContext.current
val timeStr by remember(time) { mutableStateOf(timeAgo(time, context = context)) }
// Subscribe to the shared coarse ticker; `derivedStateOf` ensures the Text only
// recomposes when the formatted string actually flips (e.g. 1m → 2m), not on every tick.
val nowState = LocalNowSeconds.current
val timeStr by
remember(time, context, nowState) {
derivedStateOf {
nowState.value
timeAgo(time, context = context)
}
}
Text(
text = timeStr,
@@ -61,9 +69,15 @@ fun NormalTimeAgo(
modifier: Modifier,
) {
val nowStr = stringRes(id = R.string.now)
val nowState = LocalNowSeconds.current
val time by
remember(baseNote) { derivedStateOf { timeAgoShort(baseNote.createdAt() ?: 0L, nowStr) } }
remember(baseNote, nowStr, nowState) {
derivedStateOf {
nowState.value
timeAgoShort(baseNote.createdAt() ?: 0L, nowStr)
}
}
Text(
text = time,
@@ -1328,11 +1328,6 @@ class AccountViewModel(
fun cachedModificationEventsForNote(note: Note) = LocalCache.cachedModificationEventsForNote(note)
suspend fun findModificationEventsForNote(note: Note): List<Note> =
withContext(Dispatchers.IO) {
LocalCache.findLatestModificationForNote(note)
}
fun checkGetOrCreatePublicChatChannel(key: HexKey): PublicChatChannel = LocalCache.getOrCreatePublicChatChannel(key)
fun checkGetOrCreateLiveActivityChannel(key: Address): LiveActivitiesChannel = LocalCache.getOrCreateLiveChannel(key)
@@ -26,6 +26,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -35,6 +37,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.note.elements.LocalNowSeconds
import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.stringRes
@@ -46,7 +49,14 @@ import com.vitorpamplona.quartz.nip40Expiration.expiration
@Composable
fun ChatTimeAgo(baseNote: Note) {
val nowStr = stringRes(id = R.string.now)
val time = remember(baseNote) { timeAgoShort(baseNote.createdAt() ?: 0L, nowStr) }
val nowState = LocalNowSeconds.current
val time by
remember(baseNote, nowStr, nowState) {
derivedStateOf {
nowState.value
timeAgoShort(baseNote.createdAt() ?: 0L, nowStr)
}
}
Text(
text = time,
@@ -27,6 +27,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -60,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent
import com.vitorpamplona.amethyst.ui.note.elements.LocalNowSeconds
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
@@ -483,7 +485,14 @@ private fun TimeAgo(channelLastTime: Long?) {
if (channelLastTime == null) return
val context = LocalContext.current
val timeAgo = remember(channelLastTime) { timeAgo(channelLastTime, context) }
val nowState = LocalNowSeconds.current
val timeAgo by
remember(channelLastTime, context, nowState) {
derivedStateOf {
nowState.value
timeAgo(channelLastTime, context)
}
}
Text(
text = timeAgo,
color = MaterialTheme.colorScheme.grayText,
@@ -22,35 +22,47 @@ package com.vitorpamplona.amethyst.commons.compose
import androidx.collection.LruCache
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
// On a cache hit, short-circuit with a remembered State<V?> and skip the produceState coroutine.
// Only on a miss do we launch the suspending update.
@Composable
fun <K : Any, V : Any> produceCachedStateAsync(
cache: AsyncCachedState<K, V>,
key: K,
): State<V?> =
@Suppress("ProduceStateDoesNotAssignValue")
produceState(initialValue = cache.cached(key), key1 = key) {
): State<V?> {
val state = remember(key) { mutableStateOf(cache.cached(key)) }
if (state.value == null) {
LaunchedEffect(key) {
val newValue = cache.update(key)
if (newValue != value) {
value = newValue
if (state.value != newValue) {
state.value = newValue
}
}
}
return state
}
@Composable
fun <K : Any, V : Any> produceCachedStateAsync(
cache: AsyncCachedState<K, V>,
key: String,
updateValue: K,
): State<V?> =
@Suppress("ProduceStateDoesNotAssignValue")
produceState(initialValue = cache.cached(updateValue), key1 = key) {
): State<V?> {
val state = remember(key) { mutableStateOf(cache.cached(updateValue)) }
if (state.value == null) {
LaunchedEffect(key) {
val newValue = cache.update(updateValue)
if (newValue != value) {
value = newValue
if (state.value != newValue) {
state.value = newValue
}
}
}
return state
}
interface AsyncCachedState<K : Any, V : Any> {
fun cached(k: K): V?
@@ -22,35 +22,48 @@ package com.vitorpamplona.amethyst.commons.compose
import androidx.collection.LruCache
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
// On a cache hit (the common case during scroll for things like Bech32 link previews),
// short-circuit with a remembered State<V?> and skip the produceState coroutine entirely.
// Only on a miss do we launch the suspending update.
@Composable
fun <K : Any, V : Any> produceCachedState(
cache: CachedState<K, V>,
key: K,
): State<V?> =
@Suppress("ProduceStateDoesNotAssignValue")
produceState(initialValue = cache.cached(key), key1 = key) {
): State<V?> {
val state = remember(key) { mutableStateOf(cache.cached(key)) }
if (state.value == null) {
LaunchedEffect(key) {
val newValue = cache.update(key)
if (value != newValue) {
value = newValue
if (state.value != newValue) {
state.value = newValue
}
}
}
return state
}
@Composable
fun <K : Any, V : Any> produceCachedState(
cache: CachedState<K, V>,
key: String,
updateValue: K,
): State<V?> =
@Suppress("ProduceStateDoesNotAssignValue")
produceState(initialValue = cache.cached(updateValue), key1 = key) {
): State<V?> {
val state = remember(key) { mutableStateOf(cache.cached(updateValue)) }
if (state.value == null) {
LaunchedEffect(key) {
val newValue = cache.update(updateValue)
if (value != newValue) {
value = newValue
if (state.value != newValue) {
state.value = newValue
}
}
}
return state
}
interface CachedState<K : Any, V : Any> {
fun cached(k: K): V?