Harden timeline windowing and default it on for phones via config omission semantics - #2391
Harden timeline windowing and default it on for phones via config omission semantics#2391vburojevic wants to merge 5 commits into
Conversation
TimelineWindowedItems re-read its scroll geometry (two getBoundingClientRect calls plus scrollTop) in a dependency-less layout effect after every React commit, which forced a layout per streaming delta. Replace it with a geometry-revision scheme: expand/collapse paths (ExpandableTimelineRow's isExpanded transitions) bump a revision through TimelineWindowingGeometryInvalidateContext, and the windowed list re-reads only when the revision, its item count, or scroll-root usability changes. A trigger-string ref leaves the mount read to the ResizeObserver effect so each trigger costs one geometry read; the 0.5px setScrollMargin guard is unchanged. New tests prove commits without a geometry trigger no longer force a layout read, that a revision bump re-reads and repositions the window when the owning row's expansion moves a nested list, and that a real row toggle inside ThreadTimelineRows still refreshes geometry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First open of a long thread on a cold connection paid a serialized boot parse -> route chunk -> windowed chunk download while the Suspense fallback mounted and measured every loaded row, then remounted the whole list once TanStack landed. Warm the TimelineWindowedItems chunk from App.tsx next to the SplitWorkspaceRoute preload (still a dynamic import, so it stays out of the boot closure), and bound the captureMeasurements fallback to the trailing 60 rows - the bottom-anchor region a thread opens into. Verified with the timeline suites and check-bundle-budget: boot payload 443.2 / 467.8 KiB brotli, and the TimelineWindowedItems chunk appears in neither bootChunks nor the SplitWorkspaceRoute closure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 40-row compact minimum and 8-row overscan were desktop numbers: 39 rows on a phone never windowed at all, and 16 retained rich rows of overscan is most of a phone's DOM budget. Window top-level lists from 16 rows on compact viewports (desktop stays 60, nested stays 20) and halve overscan to 4 there, using the same useIsCompactViewport signal that already drives the row minimum. Verified with new tests: a 20-row compact top-level timeline now windows, and the compact render retains exactly one overscan side's worth fewer wrappers than the desktop render of the same geometry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phones are where the unwindowed timeline hangs (1,519-5,249 DOM nodes on a 402x874 viewport in mobile hang traces), yet windowing shipped behind a default-off experiment. Resolve the enablement at ThreadTimelineSurface as `experiment ?? isCompactViewport`: compact viewports window whenever the experiment is unset, an explicitly false experiment still disables windowing everywhere (the kill switch), and desktop keeps the served value. `defaultExperiments.timelineWindowing` stays false, so server value semantics are unchanged. New surface tests assert compact default-on (fails before this change), the explicit-false opt-out, the unchanged desktop default, and the explicit-true opt-in. docs/configuration.md and the bb-cli skill surfaces now describe the compact default and kill-switch semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The config payload materialized every experiment from defaultExperiments, so a client could not distinguish "user chose false" from "user never chose" - and the compact-viewport windowing default rests on exactly that distinction: a fresh install's served false would defeat default-on before the user ever touched Settings. Omission now carries the meaning (AGENTS.md's optional-field rule: real semantics, not a hidden default). The /system/config experiments payload becomes a partial record (storedExperimentsSchema / getStoredExperiments) serving persisted rows only; setExperiments still writes every key on a save, so after the first explicit choice the stored values win everywhere. Server-internal policy (runtime policy, edit-message gating) keeps reading concrete booleans through getExperiments. Consumers that need a concrete boolean overlay defaultExperiments at their boundary: the Settings views (web and mobile), the editMessages readers (its default is true, so `?? false` would have regressed fresh installs), and the CLI's experiment update; the connect plugin's mobileApp gate fails closed. The offline fallback config omits timelineWindowing so an unreachable server still yields the compact windowing default. The experiments PUT contract is unchanged, and nothing crosses the server/host-daemon wire, so HOST_DAEMON_PROTOCOL_VERSION is untouched. New tests: /system/config omits never-saved experiments while getExperiments stays concrete (fails before: the payload carried all five defaults); getStoredExperiments returns only persisted rows; a compact surface windows when the payload omits the key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Closing this PR for rework after an adversarial review of the #2385–#2393 set. Method: two independent multi-agent review passes over a worktree at this PR head; every finding went to three independent refuters (code trace, a throwaway experiment against the real code, and an impact judge), and only findings that survived at least two of three are listed. The branch stays as-is; please reopen this PR or open a new one when the blocking items are addressed. Blocking
Also found
Suggested salvageSplit it. The windowing hardening and the phone default are sound: fix the geometry invalidation (re-read after the transition or from the deferred body mount / the shared observer write phase, plus a scroll-idle self-heal), render pinned rows in the fallback, and land behind the existing experiment. The default-on rollout needs a contract that survives old clients: keep
|
What was wrong
The thread timeline virtualizer (#2011, hardened by #2260/#2300) was sound but not shippable on phones.
TimelineWindowedItemsre-read scroll geometry in a dependency-less layout effect after every React commit — a forced layout per streaming delta. The loader's Suspense fallback mounted and measured every loaded row while the TanStack chunk downloaded. The thresholds were desktop numbers (compact minimum 40 rows, overscan 8), so a 39-row phone thread never windowed. And the feature shipped behind a default-off experiment, leaving production phones with 1,519–5,249 DOM nodes on a 402×874 viewport — the #1 SPA-owned cause of the 10–39s mobile hangs. Defaulting it on for phones was blocked by the config payload itself:/system/configmaterialized every experiment fromdefaultExperiments, so a client could not distinguish "user chose false" from "user never chose".What changed
TimelineWindowingGeometryInvalidateContext; windowed lists re-read geometry only when the revision, item count, or scroll-root usability changes — one read per trigger, 0.5pxsetScrollMarginguard unchanged.SplitWorkspaceRoutepreload (still a dynamic import outside both closures); thecaptureMeasurementsfallback mounts only the trailing 60 rows.minItemCount40 → 16 (desktop 60, nested 20 unchanged); overscan 8 → 4 on compact viewports.ThreadTimelineSurface: windowing resolves asexperiment ?? isCompactViewport— compact windows whenever the experiment is unset, explicit false still disables everywhere (the kill switch), desktop keeps the served value./system/configserves only persisted experiment choices (storedExperimentsSchemapartial record,getStoredExperiments);setExperimentsstill persists every key on save, so the first explicit save wins everywhere after. Server-internal policy keeps concrete booleans viagetExperiments. Consumers needing concrete values overlaydefaultExperimentsat their boundary (Settings on web and mobile,editMessagesreaders whose default is true, the CLI update); the connect plugin's mobileApp gate fails closed; the offline fallback omitstimelineWindowingso an unreachable server still yields the compact default. The experiments PUT contract is unchanged. Nothing crosses the server↔host-daemon wire.docs/configuration.md§ Experiments, bb-cli skill surfaces.How you verified
Fail-before/pass-after: geometry-trigger test (
expected 4 to be 3— a forced layout read per commit before); compact-default surface test (expected 'false' to be 'true'before); omission route test (expected { changelogPreview: false, …(4) } to deeply equal {}before), which also assertsgetExperimentsstays concrete. Full suites:@bb/app3,352;@bb/server2,002;@bb/db406;@bb/mobile846; connect plugin 90.check:bundlegreen with the windowed chunk outside both closures. Merged-tree WebKit QA on the perf fixture (1,408-event thread, iPhone viewport): 9 mounted rows / 1,362 DOM nodes with no experiment set (compact default active end-to-end), scroll up/back with no blanking, desktop stays unwindowed (107 rows), zero console errors.