perf(NoteCompose): cut per-item allocations during feed scroll

Three targeted fixes for the jitter that shows up as soon as a feed
contains nested NoteCompose (reposts, quotes, BechLink previews):

* `calculateBackgroundColor`: only schedule the 5s "new item" fade
  LaunchedEffect when the item is actually new and tracks read state.
  Inner notes pass `routeForLastRead = null` and were parking a
  coroutine for 5s per item, every scroll. Also drop a per-call
  `Color.copy(alpha = 0f)` allocation in favor of `Color.Transparent`.

* `EventObservers` + `WatchBlockAndReport`: wrap the `StateFlow`
  lookups in `remember(note)` so each recomposition of the same note
  doesn't re-resolve `note.flow().…stateFlow` (and, in
  `WatchBlockAndReport`, hit the synchronized LRU lookup) on every
  pass. Affects observeNote / Replies / Reactions / Zaps / Reposts /
  Ots / Edits and the per-item hidden-flow check.

* `produceCachedState` / `produceCachedStateAsync`: short-circuit on
  cache hits. The previous `produceState` body always launched a
  coroutine even when the LRU already had the value; this is the
  common path for BechLink previews and draft notes during scroll.
  Now we read the cache synchronously inside `remember`, and only
  launch a `LaunchedEffect` for the actual miss.

No behavior change.
This commit is contained in:
Claude
2026-05-10 20:47:03 +00:00
parent 40dcfa2004
commit 18c1ab2ece
5 changed files with 84 additions and 73 deletions
@@ -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) {
val newValue = cache.update(key)
if (newValue != value) {
value = newValue
): State<V?> {
val state = remember(key) { mutableStateOf(cache.cached(key)) }
if (state.value == null) {
LaunchedEffect(key) {
val newValue = cache.update(key)
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) {
val newValue = cache.update(updateValue)
if (newValue != value) {
value = newValue
): State<V?> {
val state = remember(key) { mutableStateOf(cache.cached(updateValue)) }
if (state.value == null) {
LaunchedEffect(key) {
val newValue = cache.update(updateValue)
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) {
val newValue = cache.update(key)
if (value != newValue) {
value = newValue
): State<V?> {
val state = remember(key) { mutableStateOf(cache.cached(key)) }
if (state.value == null) {
LaunchedEffect(key) {
val newValue = cache.update(key)
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) {
val newValue = cache.update(updateValue)
if (value != newValue) {
value = newValue
): State<V?> {
val state = remember(key) { mutableStateOf(cache.cached(updateValue)) }
if (state.value == null) {
LaunchedEffect(key) {
val newValue = cache.update(updateValue)
if (state.value != newValue) {
state.value = newValue
}
}
}
return state
}
interface CachedState<K : Any, V : Any> {
fun cached(k: K): V?