Skip to content

Commit c2096bc

Browse files
committed
Convert subscriptions to useSyncExternalStore (bd-06s9sufx)
- useDocBranches: replace version-bump re-render with useSyncExternalStore over new memoized per-path branchService snapshots (getBranchSnapshot) — the one site that could actually tear under concurrent rendering - usePreference: convert the custom-event subscription; the writer's local echo was redundant (setPreference dispatches synchronously) - usePresence: convert onPresenceChange; presenceService now memoizes getRemotePresences on a version bumped before the visibility gate, so data freshness is never deferred, only notifications - Add shared useNow(intervalMs) (one ticking store per interval) and useOnlineStatus() hooks; replace the duplicated 1s ticker + online/ offline blocks in SyncStatusBadge and ConnectionStatusDialog - Document the intentional polling in DebugInspectorPanel (debug-only) Characterization tests pinned first for the uncovered sites (useDocBranches, ConnectionStatusDialog). Phase 2 of claude-notes/plans/2026-10-24-hub-client-useeffect-refactor.md.
1 parent 3d8286f commit c2096bc

18 files changed

Lines changed: 716 additions & 87 deletions

‎claude-notes/plans/2026-10-24-hub-client-useeffect-refactor.md‎

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: 'hub-client `useEffect` audit: phased refactor'
33
date: 2026-10-24
44
description: '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+
126174
Only `useDocBranches` can actually tear under concurrent rendering: it bumps
127175
a version counter in the subscription callback and re-reads `branchService`
128176
during render. `usePreference` and `usePresence` instead push the data into
@@ -131,7 +179,7 @@ tearing; converting them is simplification, not a correctness fix, and they
131179
are safe to defer. Replace with `useSyncExternalStore`, following the
132180
existing 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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Characterization tests for ConnectionStatusDialog, pinned before the
3+
* useNow/useOnlineStatus conversion (bd-06s9sufx): the dialog refreshes
4+
* its relative-time cells once per second while open, and reflects the
5+
* browser's online/offline state.
6+
*
7+
* @vitest-environment jsdom
8+
*/
9+
10+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
11+
import { render, screen, act, cleanup } from '@testing-library/react';
12+
import ConnectionStatusDialog from './ConnectionStatusDialog';
13+
14+
const mocks = vi.hoisted(() => ({
15+
lastRemoteChangeAt: null as number | null,
16+
}));
17+
18+
vi.mock('@quarto/preview-runtime', () => ({
19+
getConnectionInfo: () => ({
20+
wsReadyState: 1,
21+
wsUrl: null,
22+
peers: [{ peerId: 'peer-1' }],
23+
}),
24+
getIndexHandle: () => ({ documentId: 'doc-index' }),
25+
getFileHandle: () => ({ documentId: 'doc-file' }),
26+
}));
27+
28+
vi.mock('@quarto/quarto-sync-client', () => ({
29+
getDocSyncActivityWithPersisted: () => ({
30+
lastSyncMessageAt: null,
31+
lastEphemeralMessageAt: null,
32+
lastRemoteChangeAt: mocks.lastRemoteChangeAt,
33+
lastLocalChangeAt: null,
34+
lastLocalDeliveredAt: null,
35+
}),
36+
getDocRemoteChange: () => null,
37+
getConnectionLog: () => [],
38+
}));
39+
40+
describe('ConnectionStatusDialog', () => {
41+
beforeEach(() => {
42+
vi.useFakeTimers();
43+
});
44+
45+
afterEach(() => {
46+
cleanup();
47+
vi.useRealTimers();
48+
localStorage.clear();
49+
});
50+
51+
it('refreshes relative-time cells once per second while open', () => {
52+
mocks.lastRemoteChangeAt = Date.now();
53+
render(<ConnectionStatusDialog currentFilePath="report.qmd" onClose={() => {}} />);
54+
55+
// Both columns (project + file) show the just-landed change.
56+
const initial = screen.getAllByText('0s ago');
57+
expect(initial.length).toBeGreaterThan(0);
58+
59+
act(() => {
60+
vi.advanceTimersByTime(65_000);
61+
});
62+
63+
expect(screen.getAllByText('1m ago').length).toBeGreaterThan(0);
64+
expect(screen.queryAllByText('0s ago')).toHaveLength(0);
65+
});
66+
67+
it('shows the browser network state, refreshed on offline/online events', () => {
68+
const onLine = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);
69+
render(<ConnectionStatusDialog currentFilePath="report.qmd" onClose={() => {}} />);
70+
expect(screen.getByText('Online')).toBeDefined();
71+
72+
onLine.mockReturnValue(false);
73+
act(() => {
74+
window.dispatchEvent(new Event('offline'));
75+
});
76+
expect(screen.getByText('Offline')).toBeDefined();
77+
78+
onLine.mockReturnValue(true);
79+
act(() => {
80+
window.dispatchEvent(new Event('online'));
81+
});
82+
expect(screen.getByText('Online')).toBeDefined();
83+
});
84+
});

‎hub-client/src/components/ConnectionStatusDialog.tsx‎

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* Refreshes once per second while open.
99
*/
1010

11-
import { useEffect, useMemo, useState } from 'react';
11+
import { useMemo } from 'react';
1212
import type { ReactNode } from 'react';
1313
import {
1414
getDocSyncActivityWithPersisted,
@@ -21,6 +21,8 @@ import { getFileHandle, getIndexHandle, getConnectionInfo } from '@quarto/previe
2121
import type { Patch } from '@automerge/automerge-repo';
2222
import fastDiff from 'fast-diff';
2323
import ModalDialog from './ModalDialog';
24+
import { useNow } from '../hooks/useNow';
25+
import { useOnlineStatus } from '../hooks/useOnlineStatus';
2426
import { dialogs } from '../strings';
2527
import './ConnectionStatusDialog.css';
2628

@@ -211,25 +213,10 @@ export default function ConnectionStatusDialog({
211213
currentFilePath,
212214
onClose,
213215
}: ConnectionStatusDialogProps) {
214-
const [now, setNow] = useState(() => Date.now());
215-
const [liveBrowserOnline, setLiveBrowserOnline] = useState(() => navigator.onLine);
216-
217-
useEffect(() => {
218-
const refresh = () => {
219-
setNow(Date.now());
220-
setLiveBrowserOnline(navigator.onLine);
221-
};
222-
const intervalId = setInterval(refresh, 1000);
223-
window.addEventListener('online', refresh);
224-
window.addEventListener('offline', refresh);
225-
return () => {
226-
clearInterval(intervalId);
227-
window.removeEventListener('online', refresh);
228-
window.removeEventListener('offline', refresh);
229-
};
230-
}, []);
231-
232-
const browserOnline = liveBrowserOnline;
216+
// 1s clock for the relative-time cells; browser connectivity as a real
217+
// subscription rather than the old poll-navigator.onLine-every-tick.
218+
const now = useNow(1000);
219+
const browserOnline = useOnlineStatus();
233220

234221
// Resolve the two documents of interest; re-evaluated every tick
235222
// because handles appear as the project loads / files open.

‎hub-client/src/components/SyncStatusBadge.tsx‎

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { useEffect, useState } from 'react';
2929
import { getDocSyncActivity } from '@quarto/quarto-sync-client';
3030
import { getFileHandle, getIndexHandle, getConnectionInfo } from '@quarto/preview-runtime';
3131
import ConnectionStatusDialog from './ConnectionStatusDialog';
32+
import { useNow } from '../hooks/useNow';
33+
import { useOnlineStatus } from '../hooks/useOnlineStatus';
3234
import { syncStatus as s } from '../strings';
3335
import './SyncStatusBadge.css';
3436

@@ -79,21 +81,12 @@ export default function SyncStatusBadge({
7981
verbose = false,
8082
dotPosition = 'start',
8183
}: SyncStatusBadgeProps) {
82-
const [now, setNow] = useState(() => Date.now());
84+
// 1s clock for the relative-time text; connectivity as a real
85+
// subscription (the `connected` check below reads it during render).
86+
const now = useNow(1000);
87+
const online = useOnlineStatus();
8388
const [showDialog, setShowDialog] = useState(false);
8489

85-
useEffect(() => {
86-
const refresh = () => setNow(Date.now());
87-
const intervalId = setInterval(refresh, 1000);
88-
window.addEventListener('online', refresh);
89-
window.addEventListener('offline', refresh);
90-
return () => {
91-
clearInterval(intervalId);
92-
window.removeEventListener('online', refresh);
93-
window.removeEventListener('offline', refresh);
94-
};
95-
}, []);
96-
9790
// Resolve the watched doc on every tick; handles appear as the
9891
// project loads / files open.
9992
let docId: string | null = null;
@@ -119,7 +112,7 @@ export default function SyncStatusBadge({
119112

120113
const connInfo = getConnectionInfo();
121114
const connected =
122-
navigator.onLine &&
115+
online &&
123116
connInfo?.wsReadyState === WS_OPEN &&
124117
(connInfo?.peers.length ?? 0) > 0;
125118

‎hub-client/src/components/debug-inspector/DebugInspectorPanel.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ function RefreshPane({
7272
}
7373
}, [get]);
7474
const [value, setValue] = useState<unknown>(read);
75+
// Deliberate polling, not a missing subscription: the report getters
76+
// read ad-hoc debug state with no change event to subscribe to, and
77+
// this is a debug-only panel where a simple interval beats adding a
78+
// notify surface to the inspected services. Do not "modernize".
7579
useEffect(() => {
7680
const t = setInterval(() => setValue(read()), intervalMs);
7781
return () => clearInterval(t);
@@ -96,6 +100,8 @@ function MessagesPane({ am }: { am: QuartoDebugAutomergeApi }) {
96100
}));
97101
}, [am]);
98102
const [entries, setEntries] = useState<MessageLogEntry[]>(read);
103+
// Deliberate polling, as in RefreshPane: the tap's ring buffer has no
104+
// subscription surface, and this debug-only pane does not justify one.
99105
useEffect(() => {
100106
const t = setInterval(() => setEntries(read()), 1000);
101107
return () => clearInterval(t);

0 commit comments

Comments
 (0)