Skip to content

Commit fc309b4

Browse files
heavygeecursoragent
andcommitted
fix(scratchlist): prevent cross-session leak in useScratchlist hook
Per upstream review on PR tiann#798 (github-actions[bot] [Major]): > useScratchlist persists current `entries` whenever `sessionId` > changes. On A -> B navigation, React first commits with B's id > and A's entries; after paint, this persist effect can write A's > entries to hapi.scratchlist.v1.B before the rehydrate effect > loads B. The previous keyed panel existed specifically to avoid > this race. Lifting state out of the v1 panel (which sidestepped the race via key={props.session.id} forced remount) re-introduced this same data- loss window. The composer-controlled drawer in v1.1 cannot remount on session change because its parent SessionChat doesn't either. Fix: keep the loaded sessionId in state alongside the entries so they swap atomically, and persist against the LOADED sessionId rather than the prop. After A->B, the loaded sessionId is still A until rehydrate runs, so a spurious persist re-writes A's storage with A's entries - a no-op instead of a corruption. Tests: - New use-scratchlist.test.ts with 6 tests: - hydrates from localStorage on mount - add() persists to current session's storage only - rerender to a new session preserves the new session's existing entries - after switching, add() targets the new session - regression test that spies on Storage.prototype.setItem and asserts the rerender lifecycle never produces a (B-key, A-entries) write - remove()/move() target the loaded sessionId - The setItem-spy test correctly fails against the buggy code (verified by temporarily reverting the fix) and passes with the fix in place. - Full web suite: 88 files, 756 tests, all green. Typecheck clean. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ab22d30 commit fc309b4

2 files changed

Lines changed: 186 additions & 13 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { act, renderHook } from '@testing-library/react'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { addScratchlistEntry, persistScratchlist, readScratchlist } from './scratchlist'
4+
import { useScratchlist } from './use-scratchlist'
5+
6+
const SESSION_A = 'session-a'
7+
const SESSION_B = 'session-b'
8+
9+
describe('useScratchlist', () => {
10+
beforeEach(() => {
11+
localStorage.clear()
12+
})
13+
14+
afterEach(() => {
15+
localStorage.clear()
16+
})
17+
18+
it('hydrates from localStorage on mount', () => {
19+
const { entries: seeded } = addScratchlistEntry([], 'a-only', 1000)
20+
persistScratchlist(SESSION_A, seeded)
21+
const { result } = renderHook(({ id }: { id: string }) => useScratchlist(id), {
22+
initialProps: { id: SESSION_A },
23+
})
24+
expect(result.current.entries.map((e) => e.text)).toEqual(['a-only'])
25+
})
26+
27+
it('add() persists to the current sessions storage', () => {
28+
const { result } = renderHook(({ id }: { id: string }) => useScratchlist(id), {
29+
initialProps: { id: SESSION_A },
30+
})
31+
act(() => {
32+
result.current.add('first')
33+
})
34+
expect(readScratchlist(SESSION_A).map((e) => e.text)).toEqual(['first'])
35+
expect(readScratchlist(SESSION_B)).toEqual([])
36+
})
37+
38+
it('switching sessions does NOT overwrite the new sessions storage with stale entries', () => {
39+
// Regression test for the cross-session leak found by upstream review on PR #798.
40+
// Seed both sessions distinctly; mount with A; rerender with B.
41+
// The persist effect must not write A's entries into B's localStorage
42+
// key during the brief render where the prop has changed but the
43+
// rehydrate effect hasn't run yet.
44+
const { entries: aEntries } = addScratchlistEntry([], 'a-original', 1000)
45+
const { entries: bEntries } = addScratchlistEntry([], 'b-original', 2000)
46+
persistScratchlist(SESSION_A, aEntries)
47+
persistScratchlist(SESSION_B, bEntries)
48+
49+
const { rerender } = renderHook(({ id }: { id: string }) => useScratchlist(id), {
50+
initialProps: { id: SESSION_A },
51+
})
52+
53+
rerender({ id: SESSION_B })
54+
55+
// After the session switch, B's storage must still contain B's
56+
// entry (not A's). Reading from disk because that's what the next
57+
// mount of any other component would see.
58+
expect(readScratchlist(SESSION_B).map((e) => e.text)).toEqual(['b-original'])
59+
// A's storage stays intact too.
60+
expect(readScratchlist(SESSION_A).map((e) => e.text)).toEqual(['a-original'])
61+
})
62+
63+
it('after switching sessions, add() targets the new session', () => {
64+
const { entries: aEntries } = addScratchlistEntry([], 'a-original', 1000)
65+
persistScratchlist(SESSION_A, aEntries)
66+
67+
const { result, rerender } = renderHook(
68+
({ id }: { id: string }) => useScratchlist(id),
69+
{ initialProps: { id: SESSION_A } }
70+
)
71+
rerender({ id: SESSION_B })
72+
act(() => {
73+
result.current.add('b-only')
74+
})
75+
expect(readScratchlist(SESSION_B).map((e) => e.text)).toEqual(['b-only'])
76+
expect(readScratchlist(SESSION_A).map((e) => e.text)).toEqual(['a-original'])
77+
})
78+
79+
it('switching sessions never writes the previous sessions entries to the new sessions storage key', () => {
80+
// The bot's review on PR #798 specifically called out the write
81+
// window: between commit-with-new-id and the rehydrate effect
82+
// running, the persist effect can fire one corrupting write
83+
// (sessionId=B, entries=A's). That write self-heals on the next
84+
// render once the rehydrate completes, so a "read after rerender"
85+
// assertion would falsely pass. This test inspects every setItem
86+
// call that happens during the rerender lifecycle and asserts no
87+
// call wrote A's entries to B's storage key.
88+
const { entries: aEntries } = addScratchlistEntry([], 'a-original', 1000)
89+
const { entries: bEntries } = addScratchlistEntry([], 'b-original', 2000)
90+
persistScratchlist(SESSION_A, aEntries)
91+
persistScratchlist(SESSION_B, bEntries)
92+
93+
const { rerender } = renderHook(({ id }: { id: string }) => useScratchlist(id), {
94+
initialProps: { id: SESSION_A },
95+
})
96+
97+
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem')
98+
rerender({ id: SESSION_B })
99+
100+
// Storage format is a top-level array of entries (see writeScratchlist
101+
// in scratchlist.ts), so unpack and inspect each entry directly.
102+
const corruptingWrites = setItemSpy.mock.calls.filter(([key, value]) => {
103+
if (typeof key !== 'string' || typeof value !== 'string') return false
104+
if (!key.endsWith(SESSION_B)) return false
105+
try {
106+
const parsed = JSON.parse(value)
107+
if (!Array.isArray(parsed)) return false
108+
return parsed.some(
109+
(e: { text?: string }) => e?.text === 'a-original'
110+
)
111+
} catch {
112+
return false
113+
}
114+
})
115+
setItemSpy.mockRestore()
116+
117+
expect(corruptingWrites).toEqual([])
118+
})
119+
120+
it('remove() and move() use the loaded sessionId', () => {
121+
const { entries: seeded } = addScratchlistEntry([], 'first', 1000)
122+
const { entries: seeded2 } = addScratchlistEntry(seeded, 'second', 2000)
123+
persistScratchlist(SESSION_A, seeded2)
124+
125+
const { result } = renderHook(({ id }: { id: string }) => useScratchlist(id), {
126+
initialProps: { id: SESSION_A },
127+
})
128+
129+
const firstId = result.current.entries[0]!.id
130+
act(() => {
131+
result.current.remove(firstId)
132+
})
133+
expect(result.current.entries.map((e) => e.text)).toEqual(['first'])
134+
expect(readScratchlist(SESSION_A).map((e) => e.text)).toEqual(['first'])
135+
})
136+
})

web/src/lib/use-scratchlist.ts

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,36 +12,73 @@ import {
1212
* useScratchlist - per-session scratchlist state hook.
1313
*
1414
* Originally the entries lived inside ScratchlistPanel's useState. The
15-
* composer-controlled drawer needs the same data exposed in two places
16-
* (the panel + the composer-toolbar counter), so the state gets lifted
17-
* here. localStorage stays the source of truth; this hook is the React
18-
* mirror.
15+
* composer-controlled drawer (v1.1) needs the same data exposed in two
16+
* places (the drawer + the composer-toolbar counter), so the state is
17+
* lifted here. localStorage stays the source of truth; this hook is the
18+
* React mirror.
19+
*
20+
* Cross-session race protection
21+
* -----------------------------
22+
* The naive shape (entries: useState, sessionId: prop, two useEffects)
23+
* leaks across session navigation:
24+
*
25+
* 1. Mount with sessionId=A → entries = readScratchlist(A) = [a1, a2]
26+
* 2. Parent rerenders with sessionId=B (same component instance — the
27+
* v1 panel sidestepped this with key={props.session.id}; the v1.1
28+
* lifted hook can't, because its parent SessionChat *isn't*
29+
* remounted on session switch).
30+
* 3. React commits with sessionId=B but `entries` is still A's data.
31+
* 4. Persist effect fires: persistScratchlist(B, [a1, a2]) —
32+
* OVERWRITES B's storage with A's entries before the rehydrate
33+
* effect has a chance to run.
34+
*
35+
* Fix (per upstream review on PR #798): keep the loaded sessionId in
36+
* state alongside the entries so they can swap atomically, and persist
37+
* against the LOADED sessionId, not the current prop. After step 2 the
38+
* loaded sessionId is still A (until the rehydrate effect runs), so a
39+
* spurious persist re-writes A's storage with A's entries — a no-op
40+
* instead of a corruption.
1941
*/
2042
export function useScratchlist(sessionId: string) {
21-
const [entries, setEntries] = useState<ScratchlistEntry[]>(() => readScratchlist(sessionId))
43+
const [{ sessionId: loadedSessionId, entries }, setScratchlist] = useState<{
44+
sessionId: string
45+
entries: ScratchlistEntry[]
46+
}>(() => ({ sessionId, entries: readScratchlist(sessionId) }))
2247

48+
// Rehydrate when the parent navigates to a different session. This
49+
// atomically swaps both the loaded sessionId and the entries, so the
50+
// persist effect below sees a consistent (sessionId, entries) pair.
2351
useEffect(() => {
24-
setEntries(readScratchlist(sessionId))
52+
setScratchlist({ sessionId, entries: readScratchlist(sessionId) })
2553
}, [sessionId])
2654

55+
// Persist using the LOADED sessionId, not the prop. If the prop has
56+
// moved ahead of the rehydrate effect, this still writes back to the
57+
// session whose entries we currently hold — no cross-session leak.
2758
useEffect(() => {
28-
persistScratchlist(sessionId, entries)
29-
}, [sessionId, entries])
59+
persistScratchlist(loadedSessionId, entries)
60+
}, [loadedSessionId, entries])
3061

3162
const add = useCallback((rawText: string): boolean => {
3263
const result = addScratchlistEntry(entries, rawText)
3364
if (result.entries === entries) return false
34-
setEntries(result.entries)
65+
setScratchlist({ sessionId: loadedSessionId, entries: result.entries })
3566
return true
36-
}, [entries])
67+
}, [entries, loadedSessionId])
3768

3869
const remove = useCallback((id: string) => {
39-
setEntries((prev) => deleteScratchlistEntry(prev, id))
70+
setScratchlist((prev) => ({
71+
sessionId: prev.sessionId,
72+
entries: deleteScratchlistEntry(prev.entries, id),
73+
}))
4074
}, [])
4175

4276
const move = useCallback((id: string, direction: 'up' | 'down') => {
43-
setEntries((prev) => moveScratchlistEntry(prev, id, direction))
77+
setScratchlist((prev) => ({
78+
sessionId: prev.sessionId,
79+
entries: moveScratchlistEntry(prev.entries, id, direction),
80+
}))
4481
}, [])
4582

46-
return { entries, add, remove, move, setEntries }
83+
return { entries, add, remove, move }
4784
}

0 commit comments

Comments
 (0)