22title : ' hub-client `useEffect` audit: phased refactor'
33date : 2026-10-24
44description : ' Phased refactor of the refactor-worthy useEffect sites in hub-client: stale-dep fixes, useSyncExternalStore conversions, derived-state elimination, then measured wide-dep performance work.'
5- status : draft
5+ status : in-progress # Phases 1–2 done and open as stacked PRs (#924, #925); Phases 3–5 not started.
66---
77
88## Overview
@@ -62,7 +62,7 @@ positive, `FileSidebar.tsx:280`, was dropped from Phase 1).
6262
6363## Phase 1 — Correctness: stale deps, suppressions, leaks
6464
65- ** Done 2026-10-27 (bd-lsf2v5n0).** Findings that shaped the landing:
65+ ** Done 2026-10-27 (bd-lsf2v5n0); open as PR # 924 .** Findings that shaped the landing:
6666
6767- The App.tsx:830 fix landed as a factory
6868 (` src/services/connectionHandlers.ts#createConnectionChangeHandler ` )
@@ -123,6 +123,54 @@ positive, `FileSidebar.tsx:280`, was dropped from Phase 1).
123123
124124## Phase 2 — ` useSyncExternalStore ` conversions
125125
126+ ** Done 2026-10-27 (bd-06s9sufx); open as PR #925 , stacked on #924 .** Findings that shaped the landing:
127+
128+ - The ` branchService ` prerequisite landed as a version counter plus per-path
129+ snapshot cache (` getBranchSnapshot(path) ` ), covering ` branches ` and
130+ ` activeBranchId ` in one object. Every mutation already funneled through
131+ ` notify() ` , which now bumps the version; the cache also re-resolves when
132+ the path\' s docId changes (a file handle can appear without any
133+ branchService event, and must not pin the empty snapshot). The
134+ push-model escape hatch was not needed.
135+ - ` usePresence ` needed the same memoization one layer down:
136+ ` getRemotePresences() ` returned a fresh array per call, so the service
137+ now memoizes it on a version bumped at the top of ` notifySubscribers ` —
138+ before the visibility gate, so data freshness is not deferred even when
139+ subscriber callbacks are. One intended semantic difference from the old
140+ push model: an unrelated re-render while the tab is hidden now reads
141+ fresh presence data rather than the last pushed array (fresher, never
142+ staler). ` enabled: false ` keeps its old behavior via a shared
143+ ` EMPTY_REMOTE_USERS ` snapshot.
144+ - ` usePreference ` converted directly: all preference values are primitives,
145+ so ` getPreference(key) ` is a stable snapshot by value, and the writer\' s
146+ local echo was redundant (` setPreference ` dispatches synchronously).
147+ - ` useNow(intervalMs) ` is one shared ticking store per interval (interval
148+ runs only while subscribed; clock refreshes on resubscribe so a remount
149+ never reads a stale timestamp). ` useOnlineStatus() ` is the textbook
150+ ` navigator.onLine ` + online/offline ` useSyncExternalStore ` . Both
151+ ` SyncStatusBadge ` and ` ConnectionStatusDialog ` now use both hooks; the
152+ badge\' s old immediate-refresh-on-online-event is approximated by the
153+ connectivity flip re-rendering and the clock catching up within one
154+ tick (≤ 1 s, imperceptible for "n minutes ago" text).
155+ - Characterization tests were pinned before touching each uncovered site:
156+ ` useDocBranches.test.tsx ` and ` ConnectionStatusDialog.test.tsx ` (the
157+ dialog had no coverage). A stale-cleanup memoization test was drafted
158+ and dropped: existing ` stale presence cleanup ` tests already cover
159+ expiry, and its interval predates ` vi.useFakeTimers() ` in the test.
160+ - Verification: ` npm run test:ci ` unit and integration legs green; the
161+ WASM leg has 3 golden-parity failures (` pandocBook ` , ` pandocRequest ` ,
162+ ` smokeAll ` ) that reproduce identically on the clean tree — the
163+ pre-existing announcement-string skew Phase 1 documented, unrelated to
164+ this phase. ** Root-caused 2026-10-27 on rebase:** the skew was a stale
165+ local WASM build against post-announcement goldens; after `npm run
166+ build: wasm ` all three files pass, and the full ` test: ci ` on the rebased
167+ stack is green (1720 unit + 225 integration + 434 WASM). `npm run
168+ build` green; eslint clean on touched files (one pre-existing
169+ ` usePresence ` ref-cleanup warning, unchanged). Not
170+ browser-smoked: no browser session was run for this phase (refactor
171+ with characterization tests; the badge/dialog render paths are covered
172+ by ` SyncStatusBadge.test.tsx ` and the new dialog test).
173+
126174Only ` useDocBranches ` can actually tear under concurrent rendering: it bumps
127175a version counter in the subscription callback and re-reads ` branchService `
128176during render. ` usePreference ` and ` usePresence ` instead push the data into
@@ -131,7 +179,7 @@ tearing; converting them is simplification, not a correctness fix, and they
131179are safe to defer. Replace with ` useSyncExternalStore ` , following the
132180existing in-repo template.
133181
134- - [ ] Prerequisite: ` branchService ` memoizes its snapshots (version counter
182+ - [x ] Prerequisite: ` branchService ` memoizes its snapshots (version counter
135183 plus cached per-path snapshot) so ` getSnapshot ` returns a stable reference
136184 — ` getBranches(path) ` currently returns a fresh array per call, which
137185 ` useSyncExternalStore ` cannot tolerate. The snapshot must cover everything
@@ -142,14 +190,14 @@ existing in-repo template.
142190 ` getBranches(path) ` and ` getActiveBranchId(path) ` , with ` subscribe `
143191 updating both — also eliminates tearing and has no snapshot-stability
144192 requirement.
145- - [ ] ` src/hooks/useDocBranches.ts:15 ` — replace
193+ - [x ] ` src/hooks/useDocBranches.ts:15 ` — replace
146194 ` useEffect(() => subscribe(...), []) ` + ` setVersion ` with
147195 ` useSyncExternalStore ` .
148- - [ ] ` src/hooks/usePreference.ts:40 ` — convert the custom-event
196+ - [x ] ` src/hooks/usePreference.ts:40 ` — convert the custom-event
149197 subscription (simplification, not a tear-fix; safe to defer or drop).
150- - [ ] ` src/hooks/usePresence.ts:185 ` — convert the ` onPresenceChange `
198+ - [x ] ` src/hooks/usePresence.ts:185 ` — convert the ` onPresenceChange `
151199 subscription (simplification, not a tear-fix; safe to defer or drop).
152- - [ ] Add two shared hooks: ` useNow(intervalMs) ` (one ticking store on
200+ - [x ] Add two shared hooks: ` useNow(intervalMs) ` (one ticking store on
153201 ` useSyncExternalStore ` ) and ` useOnlineStatus() ` (` navigator.onLine ` plus
154202 online/offline events — a textbook ` useSyncExternalStore ` ). Replace the
155203 duplicated 1 s ticker + online/offline listener blocks in
@@ -158,7 +206,7 @@ existing in-repo template.
158206 connectivity during render (` SyncStatusBadge.tsx:122 ` reads
159207 ` navigator.onLine ` at render time, today freshened only by the ticker
160208 re-render; ` useOnlineStatus() ` turns that into a proper subscription).
161- - [ ] ` src/components/debug-inspector/DebugInspectorPanel.tsx:75 ` and ` :99 ` —
209+ - [x ] ` src/components/debug-inspector/DebugInspectorPanel.tsx:75 ` and ` :99 ` —
162210 document why polling is intentional in a debug-only panel (a code comment
163211 is enough); do not convert.
164212
0 commit comments