From 16f5bc2c6041fca585ad8f6931d6f6387f32b216 Mon Sep 17 00:00:00 2001 From: GEONWOO Date: Thu, 26 Mar 2026 17:50:13 +0900 Subject: [PATCH] fix(app): guard against non-array diffs in session review The SessionReview component crashes with `TypeError: e.diffs.map is not a function` when `props.diffs` is not an array. This can happen when the `session.diff` SSE event delivers malformed properties that bypass the unsafe `as` cast in the event reducer, causing `reconcile` to store a non-array value. - Add `Array.isArray` guard in event-reducer before passing to reconcile - Add `safeDiffs()` accessor in session-review.tsx to normalize props - Add tests for undefined and non-array diff payloads Fixes the common `e.diffs.map is not a function` crash reported by multiple users in the session review UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../context/global-sync/event-reducer.test.ts | 69 +++++++++++++++++++ .../src/context/global-sync/event-reducer.ts | 3 +- packages/ui/src/components/session-review.tsx | 5 +- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index 892129788e6c..2931c0403ca7 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -517,6 +517,75 @@ describe("applyDirectoryEvent", () => { expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" }) }) + test("session.diff stores array diffs in session_diff", () => { + const [store, setStore] = createStore(baseState()) + + applyDirectoryEvent({ + event: { + type: "session.diff", + properties: { + sessionID: "ses_1", + diff: [{ file: "a.txt", before: "", after: "hello", additions: 1, deletions: 0, status: "added" }], + }, + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(Array.isArray(store.session_diff.ses_1)).toBe(true) + expect(store.session_diff.ses_1?.length).toBe(1) + expect(store.session_diff.ses_1?.[0]?.file).toBe("a.txt") + }) + + test("session.diff handles undefined diff gracefully", () => { + const [store, setStore] = createStore(baseState()) + + applyDirectoryEvent({ + event: { + type: "session.diff", + properties: { + sessionID: "ses_1", + diff: undefined, + }, + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + const result = store.session_diff.ses_1 + expect(Array.isArray(result)).toBe(true) + expect(result?.length).toBe(0) + }) + + test("session.diff handles non-array diff gracefully", () => { + const [store, setStore] = createStore(baseState()) + + applyDirectoryEvent({ + event: { + type: "session.diff", + properties: { + sessionID: "ses_1", + diff: { file: "a.txt" } as any, + }, + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + const result = store.session_diff.ses_1 + expect(Array.isArray(result)).toBe(true) + expect(result?.length).toBe(0) + }) + test("routes disposal and lsp events to side-effect handlers", () => { const [store, setStore] = createStore(baseState()) const pushes: string[] = [] diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 4af636553526..06b767b7d5b9 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -162,7 +162,8 @@ export function applyDirectoryEvent(input: { } case "session.diff": { const props = event.properties as { sessionID: string; diff: FileDiff[] } - input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" })) + const diff = Array.isArray(props.diff) ? props.diff : [] + input.setStore("session_diff", props.sessionID, reconcile(diff, { key: "file" })) break } case "todo.updated": { diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 2274e93a34b5..dfc1f0e93933 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -150,7 +150,8 @@ export const SessionReview = (props: SessionReviewProps) => { const opened = () => store.opened const open = () => props.open ?? store.open - const files = createMemo(() => props.diffs.map((diff) => diff.file)) + const safeDiffs = () => (Array.isArray(props.diffs) ? props.diffs : []) + const files = createMemo(() => safeDiffs().map((diff) => diff.file)) const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified") const hasDiffs = () => files().length > 0 @@ -281,7 +282,7 @@ export const SessionReview = (props: SessionReviewProps) => {
- + {(diff) => { let wrapper: HTMLDivElement | undefined const file = diff.file