Patch sidebar rows from change metadata; weak ETag; search keepPreviousData (B1, B27, E8) - #1902
Closed
SawyerHood wants to merge 4 commits into
Closed
Conversation
This was referenced Aug 19, 2026
SawyerHood
force-pushed
the
bb/mobile-perf/sidebar-bootstrap-patch
branch
from
August 19, 2026 07:42
b8b9615 to
ec7b0fd
Compare
…sidebar-bootstrap Every thread status, title, pin, environment or background-activity change made the browser refetch the unbounded GET /sidebar-bootstrap (every non-archived thread across all projects); status-changed flushes immediately and fires at least twice per turn, so a phone on a slow link re-downloaded the whole sidebar several times per turn. Server: the hub accepts a thread-change metadata enricher, installed by createApp, that attaches the thread's current ThreadListEntry (`metadata.listEntry`) to row-only changes (status-changed, title-changed, pin-state-changed, environment-changed, and events-appended with backgroundActivityChanged) when a client socket is subscribed to the list or that thread. Membership changes (create, delete, archive, parent) carry no row. The route now serialises once, sends `ETag: W/"<sha1>"` + `Cache-Control: private, no-cache` and answers a matching If-None-Match with a bodyless 304, so browser HTTP caches revalidate instead of re-downloading identical payloads. Client: `patchThreadListRowsFromListEntry` replaces the cached row in every thread list and the sidebar navigation in place; it falls back to the previous invalidation when the row is absent (older server), when the thread is not in the cached sidebar, when a bootstrap fetch is in flight, or when the row left the sidebar membership. The lenient inbound schema drops an unparseable row instead of the whole message. Server->browser only; nothing on the host-daemon wire changed. Co-Authored-By: Claude <noreply@anthropic.com>
The sidebar search panel dropped every result row to a spinner while the debounce and the next fetch ran, then remounted all rows on each keystroke. On phones that meant a flash plus a full row rebuild per character. useThreadSearch now uses `placeholderData: keepPreviousData` and exposes `isPlaceholderData`; the panel renders the previous rows in place, keeps their keys, marks the listbox `aria-busy`, and shows a small updating spinner in the section header. The full-width "Searching threads..." message is only used when there are no rows to keep. Co-Authored-By: Claude <noreply@anthropic.com>
Selecting a search result ran navigate, the mobile drawer close and `setIsActive(false)` in one event handler, so React committed the thread view mount, the drawer closing flip and the full thread-list remount (rows, drag contexts, windowing observers) in a single task before the first slide-out frame could paint. On phones with a large sidebar that made tap-to-thread feel laggy. The hook now opens the thread and starts the drawer close in the tap commit and flips search off one frame plus a macrotask later, so the list remount lands after the frame is presented (and, on phones, while the drawer is sliding away). A reopened search cancels the pending flip so it cannot wipe a fresh query; unmount cancels it too. Co-Authored-By: Claude <noreply@anthropic.com>
… refetches Realtime invalidations refetch the open search under the same key on every events-appended flush of any streaming thread. With isFetching in the updating condition the header spinner and aria-busy would toggle continuously while a thread streams. Count only debounce, a lagging debounced query, placeholder data from the previous key, or the first load as updating. The archived-threads settings list has no in-place cue, so it treats placeholder data as loading to keep its prior behavior. Co-Authored-By: Claude <noreply@anthropic.com>
SawyerHood
force-pushed
the
bb/mobile-perf/sidebar-bootstrap-patch
branch
from
August 19, 2026 07:50
ec7b0fd to
d2b37f3
Compare
SawyerHood
marked this pull request as ready for review
August 19, 2026 08:04
SawyerHood
marked this pull request as draft
August 19, 2026 15:09
Collaborator
Author
Collaborator
Author
|
Closing: the maintainer decided to land only layers 1–22 of the mobile-perf stack (#1880–#1901). The branch stays on the remote for reference; it can be reopened and rebased later if the remaining big rocks (sidebar-bootstrap patching, thread bootstrap include=, composer draft push-down, timeline windowing, persisted cache, service worker) are picked up again. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
GET /sidebar-bootstrap(all non-archived threads across all projects).status-changedflushes immediately and fires at least twice per turn, so a phone re-downloaded the whole sidebar several times per turn (B1).setIsActive(false)in one event handler, so React committed the thread mount, the drawer closing flip and the full thread-list remount in one task before the first slide-out frame could paint (E8).What changed
NotificationHub.setThreadChangeMetadataEnricher(installed bycreateApp) attaches the thread's currentThreadListEntryasmetadata.listEntryto row-only changes when a client is subscribed to the list or the thread. Membership changes carry no row. Enrichment never throws; failures fall back to the original metadata./sidebar-bootstrapserialises once, sendsETag: W/"..."+Cache-Control: private, no-cache, and returns a bodyless 304 for a matchingIf-None-Match(services/lib/weak-etag.ts).threadChangeMetadataSchema.listEntry(strict) and a lenient inbound twin that drops an unparseable row (.catch(undefined)).patchThreadListRowsFromListEntryreplaces the row in every cached thread list and the sidebar navigation (updateCachedThreadListEntry); it falls back to the previous invalidation when the row is absent, the thread is not in the cached sidebar, a bootstrap fetch is in flight, or the row left the sidebar membership.status-changed,title-changed,pin-state-changed,environment-changedand background-activityevents-appendeduse it.useThreadSearchkeeps previous data (keepPreviousData,isPlaceholderData); the panel keeps rows mounted, marks the listboxaria-busy, and shows an inline updating spinner.useSidebarThreadSearchdefers leaving search mode to after the tap commit paints (rAF + macrotask), cancellable on re-activation and unmount.How you verified
apps/server/test/threads/thread-change-metadata.test.ts(row equals the bootstrap row; title/pin changes carry rows; created/plain events do not; missing thread keeps metadata),apps/server/test/public/public-sidebar-bootstrap-etag.test.ts(ETag headers, 304, rotation after change, If-None-Match matcher),apps/server/test/app/hub.test.ts(enricher gate on subscribers),packages/domain/test/change-kinds.test.ts(maximal fixture + lenient drop of unparseable row),apps/app/src/hooks/realtime-cache-effects.test.ts(7 patch/fallback cases with a real QueryClient),apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx(rows keep DOM identity while updating; empty-state message),apps/app/src/components/sidebar/useSidebarThreadSearch.test.tsx(deferred flip; cancel on reopen).pnpm exec turbo run typecheck lint --filter=@bb/app --filter=@bb/server --filter=@bb/db --filter=@bb/domain(0 errors), typecheck for@bb/sdk,@bb/server-contract,@bb/templates,@get-bb/plugin-sdk(bundled d.ts regenerated and checked).@bb/serversuite: 1734 passed; the 2 failures are unrelated and pre-exist on the clean base (internal-skill-treesfile-mode 420 vs 436 umask on this machine; a 5 s timeout inplugin-service.test.tsthat passes in isolation).@bb/domain,@bb/db,@get-bb/plugin-sdk,@bb/templatessuites pass;@bb/appsrc/hooks,src/lib/ws,src/components/sidebar,src/components/layoutpass (49 + 43 files).Fixes: part of the mobile / iOS Safari performance program (verified sweep report in the bb thread; no single issue).
Stack context
Layer 23 of 28 in the
bb/mobile-perf/*stack (bottom → top: quick wins first, big rocks last).bb/mobile-perf/diff-and-file-preview(Fetch diff-card context on demand; virtualize and cap the file preview (G2, G3) #1901).bb/mobile-perf/thread-bootstrap-include(Bundle per-thread reads into GET /threads/:id?include= and seed the caches (B5) #1903).ThreadChangeMetadatagains optionallistEntry: ThreadListEntry(strict outgoing schema; lenient inbound schema uses.optional().catch(undefined)so an unparseable row drops to a refetch instead of dropping the message). GET /api/v1/sidebar-bootstrap addsETag: W/"<sha1-base64url>",Cache-Control: private, no-cacheand 304 on If-None-Match.@bb/dbgains `getThr