Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions core/src/interfaces/StackflowActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,9 @@ export type StackflowActions = {
* Resume paused stack
*/
resume: (params?: Omit<ResumedEvent, keyof BaseDomainEvent>) => void;

/**
* Clear past events and reconstruct state with active activities to release memory
*/
prune: () => void;
};
68 changes: 68 additions & 0 deletions core/src/makeCoreStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
6 changes: 6 additions & 0 deletions core/src/makeCoreStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore {
stepPop: () => {},
pause: () => {},
resume: () => {},
prune: () => {},
};

const setStackValue = (nextStackValue: Stack) => {
Expand All @@ -133,6 +134,11 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore {
dispatchEvent: actions.dispatchEvent,
pluginInstances,
actions,
store: {
getStack: actions.getStack,
events,
setStackValue,
},
}),
);

Expand Down
119 changes: 119 additions & 0 deletions core/src/utils/makeActions.ts
Original file line number Diff line number Diff line change
@@ -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<StackflowPlugin>[];
actions: StackflowActions;
store: {
getStack: () => Stack;
events: { value: DomainEvent[] };
setStackValue: (stack: Stack) => void;
};
};

export function makeActions({
dispatchEvent,
pluginInstances,
actions,
store,
}: ActionCreatorOptions): Omit<StackflowActions, "dispatchEvent" | "getStack"> {
return {
push(params) {
Expand Down Expand Up @@ -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);
Comment on lines +242 to +245
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add error handling to prevent inconsistent state.

If aggregate throws an exception, store.events.value will already be mutated but store.setStackValue won't be called, leaving the store in an inconsistent state.

Wrap in try-catch to handle errors gracefully:

-store.events.value = newEvents;
-
-const nextStackValue = aggregate(store.events.value, now);
-store.setStackValue(nextStackValue);
+try {
+  const nextStackValue = aggregate(newEvents, now);
+  store.events.value = newEvents;
+  store.setStackValue(nextStackValue);
+} catch (error) {
+  console.error("Failed to prune stack:", error);
+  // Store remains in original state
+}

This ensures atomicity—either both updates succeed or neither does.

🤖 Prompt for AI Agents
In core/src/utils/makeActions.ts around lines 174 to 177, assigning
store.events.value before calling aggregate can leave the store half-updated if
aggregate throws; compute the aggregated value before mutating or wrap the
mutation+aggregation in a try-catch that reverts store.events.value on error and
then rethrows or logs the error. Specifically, call aggregate(now) using a
non-mutated copy of events (or calculate nextStackValue first), then set
store.events.value and store.setStackValue atomically, or if you must assign
events first, catch any exception from aggregate, restore the previous
store.events.value, and surface the error so the store never remains in an
inconsistent state.

},
};
}