vc=81 — perf-audit app-side batch (search debounce + feed-merge memoize)
All checks were successful
build-apk / build-and-publish (push) Successful in 7m19s
gitleaks / scan (push) Successful in 44s

Search: the reactive cache-preview filter no longer runs on the main
thread on every keystroke. It walked the whole cached-results pool
(thousands of items on a heavy user) inline; now each keystroke
debounces ~150ms and the scan runs on Dispatchers.Default. A submit
cancels the pending preview so a late scan can't clobber live results.

Feed: mergeFromCache memoizes the relative-upload-date parse by string,
so the recency regex runs once per distinct "N days ago" value instead
of once per item (~3000 per merge on a 200-sub feed) — across
hydration, every refresh, and each background-enrichment emit.

No behavior change.
This commit is contained in:
Cobb 2026-06-21 06:26:40 -07:00
parent 0e7f0b4781
commit 1730ed3dc8
3 changed files with 79 additions and 22 deletions

View file

@ -9,6 +9,19 @@ const val STRAW_SDK_TARGET = 35
// Sulkta fork — Straw
//
// vc=81 / 0.1.0-CO — perf-audit app-side batch (no behavior change):
// * Search: the reactive cache-preview filter no longer runs on the
// main thread on every keystroke. It walked the entire cached-
// results pool (thousands of items on a heavy user) inline; now each
// keystroke debounces ~150ms and the scan runs on Dispatchers.Default.
// A submit cancels the pending preview so it can't clobber live
// results.
// * Subscription feed: the merge memoizes the relative-upload-date
// parse by string, so the recency regex runs once per distinct
// "N days ago" value instead of once per item (~3000 per merge on a
// 200-sub feed) — across hydration, every refresh, and each
// background-enrichment emit.
//
// vc=80 / 0.1.0-CN — strawcore extraction perf (Rust batch):
// * The extractor borrows the streamingData subtree out of the Android
// + iOS player responses instead of deep-cloning the largest part of
@ -121,6 +134,6 @@ const val STRAW_SDK_TARGET = 35
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 80
const val STRAW_VERSION_NAME = "0.1.0-CN"
const val STRAW_VERSION_CODE = 81
const val STRAW_VERSION_NAME = "0.1.0-CO"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -329,9 +329,18 @@ class SubscriptionFeedViewModel : ViewModel() {
// Pure read of the enrichment store; the enrichment write
// path triggers a fresh _ui emit.
val enrichments = FeedEnrichment.get().entries.value
// Memoize recencyScore by its only input — the relative-date
// string — so the regex parse runs once per *distinct* string
// instead of once per item. audit 2.3: a 200-sub feed merges
// ~3000 items but carries only a few dozen distinct "N days ago"
// strings, and this merge runs on hydration, every refresh, and
// each enrichment emit. recencyScore() reads only
// uploadDateRelative, and withEnrichment never touches it, so the
// memo is exact.
val recencyMemo = HashMap<String, Long>()
return channels.flatMap { ch -> channelCache[ch.url]?.items.orEmpty() }
.map { it.withEnrichment(enrichments) }
.map { it to it.recencyScore() }
.map { item -> item to recencyMemo.getOrPut(item.uploadDateRelative) { item.recencyScore() } }
.sortedWith(
compareByDescending<Pair<StreamItem, Long>> { it.second }
.thenByDescending { it.first.viewCount },

View file

@ -14,6 +14,7 @@ import com.sulkta.straw.data.Settings
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -119,31 +120,52 @@ class SearchViewModel : ViewModel() {
// swaps in) so a late page can't append to stale results.
private var loadMoreJob: Job? = null
// Debounced reactive-filter pass. Each keystroke cancels the prior
// one; the survivor waits FILTER_DEBOUNCE_MS then scans the pool off
// the main thread. A submit cancels it so a late preview can't
// clobber a fresh live result set.
private var filterJob: Job? = null
fun onQueryChange(q: String) {
// Clear any prior error state when the user resumes typing —
// a failed submit's banner used to persist into the next
// reactive preview, looking like the new query had failed.
// audit Q3.
_ui.update { it.copy(query = q, error = null) }
if (Settings.get().cacheEnabled.value && q.trim().length >= 2) {
val matches = reactiveFilter(q.trim())
if (matches.isNotEmpty()) {
// Cache preview replaces the live set → kill its pagination
// and drop the token (cache views don't paginate).
loadMoreJob?.cancel()
_ui.update {
it.copy(
results = matches,
fromCache = true,
loading = false,
continuation = null,
loadingMore = false,
)
}
} else if (_ui.value.fromCache) {
loadMoreJob?.cancel()
_ui.update {
it.copy(results = emptyList(), fromCache = false, continuation = null)
val trimmed = q.trim()
// Restart the debounce on every keystroke. audit 3.2 —
// reactiveFilter walks the whole cached-results pool (thousands
// of items on a heavy user) and used to run synchronously on the
// main thread per keystroke. Now: debounce, then scan off-Main.
filterJob?.cancel()
if (Settings.get().cacheEnabled.value && trimmed.length >= 2) {
filterJob = viewModelScope.launch {
delay(FILTER_DEBOUNCE_MS)
val matches = withContext(Dispatchers.Default) { reactiveFilter(trimmed) }
// A submit (or a newer keystroke) may have moved on while
// we debounced/scanned — only touch the result set if this
// is still the query the user is looking at.
ensureActive()
if (_ui.value.query.trim() != trimmed) return@launch
if (matches.isNotEmpty()) {
// Cache preview replaces the live set → kill its
// pagination and drop the token (cache views don't
// paginate).
loadMoreJob?.cancel()
_ui.update {
it.copy(
results = matches,
fromCache = true,
loading = false,
continuation = null,
loadingMore = false,
)
}
} else if (_ui.value.fromCache) {
loadMoreJob?.cancel()
_ui.update {
it.copy(results = emptyList(), fromCache = false, continuation = null)
}
}
}
} else if (q.isBlank()) {
@ -178,6 +200,10 @@ class SearchViewModel : ViewModel() {
// A fresh submit invalidates any in-flight pagination of the
// previous result set; drop its token until page 1 lands.
loadMoreJob?.cancel()
// Kill any debounced reactive-filter preview too — its query is
// (or is about to be) this same string, so without this it would
// fire ~150ms later and clobber the live submit's result set.
filterJob?.cancel()
if (cached != null && cached.isNotEmpty()) {
_ui.update {
@ -348,4 +374,13 @@ class SearchViewModel : ViewModel() {
.take(60)
.toList()
}
private companion object {
/**
* Debounce window before scanning the cached-results pool on a
* keystroke. Long enough to skip the intermediate states of a
* fast typist, short enough to feel instant. audit 3.2.
*/
const val FILTER_DEBOUNCE_MS = 150L
}
}