Skip to content

Commit daa90f7

Browse files
cliffhallclaude
andcommitted
chore(servers): stabilize flush identity + add unmount-cleanup test (#1361)
Two non-blocking observations from the third review on #1362: 1. `flush` identity churned on every keystroke because the useCallback closed over `draft` + `targetId`. Read both through refs instead so the returned callback identity is stable. The behavior is unchanged — but the consumer's `onSettingsModalClose` (which wraps `flush`) no longer re-allocates per keystroke, which means the modal's `onClose` prop identity also stays stable. A new test pins this. 2. Added a comment in `onChange` calling out that callers switching `targetId` mid-debounce must `flush()` first to preserve the pending PUT. The only switch path today (`onSettingsModalClose` in App.tsx) does that already — the comment is for future refactors. 3. New unmount-cleanup test pins the intentional "drop final <debounceMs window on unmount" trade-off — flushing on unmount could fire a PUT against an unmounting component, which is a worse footgun than losing a few hundred ms of typing on route change / HMR. Validate clean: 144 files, 1758 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f453790 commit daa90f7

2 files changed

Lines changed: 77 additions & 8 deletions

File tree

clients/web/src/test/core/react/useSettingsDraft.test.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,4 +323,56 @@ describe("useSettingsDraft", () => {
323323
rows: [],
324324
});
325325
});
326+
327+
it("unmount clears the debounce timer — no PUT after the component is gone", () => {
328+
// Documented contract: unmount mid-debounce drops the final
329+
// <debounceMs window of edits. Flushing on unmount could fire a
330+
// PUT against an unmounting component, which is a worse footgun
331+
// than losing a few hundred ms of typing during route change / HMR.
332+
const onPersist = vi.fn(async () => {});
333+
const { result, unmount } = renderHook(() =>
334+
useSettingsDraft<SettingsShape>({
335+
targetId: "alpha",
336+
resolveInitial: () => EMPTY,
337+
onPersist,
338+
onError: vi.fn(),
339+
}),
340+
);
341+
act(() => {
342+
result.current.onChange({ text: "doomed", rows: [] });
343+
});
344+
unmount();
345+
act(() => {
346+
vi.advanceTimersByTime(1000);
347+
});
348+
expect(onPersist).not.toHaveBeenCalled();
349+
});
350+
351+
it("flush identity is stable across keystrokes (no churn on consumer's onClose)", () => {
352+
// Without ref-reading draft + targetId inside flush, every
353+
// `setDraft` would change `flush`'s deps and produce a new
354+
// identity. That cascades to App.tsx's `onSettingsModalClose`,
355+
// which in turn re-allocates the modal's `onClose` prop on every
356+
// character typed. Harmless in practice but the useCallback
357+
// would be doing no work.
358+
const { result } = renderHook(() =>
359+
useSettingsDraft<SettingsShape>({
360+
targetId: "alpha",
361+
resolveInitial: () => EMPTY,
362+
onPersist: vi.fn(),
363+
onError: vi.fn(),
364+
}),
365+
);
366+
const flushAfterMount = result.current.flush;
367+
act(() => {
368+
result.current.onChange({ text: "a", rows: [] });
369+
});
370+
act(() => {
371+
result.current.onChange({ text: "ab", rows: [] });
372+
});
373+
act(() => {
374+
result.current.onChange({ text: "abc", rows: [] });
375+
});
376+
expect(result.current.flush).toBe(flushAfterMount);
377+
});
326378
});

core/react/useSettingsDraft.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,23 @@ export function useSettingsDraft<T>({
7373
const [draft, setDraft] = useState<T | null>(null);
7474
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
7575

76-
// Read callbacks through refs so the consumer doesn't have to
77-
// `useCallback` them. The effect that re-initializes the draft must
78-
// depend on `targetId` only — anything else in its deps risks
79-
// resetting an in-progress edit when the parent re-renders for
80-
// unrelated reasons (e.g. a background server-list refresh).
76+
// Read callbacks (and the current draft) through refs so the consumer
77+
// doesn't have to `useCallback` them, and so the returned `flush`
78+
// identity is stable across keystrokes. The effect that
79+
// re-initializes the draft must depend on `targetId` only — anything
80+
// else in its deps risks resetting an in-progress edit when the
81+
// parent re-renders for unrelated reasons (e.g. a background
82+
// server-list refresh).
8183
const resolveInitialRef = useRef(resolveInitial);
8284
const onPersistRef = useRef(onPersist);
8385
const onErrorRef = useRef(onError);
86+
const draftRef = useRef<T | null>(null);
87+
const targetIdRef = useRef(targetId);
8488
resolveInitialRef.current = resolveInitial;
8589
onPersistRef.current = onPersist;
8690
onErrorRef.current = onError;
91+
draftRef.current = draft;
92+
targetIdRef.current = targetId;
8793

8894
useEffect(() => {
8995
if (!targetId) {
@@ -118,6 +124,11 @@ export function useSettingsDraft<T>({
118124
// bug this hook was extracted to fix was the input's controlled
119125
// `value` prop going stale until the round-trip completed.
120126
setDraft(next);
127+
// Cancel any pending debounce — callers that switch `targetId`
128+
// mid-debounce must call `flush()` first if they want the prior
129+
// target's edits to land. The only switch path today
130+
// (`onSettingsModalClose` in App.tsx) does exactly that, so the
131+
// window is closed in practice.
121132
if (timerRef.current) clearTimeout(timerRef.current);
122133
const id = targetId;
123134
timerRef.current = setTimeout(() => {
@@ -128,14 +139,20 @@ export function useSettingsDraft<T>({
128139
[targetId, debounceMs, persist],
129140
);
130141

142+
// Read `targetId` and `draft` through refs so this callback's
143+
// identity is stable across keystrokes. Without that, the parent's
144+
// `onClose` prop (which closes over `flush`) churns on every
145+
// `setDraft`, which churns the modal's prop identity.
131146
const flush = useCallback(() => {
132147
if (!timerRef.current) return;
133148
clearTimeout(timerRef.current);
134149
timerRef.current = undefined;
135-
if (targetId && draft !== null) {
136-
persist(targetId, draft);
150+
const id = targetIdRef.current;
151+
const value = draftRef.current;
152+
if (id && value !== null) {
153+
persist(id, value);
137154
}
138-
}, [targetId, draft, persist]);
155+
}, [persist]);
139156

140157
return { draft, onChange, flush };
141158
}

0 commit comments

Comments
 (0)