From c84eb648e6347898ba2136360b7d8a4a156004cd Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 18 Oct 2026 13:58:23 +0900 Subject: [PATCH 1/2] fix(core): throw the intended error when no reducer matches an event makeReducer bound reducerMap[event.name] before checking it existed, so a missing reducer threw a generic TypeError from .bind() instead of the descriptive "No reducer for event" error. Check for the reducer first, then bind. --- core/src/activity-utils/makeReducer.spec.ts | 50 +++++++++++++++++++++ core/src/activity-utils/makeReducer.ts | 8 ++-- 2 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 core/src/activity-utils/makeReducer.spec.ts diff --git a/core/src/activity-utils/makeReducer.spec.ts b/core/src/activity-utils/makeReducer.spec.ts new file mode 100644 index 000000000..62c57fbf0 --- /dev/null +++ b/core/src/activity-utils/makeReducer.spec.ts @@ -0,0 +1,50 @@ +import type { DomainEvent } from "../event-types"; +import { makeReducer } from "./makeReducer"; + +test("makeReducer - 등록된 이벤트는 해당 reducer로 처리됩니다", () => { + const reducer = makeReducer({ + Initialized: (state) => state + 1, + ActivityRegistered: (state) => state, + Pushed: (state) => state, + Replaced: (state) => state, + Popped: (state) => state, + StepPushed: (state) => state, + StepReplaced: (state) => state, + StepPopped: (state) => state, + Paused: (state) => state, + Resumed: (state) => state, + }); + + const event = { + id: "e1", + name: "Initialized", + eventDate: 0, + } as unknown as DomainEvent; + + expect(reducer(0, event)).toBe(1); +}); + +test("makeReducer - 등록되지 않은 이벤트는 명확한 에러를 던집니다", () => { + const reducer = makeReducer({ + Initialized: (state) => state, + ActivityRegistered: (state) => state, + Pushed: (state) => state, + Replaced: (state) => state, + Popped: (state) => state, + StepPushed: (state) => state, + StepReplaced: (state) => state, + StepPopped: (state) => state, + Paused: (state) => state, + Resumed: (state) => state, + }); + + const unknownEvent = { + id: "e1", + name: "Unknown", + eventDate: 0, + } as unknown as DomainEvent; + + expect(() => reducer(0, unknownEvent)).toThrow( + `No reducer for event ${JSON.stringify(unknownEvent)}`, + ); +}); diff --git a/core/src/activity-utils/makeReducer.ts b/core/src/activity-utils/makeReducer.ts index 5c5c2d285..d6c7f9a91 100644 --- a/core/src/activity-utils/makeReducer.ts +++ b/core/src/activity-utils/makeReducer.ts @@ -11,10 +11,10 @@ export function makeReducer( }, ) { return (target: T, event: DomainEvent) => { - const reducer = (reducerMap[event.name] as Reducer).bind(reducerMap); - if (reducer) { - return reducer(target, event); + const rawReducer = reducerMap[event.name] as Reducer | undefined; + if (!rawReducer) { + throw new Error(`No reducer for event ${JSON.stringify(event)}`); } - throw new Error(`No reducer for event ${JSON.stringify(event)}`); + return rawReducer.bind(reducerMap)(target, event); }; } From fef979602d9ada73049a9461c573c4892e1bf687 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 18 Oct 2026 14:01:06 +0900 Subject: [PATCH 2/2] fix(core): make StepPopped reducer immutable StepPopped mutated activity.steps in place via Array#pop, unlike every other reducer in this file which derives a new array. Build the popped steps as a new array instead so callers that still hold a reference to the previous activity aren't affected. --- .../makeActivityReducer.spec.ts | 58 +++++++++++++++++++ .../src/activity-utils/makeActivityReducer.ts | 5 +- 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 core/src/activity-utils/makeActivityReducer.spec.ts diff --git a/core/src/activity-utils/makeActivityReducer.spec.ts b/core/src/activity-utils/makeActivityReducer.spec.ts new file mode 100644 index 000000000..f81adf8ff --- /dev/null +++ b/core/src/activity-utils/makeActivityReducer.spec.ts @@ -0,0 +1,58 @@ +import { makeEvent } from "../event-utils"; +import type { Activity } from "../Stack"; +import { makeActivityReducer } from "./makeActivityReducer"; + +function makeActivity(): Activity { + const pushedEvent = makeEvent("Pushed", { + activityId: "a1", + activityName: "sample", + activityParams: { page: "1" }, + }); + + const stepPushedEvent = makeEvent("StepPushed", { + stepId: "s2", + stepParams: { page: "2" }, + }); + + return { + id: "a1", + name: "sample", + transitionState: "enter-done", + params: { page: "2" }, + enteredBy: pushedEvent, + steps: [ + { + id: "a1", + params: { page: "1" }, + enteredBy: pushedEvent, + zIndex: -1, + }, + { + id: "s2", + params: { page: "2" }, + enteredBy: stepPushedEvent, + zIndex: -1, + }, + ], + isTop: true, + isActive: true, + isRoot: true, + zIndex: 0, + }; +} + +test("makeActivityReducer - StepPopped는 activity.steps를 변경하지 않고 새 배열을 반환합니다", () => { + const activity = makeActivity(); + const originalSteps = activity.steps; + + const reducer = makeActivityReducer({ transitionDuration: 300, now: 0 }); + const stepPoppedEvent = makeEvent("StepPopped", {}); + + const nextActivity = reducer(activity, stepPoppedEvent); + + expect(activity.steps).toBe(originalSteps); + expect(activity.steps.length).toBe(2); + expect(nextActivity.steps).not.toBe(originalSteps); + expect(nextActivity.steps.length).toBe(1); + expect(nextActivity.params).toEqual({ page: "1" }); +}); diff --git a/core/src/activity-utils/makeActivityReducer.ts b/core/src/activity-utils/makeActivityReducer.ts index fac85e2d7..1bdb0e989 100644 --- a/core/src/activity-utils/makeActivityReducer.ts +++ b/core/src/activity-utils/makeActivityReducer.ts @@ -108,13 +108,14 @@ export function makeActivityReducer(context: { * If there are params in the previous step, set them as the new params */ StepPopped: (activity: Activity, event: StepPoppedEvent): Activity => { - activity.steps.pop(); + const steps = activity.steps.slice(0, -1); - const beforeActivityParams = last(activity.steps)?.params; + const beforeActivityParams = last(steps)?.params; return { ...activity, params: beforeActivityParams ?? activity.params, + steps, }; },