Repository navigation
Conversation
|
Updated the description with a benchmark, because the original time-to-first-paint claim in this PR was wrong and I would rather say so than leave it standing. Clean profile (Playwright-driven Chrome, no extensions), production
First paint: no measurable difference. Total blocking time: consistently lower with chunking. So this is a responsiveness change, not a load-time change. The reason the first-paint argument fails is worth recording for #1765: in every run Happy to close this if a lower blocking time alone is not worth the extra merges. |
fetchSessions decrypted every session's metadata + agentState up front and called applySessions once. On web (JS crypto, no native offload) that blocks the main thread for seconds before anything paints, and the freeze grows with the account size. applySessions merges rather than replaces the list, so applying in chunks accumulates safely. Decrypt newest-first in chunks of 24, applying each chunk incrementally and yielding to the event loop between chunks: the viewport sessions paint after the first chunk while the rest fill in with the thread responsive to paint and input. The thinking-state snapshot is taken synchronously right before each chunk's apply (unchanged semantics), so live sessions keep their pulsing state across a full refetch. Refs slopus#1765 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
e96a0fa to
11fb5e7
Compare
What
Decrypt and apply the session list in chunks (newest first) instead of decrypting every session up front and applying once.
Why
fetchSessions(sources/sync/sync.ts) decrypts every session'smetadata+agentStatein one sequentialawaitloop, accumulates them, and callsapplySessionsa single time at the end. So nothing is applied to the store until the whole dataset is decrypted, and the main thread is held for the duration.Change
applySessions(sources/sync/storage.ts) merges into the existing session map rather than replacing it, so applying in chunks accumulates correctly. Using that:updatedAtdesc) and decrypt them in chunks of 24.awaitasetTimeout(0)between chunks to yield the main thread to paint and input.The thinking-state snapshot (
storage.getState().sessions, preserving live per-sessionthinking/thinkingAtacross a full refetch) is taken synchronously right before each chunk's apply — same invariant as before, still not taken inside the awaiting decrypt loop.Single-file change (
sync.ts),pnpm typecheckclean.Measured (updated — the original claim was wrong)
This PR was originally argued from the code path as a time-to-first-paint win. I have now benchmarked it, and that claim does not hold. Keeping the honest numbers here rather than the original reasoning.
Setup: production
expo exportbuild served locally, dev server, 150 sessions with production-sized metadata (12.5 KB plaintext / 16.7 KB encrypted-base64 each). Chrome driven by Playwright on a clean profile with no extensions. Both variants selected at runtime on the same bundle via a temporaryCHUNK_SIZEoverride (24vs one huge chunk = the pre-PR behaviour).firstRow= first session row present in the DOM,ingestDone= end offetchSessions, long tasks viaPerformanceObserver,totalBlocking= sum ofmax(0, duration - 50ms). Median of 3 runs.Time to first paint is unchanged — the differences are inside run-to-run variance. The one real, repeatable effect is total blocking time: 158 vs 267 ms on desktop, 2079 vs 2295 ms throttled (2011 vs 2783 ms in an earlier run of the same setup). The work is not reduced, it is split up: the page stays responsive to paint and input during ingest, but it does not get there sooner.
Why the first-paint argument fails
In every single run,
firstRowis greater thaningestDone— the first row paints only after the whole ingest has finished, in all four combinations and on both a light and a production-sized dataset. So the premise behind chunking ("paint the first chunk while the rest decrypt") never materialises: paint is not gated on the decrypt loop, it is gated on something downstream (React/FlashList commit, navigation boot) that starts once ingest is done anyway.Also worth noting for #1765:
AES256Encryption.decrypt(sources/sync/encryption/encryptor.ts) already batches throughPromise.all, so the AES-GCM work is largely off the main thread. What the main thread actually spends the ingest on isJSON.parse, zodsafeParseand rendering.So: merge this if a lower blocking time during startup is worth a few extra
applySessionsmerges — it is a responsiveness change, not a load-time change. But the real startup win for #1765 is in the window betweeningestDoneand first paint, which this PR does not touch.Deployed and running on a self-hosted web instance without regressions (session list, live
thinkingdots, ordering all correct).Related
fetchOlderMessagesInBackgroundprefetch bound), which named this synchronous full-list decrypt as the remaining startup hotspot.Note
CHUNK_SIZE = 24is a plain paint-vs-merge-overhead pick, not tuned.🤖 Generated with Claude Code