From 1e4c2dcc845aed457b942f44bd2dd883475f0a66 Mon Sep 17 00:00:00 2001 From: "Park, Woocheol" Date: Tue, 25 Nov 2025 11:52:59 +0900 Subject: [PATCH 1/4] feat(core): add prune API to clear event history --- core/src/interfaces/StackflowActions.ts | 5 ++ core/src/makeCoreStore.spec.ts | 68 +++++++++++++++++++++++++ core/src/makeCoreStore.ts | 6 +++ core/src/utils/makeActions.ts | 47 +++++++++++++++++ 4 files changed, 126 insertions(+) diff --git a/core/src/interfaces/StackflowActions.ts b/core/src/interfaces/StackflowActions.ts index bc1b1bfa5..648ee1de0 100644 --- a/core/src/interfaces/StackflowActions.ts +++ b/core/src/interfaces/StackflowActions.ts @@ -62,4 +62,9 @@ export type StackflowActions = { * Resume paused stack */ resume: (params?: Omit) => void; + + /** + * Clear past events and reconstruct state with active activities to release memory + */ + prune: () => void; }; diff --git a/core/src/makeCoreStore.spec.ts b/core/src/makeCoreStore.spec.ts index 83603243a..37305debe 100644 --- a/core/src/makeCoreStore.spec.ts +++ b/core/src/makeCoreStore.spec.ts @@ -281,3 +281,71 @@ test("makeCoreStore - subscribe에 등록한 이후에 아무 Event가 없는 expect(listener1).toHaveBeenCalledTimes(0); }); + +test("makeCoreStore - prune이 호출되면, 과거의 이벤트를 정리하고 현재 상태를 유지합니다", () => { + const { actions, pullEvents } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 0, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "home", + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "detail", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "home", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + // Push detail (a2) + actions.push({ + activityId: "a2", + activityName: "detail", + activityParams: { id: "1" }, + }); + + // Push detail (a3) + actions.push({ + activityId: "a3", + activityName: "detail", + activityParams: { id: "2" }, + }); + + // Pop a3 -> a3 becomes exit-done + actions.pop(); + + // Pop a2 -> a2 becomes exit-done + actions.pop(); + + // Now only a1 (home) is active + const stackBeforePrune = actions.getStack(); + const eventsBeforePrune = pullEvents(); + + expect(stackBeforePrune.activities.length).toBe(3); // a1, a2, a3 + expect(eventsBeforePrune.length).toBeGreaterThan(5); + + // Prune! + actions.prune(); + + const stackAfterPrune = actions.getStack(); + const eventsAfterPrune = pullEvents(); + + // Verify activities: only a1 should remain + expect(stackAfterPrune.activities.length).toBe(1); + expect(stackAfterPrune.activities[0].id).toEqual("a1"); + expect(stackAfterPrune.activities[0].transitionState).toEqual("enter-done"); + + // Verify events: should be reset to minimal set + // Initialized + ActivityRegistered(home) + Pushed(a1) = 3 events + expect(eventsAfterPrune.length).toBe(3); +}); diff --git a/core/src/makeCoreStore.ts b/core/src/makeCoreStore.ts index cbdc99aaa..3fd370adc 100644 --- a/core/src/makeCoreStore.ts +++ b/core/src/makeCoreStore.ts @@ -118,6 +118,7 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { stepPop: () => {}, pause: () => {}, resume: () => {}, + prune: () => {}, }; const setStackValue = (nextStackValue: Stack) => { @@ -133,6 +134,11 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { dispatchEvent: actions.dispatchEvent, pluginInstances, actions, + store: { + getStack: actions.getStack, + events, + setStackValue, + }, }), ); diff --git a/core/src/utils/makeActions.ts b/core/src/utils/makeActions.ts index 31022aa4c..7c2ec045e 100644 --- a/core/src/utils/makeActions.ts +++ b/core/src/utils/makeActions.ts @@ -1,16 +1,26 @@ +import { aggregate } from "../aggregate"; +import type { DomainEvent } from "../event-types"; +import { makeEvent } from "../event-utils"; import type { StackflowActions, StackflowPlugin } from "../interfaces"; +import type { Stack } from "../Stack"; import { triggerPreEffectHook } from "./triggerPreEffectHooks"; type ActionCreatorOptions = { dispatchEvent: StackflowActions["dispatchEvent"]; pluginInstances: ReturnType[]; actions: StackflowActions; + store: { + getStack: () => Stack; + events: { value: DomainEvent[] }; + setStackValue: (stack: Stack) => void; + }; }; export function makeActions({ dispatchEvent, pluginInstances, actions, + store, }: ActionCreatorOptions): Omit { return { push(params) { @@ -125,5 +135,42 @@ export function makeActions({ dispatchEvent("Resumed", nextActionParams); }, + prune() { + const { activities } = store.getStack(); + const activeActivities = activities.filter( + (activity) => + activity.transitionState === "enter-active" || + activity.transitionState === "enter-done", + ); + + const now = new Date().getTime(); + + const newEvents: DomainEvent[] = [ + makeEvent("Initialized", { + transitionDuration: 0, + eventDate: now, + }), + ...activeActivities.map((activity) => + makeEvent("ActivityRegistered", { + activityName: activity.name, + eventDate: now, + }), + ), + ...activeActivities.map((activity) => + makeEvent("Pushed", { + activityId: activity.id, + activityName: activity.name, + activityParams: activity.params, + eventDate: now, + skipEnterActiveState: true, + }), + ), + ]; + + store.events.value = newEvents; + + const nextStackValue = aggregate(store.events.value, now); + store.setStackValue(nextStackValue); + }, }; } From aaef6901323e85402e591affb193b130793bb66a Mon Sep 17 00:00:00 2001 From: "Park, Woocheol" Date: Tue, 25 Nov 2025 14:24:05 +0900 Subject: [PATCH 2/4] fix(core): preserve transitionDuration and deduplicate events in prune - Preserve transitionDuration from current stack instead of hardcoding to 0 - Deduplicate ActivityRegistered events when multiple activities share same name --- core/src/utils/makeActions.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/core/src/utils/makeActions.ts b/core/src/utils/makeActions.ts index 7c2ec045e..44add24e0 100644 --- a/core/src/utils/makeActions.ts +++ b/core/src/utils/makeActions.ts @@ -145,14 +145,18 @@ export function makeActions({ const now = new Date().getTime(); + const uniqueActivityNames = [ + ...new Set(activeActivities.map((a) => a.name)), + ]; + const newEvents: DomainEvent[] = [ makeEvent("Initialized", { - transitionDuration: 0, + transitionDuration: store.getStack().transitionDuration, eventDate: now, }), - ...activeActivities.map((activity) => + ...uniqueActivityNames.map((activityName) => makeEvent("ActivityRegistered", { - activityName: activity.name, + activityName, eventDate: now, }), ), From 93620891a6ddc289d96742c90b0421477f3ecf88 Mon Sep 17 00:00:00 2001 From: "Park, Woocheol" Date: Tue, 25 Nov 2025 15:59:58 +0900 Subject: [PATCH 3/4] fix(core): preserve steps and metadata in prune action - Preserve original eventDate for temporal ordering - Preserve activityContext and activityParamsSchema - Reconstruct activity steps with correct event types - Preserve hasZIndex metadata --- core/src/utils/makeActions.ts | 97 ++++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 14 deletions(-) diff --git a/core/src/utils/makeActions.ts b/core/src/utils/makeActions.ts index 44add24e0..f78f446c7 100644 --- a/core/src/utils/makeActions.ts +++ b/core/src/utils/makeActions.ts @@ -136,8 +136,8 @@ export function makeActions({ dispatchEvent("Resumed", nextActionParams); }, prune() { - const { activities } = store.getStack(); - const activeActivities = activities.filter( + const stack = store.getStack(); + const activeActivities = stack.activities.filter( (activity) => activity.transitionState === "enter-active" || activity.transitionState === "enter-done", @@ -145,31 +145,100 @@ export function makeActions({ const now = new Date().getTime(); + const originalInitialized = store.events.value.find( + (e) => e.name === "Initialized", + ); + const initializedEventDate = originalInitialized?.eventDate ?? now; + + const activityRegisteredEvents = new Map< + string, + { + eventDate: number; + paramsSchema?: { + type: "object"; + properties: { + [key: string]: { + type: "string"; + enum?: string[]; + }; + }; + required: string[]; + }; + } + >(); + for (const event of store.events.value) { + if (event.name === "ActivityRegistered") { + activityRegisteredEvents.set(event.activityName, { + eventDate: event.eventDate, + ...(event.activityParamsSchema + ? { paramsSchema: event.activityParamsSchema } + : {}), + }); + } + } + const uniqueActivityNames = [ ...new Set(activeActivities.map((a) => a.name)), ]; const newEvents: DomainEvent[] = [ makeEvent("Initialized", { - transitionDuration: store.getStack().transitionDuration, - eventDate: now, + transitionDuration: stack.transitionDuration, + eventDate: initializedEventDate, }), - ...uniqueActivityNames.map((activityName) => - makeEvent("ActivityRegistered", { + ...uniqueActivityNames.map((activityName) => { + const registered = activityRegisteredEvents.get(activityName); + return makeEvent("ActivityRegistered", { activityName, - eventDate: now, - }), - ), - ...activeActivities.map((activity) => + eventDate: registered?.eventDate ?? initializedEventDate, + ...(registered?.paramsSchema + ? { activityParamsSchema: registered.paramsSchema } + : {}), + }); + }), + ]; + + for (const activity of activeActivities) { + const rootStep = activity.steps[0]; + if (!rootStep) { + newEvents.push( + makeEvent("Pushed", { + activityId: activity.id, + activityName: activity.name, + activityParams: activity.params, + eventDate: activity.enteredBy.eventDate, + skipEnterActiveState: true, + ...(activity.context + ? { activityContext: activity.context } + : {}), + }), + ); + continue; + } + + newEvents.push( makeEvent("Pushed", { activityId: activity.id, activityName: activity.name, - activityParams: activity.params, - eventDate: now, + activityParams: rootStep.params, + eventDate: activity.enteredBy.eventDate, skipEnterActiveState: true, + ...(activity.context ? { activityContext: activity.context } : {}), }), - ), - ]; + ); + + for (const step of activity.steps.slice(1)) { + const isStepReplaced = step.enteredBy.name === "StepReplaced"; + newEvents.push( + makeEvent(isStepReplaced ? "StepReplaced" : "StepPushed", { + stepId: step.id, + stepParams: step.params, + eventDate: step.enteredBy.eventDate, + ...(step.hasZIndex ? { hasZIndex: step.hasZIndex } : {}), + }), + ); + } + } store.events.value = newEvents; From 9583f125eb710f0878b0cb2da4c5ebc1ac7e9e14 Mon Sep 17 00:00:00 2001 From: "Park, Woocheol" Date: Mon, 1 Dec 2025 10:49:56 +0900 Subject: [PATCH 4/4] fix(core): refine prune API to handle edge cases correctly --- core/src/utils/makeActions.ts | 67 +++++++++++++++++------------------ 1 file changed, 33 insertions(+), 34 deletions(-) diff --git a/core/src/utils/makeActions.ts b/core/src/utils/makeActions.ts index f78f446c7..5efb059ba 100644 --- a/core/src/utils/makeActions.ts +++ b/core/src/utils/makeActions.ts @@ -137,13 +137,20 @@ export function makeActions({ }, prune() { const stack = store.getStack(); + + if (stack.globalTransitionState === "paused") { + throw new Error("Cannot prune while the stack is paused"); + } + const activeActivities = stack.activities.filter( (activity) => activity.transitionState === "enter-active" || - activity.transitionState === "enter-done", + activity.transitionState === "enter-done" || + activity.transitionState === "exit-active", ); - const now = new Date().getTime(); + const lastEvent = store.events.value[store.events.value.length - 1]; + const now = Math.max(new Date().getTime(), lastEvent?.eventDate ?? 0) + 1; const originalInitialized = store.events.value.find( (e) => e.name === "Initialized", @@ -177,50 +184,31 @@ export function makeActions({ } } - const uniqueActivityNames = [ - ...new Set(activeActivities.map((a) => a.name)), - ]; - const newEvents: DomainEvent[] = [ makeEvent("Initialized", { transitionDuration: stack.transitionDuration, eventDate: initializedEventDate, }), - ...uniqueActivityNames.map((activityName) => { - const registered = activityRegisteredEvents.get(activityName); - return makeEvent("ActivityRegistered", { - activityName, - eventDate: registered?.eventDate ?? initializedEventDate, - ...(registered?.paramsSchema - ? { activityParamsSchema: registered.paramsSchema } - : {}), - }); - }), + ...Array.from(activityRegisteredEvents.entries()).map( + ([activityName, registered]) => + makeEvent("ActivityRegistered", { + activityName, + eventDate: registered.eventDate, + ...(registered.paramsSchema + ? { activityParamsSchema: registered.paramsSchema } + : {}), + }), + ), ]; for (const activity of activeActivities) { - const rootStep = activity.steps[0]; - if (!rootStep) { - newEvents.push( - makeEvent("Pushed", { - activityId: activity.id, - activityName: activity.name, - activityParams: activity.params, - eventDate: activity.enteredBy.eventDate, - skipEnterActiveState: true, - ...(activity.context - ? { activityContext: activity.context } - : {}), - }), - ); - continue; - } + const isReplaced = activity.enteredBy.name === "Replaced"; newEvents.push( - makeEvent("Pushed", { + makeEvent(isReplaced ? "Replaced" : "Pushed", { activityId: activity.id, activityName: activity.name, - activityParams: rootStep.params, + activityParams: activity.params, eventDate: activity.enteredBy.eventDate, skipEnterActiveState: true, ...(activity.context ? { activityContext: activity.context } : {}), @@ -240,6 +228,17 @@ export function makeActions({ } } + const poppedEvents = activeActivities + .filter( + (activity) => + activity.transitionState === "exit-active" && + activity.exitedBy?.name === "Popped", + ) + .map((activity) => activity.exitedBy as DomainEvent) + .sort((a, b) => a.eventDate - b.eventDate); + + newEvents.push(...poppedEvents); + store.events.value = newEvents; const nextStackValue = aggregate(store.events.value, now);