Skip to content

Patch sidebar rows from change metadata; weak ETag; search keepPreviousData (B1, B27, E8) - #1902

Closed
SawyerHood wants to merge 4 commits into
bb/mobile-perf/diff-and-file-previewfrom
bb/mobile-perf/sidebar-bootstrap-patch
Closed

Patch sidebar rows from change metadata; weak ETag; search keepPreviousData (B1, B27, E8)#1902
SawyerHood wants to merge 4 commits into
bb/mobile-perf/diff-and-file-previewfrom
bb/mobile-perf/sidebar-bootstrap-patch

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

What was wrong

  • Every thread status, title, pin, environment or background-activity change made the browser refetch the unbounded GET /sidebar-bootstrap (all non-archived threads across all projects). status-changed flushes immediately and fires at least twice per turn, so a phone re-downloaded the whole sidebar several times per turn (B1).
  • The sidebar search panel replaced its rows with a spinner while debouncing/fetching and remounted every row per keystroke (B27).
  • Tapping a search result ran navigate, the drawer close and 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

  • Server: NotificationHub.setThreadChangeMetadataEnricher (installed by createApp) attaches the thread's current ThreadListEntry as metadata.listEntry to 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.
  • Server: /sidebar-bootstrap serialises once, sends ETag: W/"..." + Cache-Control: private, no-cache, and returns a bodyless 304 for a matching If-None-Match (services/lib/weak-etag.ts).
  • Domain: threadChangeMetadataSchema.listEntry (strict) and a lenient inbound twin that drops an unparseable row (.catch(undefined)).
  • Client: patchThreadListRowsFromListEntry replaces 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-changed and background-activity events-appended use it.
  • Client: useThreadSearch keeps previous data (keepPreviousData, isPlaceholderData); the panel keeps rows mounted, marks the listbox aria-busy, and shows an inline updating spinner.
  • Client: useSidebarThreadSearch defers leaving search mode to after the tap commit paints (rAF + macrotask), cancellable on re-activation and unmount.

How you verified

  • New tests (fail before, pass after): 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).
  • Full @bb/server suite: 1734 passed; the 2 failures are unrelated and pre-exist on the clean base (internal-skill-trees file-mode 420 vs 436 umask on this machine; a 5 s timeout in plugin-service.test.ts that passes in isolation). @bb/domain, @bb/db, @get-bb/plugin-sdk, @bb/templates suites pass; @bb/app src/hooks, src/lib/ws, src/components/sidebar, src/components/layout pass (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).

  • Prerequisite (layer below): bb/mobile-perf/diff-and-file-preview (Fetch diff-card context on demand; virtualize and cap the file preview (G2, G3) #1901).
  • Next layer: bb/mobile-perf/thread-bootstrap-include (Bundle per-thread reads into GET /threads/:id?include= and seed the caches (B5) #1903).
  • Audit findings addressed: see title IDs. Review: approved-with-fixes; 1 review fix commit(s).
  • Reviewer notes / follow-ups: E8 is implemented as 'defer the search-mode flip' only (rAF + setTimeout 0 after the tap commit); the audit's alternative 'keep the thread list mounted during search' was not done, | Sidebar search still invalidates the thread-search prefix on every patched status/title/pin change and every events-appended flush (B18 territory, out of scope here); with keepPrev
  • Wire/contract: Server->browser/SDK only: ThreadChangeMetadata gains optional listEntry: 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 adds ETag: W/"<sha1-base64url>", Cache-Control: private, no-cache and 304 on If-None-Match. @bb/db gains `getThr

AGENT GENERATED: by Claude Opus 5

@SawyerHood SawyerHood changed the title bb/mobile perf/sidebar bootstrap patch Patch sidebar rows from change metadata; weak ETag; search keepPreviousData (B1, B27, E8) Aug 19, 2026
@SawyerHood
SawyerHood force-pushed the bb/mobile-perf/sidebar-bootstrap-patch branch from b8b9615 to ec7b0fd Compare August 19, 2026 07:42
SawyerHood and others added 4 commits August 19, 2026 07:50
…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
SawyerHood force-pushed the bb/mobile-perf/sidebar-bootstrap-patch branch from ec7b0fd to d2b37f3 Compare August 19, 2026 07:50
@SawyerHood
SawyerHood marked this pull request as ready for review August 19, 2026 08:04
@SawyerHood
SawyerHood marked this pull request as draft August 19, 2026 15:09
@SawyerHood

Copy link
Copy Markdown
Collaborator Author

Parked for now: the maintainer plans to land layers 1–22 (#1880#1901) first. This layer stays a draft until an iOS device/Simulator pass covers it (see the verification report in the bb thread). Do not merge yet.

@SawyerHood

Copy link
Copy Markdown
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.

@SawyerHood SawyerHood closed this Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant