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
@@ -52,10 +52,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 +185,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 +221,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 +259,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 +272,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,11 +355,8 @@ 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()
}
@Composable
@@ -381,11 +368,8 @@ fun observeNoteEdits(
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.flow()
.edits
.stateFlow
.collectAsStateWithLifecycle()
val flow = remember(note) { note.flow().edits.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@Composable
@@ -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 {
@@ -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)
} else {
newItemColor.compositeOver(defaultBackgroundColor)
}
} else {
parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
}
if (isNew) {
newItemColor.compositeOver(parentBackgroundColor?.value ?: defaultBackgroundColor)
} else {
parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
parentBackgroundColor?.value ?: Color.Transparent
},
)
}
LaunchedEffect(createdAt) {
delay(5000)
bgColor.value = parentBackgroundColor?.value ?: defaultBackgroundColor.copy(alpha = 0f)
if (isNew) {
LaunchedEffect(createdAt) {
delay(5000)
bgColor.value = parentBackgroundColor?.value ?: Color.Transparent
}
}
return bgColor
@@ -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?