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..5efb059ba 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,114 @@ export function makeActions({ dispatchEvent("Resumed", nextActionParams); }, + 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 === "exit-active", + ); + + 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", + ); + 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 newEvents: DomainEvent[] = [ + makeEvent("Initialized", { + transitionDuration: stack.transitionDuration, + eventDate: initializedEventDate, + }), + ...Array.from(activityRegisteredEvents.entries()).map( + ([activityName, registered]) => + makeEvent("ActivityRegistered", { + activityName, + eventDate: registered.eventDate, + ...(registered.paramsSchema + ? { activityParamsSchema: registered.paramsSchema } + : {}), + }), + ), + ]; + + for (const activity of activeActivities) { + const isReplaced = activity.enteredBy.name === "Replaced"; + + newEvents.push( + makeEvent(isReplaced ? "Replaced" : "Pushed", { + activityId: activity.id, + activityName: activity.name, + activityParams: activity.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 } : {}), + }), + ); + } + } + + 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); + store.setStackValue(nextStackValue); + }, }; }