From 1c2b38ac7d95ed90a0a58dab01179a11c2b8079c Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 16:07:59 +0900 Subject: [PATCH 1/2] fix(core): resume without queued events --- .../fep-2636-resume-without-queued-events.md | 5 ++ core/src/activity-utils/makeStackReducer.ts | 28 ++++++----- core/src/aggregate.spec.ts | 29 +++++++++++ core/src/makeCoreStore.spec.ts | 48 +++++++++++++++++++ 4 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 .changeset/fep-2636-resume-without-queued-events.md diff --git a/.changeset/fep-2636-resume-without-queued-events.md b/.changeset/fep-2636-resume-without-queued-events.md new file mode 100644 index 000000000..7ccf6d50d --- /dev/null +++ b/.changeset/fep-2636-resume-without-queued-events.md @@ -0,0 +1,5 @@ +--- +"@stackflow/core": patch +--- + +Fix `resume()` leaving a Stack paused when no navigation event was queued after `pause()`. diff --git a/core/src/activity-utils/makeStackReducer.ts b/core/src/activity-utils/makeStackReducer.ts index 5123890cd..53a81832d 100644 --- a/core/src/activity-utils/makeStackReducer.ts +++ b/core/src/activity-utils/makeStackReducer.ts @@ -11,6 +11,18 @@ import { makeActivitiesReducer } from "./makeActivitiesReducer"; import { makeActivityReducer } from "./makeActivityReducer"; import { makeReducer } from "./makeReducer"; +function getGlobalTransitionState( + activities: Stack["activities"], +): Exclude { + return activities.some( + (activity) => + activity.transitionState === "enter-active" || + activity.transitionState === "exit-active", + ) + ? "loading" + : "idle"; +} + function withPauseReducer( reducer: (stack: Stack, event: T) => Stack, ) { @@ -63,18 +75,10 @@ function withActivitiesReducer( ); } - const isLoading = activities.find( - (activity) => - activity.transitionState === "enter-active" || - activity.transitionState === "exit-active", - ); - const globalTransitionState = stack.globalTransitionState === "paused" ? "paused" - : isLoading - ? "loading" - : "idle"; + : getGlobalTransitionState(activities); return reducer({ ...stack, activities, globalTransitionState }, event); }; @@ -128,7 +132,7 @@ export function makeStackReducer(context: { now: number; resumedAt?: number }) { ), Resumed: withActivitiesReducer( (stack: Stack, event: ResumedEvent): Stack => { - if (stack.globalTransitionState !== "paused" || !stack.pausedEvents) { + if (stack.globalTransitionState !== "paused") { return { ...stack, events: [...stack.events, event] }; } @@ -137,10 +141,10 @@ export function makeStackReducer(context: { now: number; resumedAt?: number }) { resumedAt: event.eventDate, }); - const { pausedEvents, ...rest } = stack; + const { pausedEvents = [], ...rest } = stack; const resumedStack = pausedEvents.reduce(reducer, { ...rest, - globalTransitionState: "idle", + globalTransitionState: getGlobalTransitionState(rest.activities), }); return { diff --git a/core/src/aggregate.spec.ts b/core/src/aggregate.spec.ts index ec5c0e869..d2e33c56c 100644 --- a/core/src/aggregate.spec.ts +++ b/core/src/aggregate.spec.ts @@ -4132,6 +4132,35 @@ test("aggregate - Pause되면 이벤트가 반영되지 않고, globalTransition }); }); +test("aggregate - queued event 없이 Resumed 되어도 paused 상태를 해제합니다", () => { + const events = [ + initializedEvent({ + transitionDuration: 300, + }), + registeredEvent({ + activityName: "a", + }), + makeEvent("Pushed", { + activityId: "activity-1", + activityName: "a", + eventDate: enoughPastTime(), + activityParams: {}, + }), + makeEvent("Paused", { + eventDate: enoughPastTime(), + }), + makeEvent("Resumed", { + eventDate: enoughPastTime(), + }), + ]; + + const output = aggregate(events, nowTime()); + + expect(output.globalTransitionState).toBe("idle"); + expect(output.pausedEvents).toBeUndefined(); + expect(output.events).toStrictEqual(events); +}); + test("aggregate - Resumed 되면 해당 시간 이후로 Transition이 정상작동합니다", () => { let pushedEvent1: PushedEvent; let pushedEvent2: PushedEvent; diff --git a/core/src/makeCoreStore.spec.ts b/core/src/makeCoreStore.spec.ts index 83603243a..085baca7d 100644 --- a/core/src/makeCoreStore.spec.ts +++ b/core/src/makeCoreStore.spec.ts @@ -281,3 +281,51 @@ test("makeCoreStore - subscribe에 등록한 이후에 아무 Event가 없는 expect(listener1).toHaveBeenCalledTimes(0); }); + +test("makeCoreStore - queued event 없이 resume해도 Stack이 재개됩니다", () => { + const onResumed = jest.fn(); + + const { actions, pullEvents } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 150, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + () => ({ + key: "test", + onResumed, + }), + ], + }); + + actions.pause(); + + expect(actions.getStack().globalTransitionState).toBe("paused"); + expect(actions.getStack().pausedEvents).toBeUndefined(); + + actions.resume(); + + expect(actions.getStack().globalTransitionState).toBe("idle"); + expect(onResumed).toHaveBeenCalledTimes(1); + expect(last(pullEvents())?.name).toBe("Resumed"); + + actions.push({ + activityId: "a2", + activityName: "hello", + activityParams: {}, + }); + + expect(last(actions.getStack().activities)?.id).toBe("a2"); +}); From 60d4c7fb17f352bf30ea8443a38fc38b980fdf0d Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 16:22:04 +0900 Subject: [PATCH 2/2] refactor(core): inline resumed transition state --- core/src/activity-utils/makeStackReducer.ts | 30 +++++++++++---------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/core/src/activity-utils/makeStackReducer.ts b/core/src/activity-utils/makeStackReducer.ts index 53a81832d..053f61abb 100644 --- a/core/src/activity-utils/makeStackReducer.ts +++ b/core/src/activity-utils/makeStackReducer.ts @@ -11,18 +11,6 @@ import { makeActivitiesReducer } from "./makeActivitiesReducer"; import { makeActivityReducer } from "./makeActivityReducer"; import { makeReducer } from "./makeReducer"; -function getGlobalTransitionState( - activities: Stack["activities"], -): Exclude { - return activities.some( - (activity) => - activity.transitionState === "enter-active" || - activity.transitionState === "exit-active", - ) - ? "loading" - : "idle"; -} - function withPauseReducer( reducer: (stack: Stack, event: T) => Stack, ) { @@ -75,10 +63,18 @@ function withActivitiesReducer( ); } + const isLoading = activities.find( + (activity) => + activity.transitionState === "enter-active" || + activity.transitionState === "exit-active", + ); + const globalTransitionState = stack.globalTransitionState === "paused" ? "paused" - : getGlobalTransitionState(activities); + : isLoading + ? "loading" + : "idle"; return reducer({ ...stack, activities, globalTransitionState }, event); }; @@ -144,7 +140,13 @@ export function makeStackReducer(context: { now: number; resumedAt?: number }) { const { pausedEvents = [], ...rest } = stack; const resumedStack = pausedEvents.reduce(reducer, { ...rest, - globalTransitionState: getGlobalTransitionState(rest.activities), + globalTransitionState: rest.activities.some( + (activity) => + activity.transitionState === "enter-active" || + activity.transitionState === "exit-active", + ) + ? "loading" + : "idle", }); return {