diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 9eaa1d4b7..e8c09b9ab 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index 60fb52376..ce36b558b 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -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() 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> { it.second } .thenByDescending { it.first.viewCount }, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index 21bba571c..385fcf904 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -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 + } }