Skip to content

Commit f546f21

Browse files
committed
reducer-array-allocation-fixes (squashed)
1 parent 18417f8 commit f546f21

4 files changed

Lines changed: 233 additions & 29 deletions

File tree

src/shared/state/json-claude.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,48 @@ describe('jsonClaudeReducer', () => {
280280
expect(next).toBe(state)
281281
})
282282

283+
it('assistantBlockAppended preserves reference identity for untouched entries', () => {
284+
let state = seedSession(initialJsonClaude)
285+
const e1: JsonClaudeChatEntry = {
286+
entryId: 'u1',
287+
kind: 'user',
288+
text: 'hi',
289+
timestamp: 1
290+
}
291+
const e2: JsonClaudeChatEntry = {
292+
entryId: 'a1',
293+
kind: 'assistant',
294+
blocks: [{ type: 'text', text: 'hello' }],
295+
timestamp: 2,
296+
isPartial: true
297+
}
298+
const e3: JsonClaudeChatEntry = {
299+
entryId: 'u2',
300+
kind: 'user',
301+
text: 'follow',
302+
timestamp: 3
303+
}
304+
state = jsonClaudeReducer(state, {
305+
type: 'jsonClaude/entriesSeeded',
306+
payload: { sessionId: SID, entries: [e1, e2, e3] }
307+
})
308+
const before = state.sessions[SID].entries
309+
const next = jsonClaudeReducer(state, {
310+
type: 'jsonClaude/assistantBlockAppended',
311+
payload: {
312+
sessionId: SID,
313+
entryId: 'a1',
314+
block: { type: 'tool_use', id: 't1', name: 'Read' }
315+
}
316+
})
317+
const after = next.sessions[SID].entries
318+
expect(after).not.toBe(before)
319+
expect(after[0]).toBe(before[0])
320+
expect(after[2]).toBe(before[2])
321+
expect(after[1]).not.toBe(before[1])
322+
expect(after[1].blocks).toHaveLength(2)
323+
})
324+
283325
it('assistantTextDelta is a no-op for an unknown entry', () => {
284326
const state = seedSession(initialJsonClaude)
285327
const next = jsonClaudeReducer(state, {
@@ -335,6 +377,48 @@ describe('jsonClaudeReducer', () => {
335377
expect(next).toBe(state)
336378
})
337379

380+
it('assistantEntryFinalized preserves reference identity for untouched entries', () => {
381+
let state = seedSession(initialJsonClaude)
382+
const e1: JsonClaudeChatEntry = {
383+
entryId: 'u1',
384+
kind: 'user',
385+
text: 'hi',
386+
timestamp: 1
387+
}
388+
const e2: JsonClaudeChatEntry = {
389+
entryId: 'a1',
390+
kind: 'assistant',
391+
blocks: [{ type: 'text', text: 'hello' }],
392+
timestamp: 2,
393+
isPartial: true
394+
}
395+
const e3: JsonClaudeChatEntry = {
396+
entryId: 'u2',
397+
kind: 'user',
398+
text: 'follow',
399+
timestamp: 3
400+
}
401+
state = jsonClaudeReducer(state, {
402+
type: 'jsonClaude/entriesSeeded',
403+
payload: { sessionId: SID, entries: [e1, e2, e3] }
404+
})
405+
const before = state.sessions[SID].entries
406+
const next = jsonClaudeReducer(state, {
407+
type: 'jsonClaude/assistantEntryFinalized',
408+
payload: {
409+
sessionId: SID,
410+
entryId: 'a1',
411+
blocks: [{ type: 'text', text: 'hello world' }]
412+
}
413+
})
414+
const after = next.sessions[SID].entries
415+
expect(after).not.toBe(before)
416+
expect(after[0]).toBe(before[0])
417+
expect(after[2]).toBe(before[2])
418+
expect(after[1]).not.toBe(before[1])
419+
expect(after[1].isPartial).toBeUndefined()
420+
})
421+
338422
it('toolResultAttached appends a tool_result entry', () => {
339423
let state = seedSession(initialJsonClaude)
340424
state = jsonClaudeReducer(state, {
@@ -559,6 +643,55 @@ describe('jsonClaudeReducer', () => {
559643
expect(next).toBe(state)
560644
})
561645

646+
it('userEntriesUnqueued is a no-op when entries exist but none are queued', () => {
647+
let state = seedSession(initialJsonClaude)
648+
state = jsonClaudeReducer(state, {
649+
type: 'jsonClaude/entriesSeeded',
650+
payload: {
651+
sessionId: SID,
652+
entries: [
653+
{ entryId: 'u1', kind: 'user', text: 'a', timestamp: 1 },
654+
{ entryId: 'u2', kind: 'user', text: 'b', timestamp: 2 }
655+
]
656+
}
657+
})
658+
const next = jsonClaudeReducer(state, {
659+
type: 'jsonClaude/userEntriesUnqueued',
660+
payload: { sessionId: SID }
661+
})
662+
expect(next).toBe(state)
663+
})
664+
665+
it('userEntriesUnqueued preserves reference identity for non-queued entries', () => {
666+
let state = seedSession(initialJsonClaude)
667+
const plain: JsonClaudeChatEntry = {
668+
entryId: 'u1',
669+
kind: 'user',
670+
text: 'plain',
671+
timestamp: 1
672+
}
673+
const queued: JsonClaudeChatEntry = {
674+
entryId: 'u2',
675+
kind: 'user',
676+
text: 'queued',
677+
timestamp: 2,
678+
isQueued: true
679+
}
680+
state = jsonClaudeReducer(state, {
681+
type: 'jsonClaude/entriesSeeded',
682+
payload: { sessionId: SID, entries: [plain, queued] }
683+
})
684+
const before = state.sessions[SID].entries
685+
const next = jsonClaudeReducer(state, {
686+
type: 'jsonClaude/userEntriesUnqueued',
687+
payload: { sessionId: SID }
688+
})
689+
const after = next.sessions[SID].entries
690+
expect(after[0]).toBe(before[0])
691+
expect(after[1]).not.toBe(before[1])
692+
expect(after[1].isQueued).toBeUndefined()
693+
})
694+
562695
it('entryRemoved drops the matching entry by id', () => {
563696
let state = seedSession(initialJsonClaude)
564697
state = jsonClaudeReducer(state, {

src/shared/state/json-claude.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -464,13 +464,15 @@ export function jsonClaudeReducer(
464464
const session = state.sessions[event.payload.sessionId]
465465
if (!session) return state
466466
const { entryId, block } = event.payload
467-
let changed = false
468-
const nextEntries = session.entries.map((entry) => {
469-
if (entry.entryId !== entryId) return entry
470-
changed = true
471-
return { ...entry, blocks: [...(entry.blocks ?? []), block] }
472-
})
473-
if (!changed) return state
467+
const i = session.entries.findIndex((e) => e.entryId === entryId)
468+
if (i === -1) return state
469+
const entry = session.entries[i]
470+
const patched = { ...entry, blocks: [...(entry.blocks ?? []), block] }
471+
const nextEntries = [
472+
...session.entries.slice(0, i),
473+
patched,
474+
...session.entries.slice(i + 1)
475+
]
474476
return {
475477
...state,
476478
sessions: {
@@ -483,15 +485,16 @@ export function jsonClaudeReducer(
483485
const session = state.sessions[event.payload.sessionId]
484486
if (!session) return state
485487
const { entryId, blocks } = event.payload
486-
let found = false
487-
const nextEntries = session.entries.map((entry) => {
488-
if (entry.entryId !== entryId) return entry
489-
found = true
490-
const { isPartial: _drop, ...rest } = entry
491-
void _drop
492-
return { ...rest, blocks }
493-
})
494-
if (!found) return state
488+
const i = session.entries.findIndex((e) => e.entryId === entryId)
489+
if (i === -1) return state
490+
const { isPartial: _drop, ...rest } = session.entries[i]
491+
void _drop
492+
const patched = { ...rest, blocks }
493+
const nextEntries = [
494+
...session.entries.slice(0, i),
495+
patched,
496+
...session.entries.slice(i + 1)
497+
]
495498
return {
496499
...state,
497500
sessions: {
@@ -634,15 +637,13 @@ export function jsonClaudeReducer(
634637
case 'jsonClaude/userEntriesUnqueued': {
635638
const session = state.sessions[event.payload.sessionId]
636639
if (!session) return state
637-
let changed = false
640+
if (!session.entries.some((e) => e.isQueued)) return state
638641
const nextEntries = session.entries.map((entry) => {
639642
if (!entry.isQueued) return entry
640-
changed = true
641643
const { isQueued: _drop, ...rest } = entry
642644
void _drop
643645
return rest
644646
})
645-
if (!changed) return state
646647
return {
647648
...state,
648649
sessions: {

src/shared/state/worktrees.test.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,26 @@ describe('worktreesReducer', () => {
9494
type: 'worktrees/pendingUpdated',
9595
payload: { id: 'pending:missing', patch: { status: 'error', error: 'nope' } }
9696
})
97-
expect(next.pending).toHaveLength(1)
98-
expect(next.pending[0].status).toBe('creating')
97+
expect(next).toBe(start)
98+
})
99+
100+
it('pendingUpdated preserves reference identity for untouched siblings', () => {
101+
const a = stubPending({ id: 'pending:a' })
102+
const b = stubPending({ id: 'pending:b' })
103+
const c = stubPending({ id: 'pending:c' })
104+
const start: WorktreesState = {
105+
...initialWorktrees,
106+
pending: [a, b, c]
107+
}
108+
const next = apply(start, {
109+
type: 'worktrees/pendingUpdated',
110+
payload: { id: 'pending:b', patch: { status: 'setup' } }
111+
})
112+
expect(next.pending).not.toBe(start.pending)
113+
expect(next.pending[0]).toBe(a)
114+
expect(next.pending[2]).toBe(c)
115+
expect(next.pending[1]).not.toBe(b)
116+
expect(next.pending[1].status).toBe('setup')
99117
})
100118

101119
it('pendingRemoved drops the matching entry', () => {
@@ -149,6 +167,44 @@ describe('worktreesReducer', () => {
149167
expect(next.pendingDeletions[0].teardownLog).toBe('hi')
150168
})
151169

170+
it('pendingDeletionUpdated on an unknown path is a no-op', () => {
171+
const start: WorktreesState = {
172+
...initialWorktrees,
173+
pendingDeletions: [
174+
{ path: '/tmp/wt/a', repoRoot: '/tmp/repo', branch: 'a', phase: 'running-teardown' }
175+
]
176+
}
177+
const next = apply(start, {
178+
type: 'worktrees/pendingDeletionUpdated',
179+
payload: { path: '/tmp/wt/missing', patch: { phase: 'failed' } }
180+
})
181+
expect(next).toBe(start)
182+
})
183+
184+
it('pendingDeletionUpdated preserves reference identity for untouched siblings', () => {
185+
const a: PendingDeletion = {
186+
path: '/tmp/wt/a',
187+
repoRoot: '/tmp/repo',
188+
branch: 'a',
189+
phase: 'running-teardown'
190+
}
191+
const b: PendingDeletion = { ...a, path: '/tmp/wt/b' }
192+
const c: PendingDeletion = { ...a, path: '/tmp/wt/c' }
193+
const start: WorktreesState = {
194+
...initialWorktrees,
195+
pendingDeletions: [a, b, c]
196+
}
197+
const next = apply(start, {
198+
type: 'worktrees/pendingDeletionUpdated',
199+
payload: { path: '/tmp/wt/b', patch: { phase: 'removing-worktree' } }
200+
})
201+
expect(next.pendingDeletions).not.toBe(start.pendingDeletions)
202+
expect(next.pendingDeletions[0]).toBe(a)
203+
expect(next.pendingDeletions[2]).toBe(c)
204+
expect(next.pendingDeletions[1]).not.toBe(b)
205+
expect(next.pendingDeletions[1].phase).toBe('removing-worktree')
206+
})
207+
152208
it('pendingDeletionRemoved drops the matching entry', () => {
153209
const start: WorktreesState = {
154210
...initialWorktrees,

src/shared/state/worktrees.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,19 @@ export function worktreesReducer(
9999
return { ...state, repoRoots: event.payload }
100100
case 'worktrees/pendingAdded':
101101
return { ...state, pending: [...state.pending, event.payload] }
102-
case 'worktrees/pendingUpdated':
102+
case 'worktrees/pendingUpdated': {
103+
const i = state.pending.findIndex((p) => p.id === event.payload.id)
104+
if (i === -1) return state
105+
const patched = { ...state.pending[i], ...event.payload.patch }
103106
return {
104107
...state,
105-
pending: state.pending.map((p) =>
106-
p.id === event.payload.id ? { ...p, ...event.payload.patch } : p
107-
)
108+
pending: [
109+
...state.pending.slice(0, i),
110+
patched,
111+
...state.pending.slice(i + 1)
112+
]
108113
}
114+
}
109115
case 'worktrees/pendingRemoved':
110116
return { ...state, pending: state.pending.filter((p) => p.id !== event.payload) }
111117
case 'worktrees/pendingDeletionStarted':
@@ -116,13 +122,21 @@ export function worktreesReducer(
116122
event.payload
117123
]
118124
}
119-
case 'worktrees/pendingDeletionUpdated':
125+
case 'worktrees/pendingDeletionUpdated': {
126+
const i = state.pendingDeletions.findIndex(
127+
(d) => d.path === event.payload.path
128+
)
129+
if (i === -1) return state
130+
const patched = { ...state.pendingDeletions[i], ...event.payload.patch }
120131
return {
121132
...state,
122-
pendingDeletions: state.pendingDeletions.map((d) =>
123-
d.path === event.payload.path ? { ...d, ...event.payload.patch } : d
124-
)
133+
pendingDeletions: [
134+
...state.pendingDeletions.slice(0, i),
135+
patched,
136+
...state.pendingDeletions.slice(i + 1)
137+
]
125138
}
139+
}
126140
case 'worktrees/pendingDeletionRemoved':
127141
return {
128142
...state,

0 commit comments

Comments
 (0)