diff --git a/.changeset/fep-2548-create-load-distinction.md b/.changeset/fep-2548-create-load-distinction.md new file mode 100644 index 000000000..8e41f05e0 --- /dev/null +++ b/.changeset/fep-2548-create-load-distinction.md @@ -0,0 +1,33 @@ +--- +"@stackflow/core": major +--- + +Distinguish how a stack is created — freshly (`create`) or restored from a snapshot (`load`) — and add the surface a snapshot round-trip (capture → persist → load) needs, through additive plugin contracts. The addition is runtime-additive — a store with no snapshot provider behaves exactly as before — but at the type level, hand-written `StackflowActions` mocks and code that constructs `onInit` hook arguments (wrap-and-forward plugins) must add the new required members (`captureSnapshot`, `initInfo`), and `overrideInitialEvents` implementations that explicitly annotate their parameter with the previous `(PushedEvent | StepPushedEvent)[]` shape must widen it to `SnapshotEvent[]` (implementations with inferred parameters are unaffected). One more type-level break versus the last release: the exported hook type `StackflowPluginHook` is renamed to `StackflowPluginInitHook` (the `onInit` hook type). The `provideSnapshot` and `onLoadError` hooks below are new additive surface — they land in their final shape (`StackSnapshot | null` and `{ policy: "recover" } | { policy: "propagate" }`), not a change to any published type. Hence a major release. + +- `StackSnapshot` (and the `SnapshotEvent` union) — a plain-data value, owned by core, that carries every event the stack recorded at runtime — the six navigation events plus `Paused`/`Resumed` — excluding only the static events (`Initialized`, `ActivityRegistered`), which are config-grade information the current config re-derives at load time. Encoding to a persistence medium (codec) is the consumer's responsibility. +- `actions.captureSnapshot()` — export the recorded event log as-is (statics excluded); callable from any hook, at any time. Core holds no opinion about when a snapshot is meaningful: capturing a paused stack yields a snapshot that restores a paused stack, and whether to capture at such a moment is the caller's timing choice. +- `provideSnapshot?({ initialContext })` plugin hook — called synchronously at creation time to supply a snapshot to load from. Returning `null` keeps the create path. If more than one plugin supplies a snapshot, core throws a creation error naming the conflicting keys rather than arbitrating. +- `onLoadError?({ error, initialContext })` plugin hook + `SnapshotLoadError` (with a `cause.kind` of `"unrecognized-snapshot"`, `"incompatible-events"`, or `"empty-stack"`) — a failed load is routed only to the plugin that provided the snapshot. Returning `{ policy: "recover" }` resumes the create path; returning `{ policy: "propagate" }` (or having no handler) throws the error out of `makeCoreStore`. +- `onInit` now receives `initInfo: { kind: "create" | "load" }` — a one-shot signal that leaves no trace on the stack state or event log. It is a record rather than a bare string so per-path fields can be added later without breaking the hook signature. +- `overrideInitialEvents` now runs on the load path too, and its signature widens to `SnapshotEvent[]` in and out, with a new `initInfo` argument (the same record `onInit` receives). On create it keeps deciding the initial entries, as before. On load it receives the provided snapshot's full replay sequence (`Paused`/`Resumed` included when the snapshot recorded them), and its return is adopted as the replay sequence with its event dates preserved — core never re-dates it, so a guarantee like "every restored activity is settled" is this hook's to provide by re-dating the events itself. The return then runs through the same load validation as the snapshot itself, so a failing return surfaces as a `SnapshotLoadError` to the provider. Reshaping the sequence reshapes the reconstructed navigation history: a plugin with no load policy must return `initialEvents` unchanged, and plugins written before this signal that fabricate initial events (e.g. from a URL) should early-return on `initInfo.kind === "load"`. + +```ts +const persisterPlugin = ({ storage, codec }) => () => ({ + key: "persister", + onChanged({ actions }) { + storage.write(codec.encode(actions.captureSnapshot())); + }, + provideSnapshot() { + const raw = storage.read(); + return raw ? codec.decode(raw) : null; + }, + onLoadError({ error }) { + storage.remove(); + return { policy: "recover" }; + }, +}); +``` + +A load reconstructs the stack by replaying the snapshot's events — as passed through the plugins' `overrideInitialEvents` chain — through the existing aggregate machinery, preserving them byte-for-byte, `eventDate` included: the recorded dates are the replay truth, so a stack captured mid-transition restores mid-transition and a paused stack restores paused, and capture∘load is an identity on the snapshot events. Static information (transition duration, the registered-activity set) is re-derived from the current config, and only those static events are re-dated — to just before the earliest replayed event, so registration and transition duration are in place before any snapshot event applies. Core imposes no settling or date normalization on the replay; a supplier or plugin that wants a fully-settled restore (or protection from a capture clock that ran ahead) re-dates the sequence in `overrideInitialEvents`. No new domain events or stack state properties are introduced. + +The snapshot load's registration check is unified into `validateEvents` (run by `aggregate` on every path), which now rejects a `Replaced` that materializes an unregistered activity, matching its long-standing check for `Pushed`. In config-first usage a replace only targets registered activities, so this added check does not fire on the live path. diff --git a/core/src/SnapshotLoadError.ts b/core/src/SnapshotLoadError.ts new file mode 100644 index 000000000..cad0f0453 --- /dev/null +++ b/core/src/SnapshotLoadError.ts @@ -0,0 +1,39 @@ +/** + * Why a snapshot load failed, in three kinds that read as the + * snapshot → events → stack pipeline: + * - `unrecognized-snapshot`: the value is not a snapshot structure core + * recognizes — a catch-all over the structural checks (`$schema` mismatch, + * `events` not being an array, or an item that is not one of the six + * navigation events, including a missing `id`/`name`). `detail` names the + * check that failed. + * - `incompatible-events`: the structure is recognized but the event sequence + * is incompatible with the current config (e.g. it materializes an + * unregistered activity) — a relational failure against the config, not a + * defect intrinsic to the events. `detail` is the raw error thrown by the + * replay machinery (`aggregate`/`validateEvents`), carried unflattened — + * hence `unknown`. Narrowing it to the offending events would drop the + * thrown error's call stack, which is the useful diagnostic here. + * - `empty-stack`: replay succeeded but left zero activities in an enter + * state, so there is nothing to show. Note the condition is "zero + * enter-state activities", not an empty `activities` array — exit-done + * activities may remain. + */ +export type SnapshotLoadErrorCause = + | { kind: "unrecognized-snapshot"; detail: string } + | { kind: "incompatible-events"; detail: unknown } + | { kind: "empty-stack" }; + +/** + * Thrown when loading a provided snapshot fails. Routed to the providing + * plugin's `onLoadError` first (R5); if unrecovered, thrown out of + * `makeCoreStore` (R4). + */ +export class SnapshotLoadError extends Error { + cause: SnapshotLoadErrorCause; + + constructor(cause: SnapshotLoadErrorCause, message?: string) { + super(message ?? `failed to load snapshot: ${cause.kind}`); + this.name = "SnapshotLoadError"; + this.cause = cause; + } +} diff --git a/core/src/StackSnapshot.ts b/core/src/StackSnapshot.ts new file mode 100644 index 000000000..37fb991fc --- /dev/null +++ b/core/src/StackSnapshot.ts @@ -0,0 +1,53 @@ +import type { + PausedEvent, + PoppedEvent, + PushedEvent, + ReplacedEvent, + ResumedEvent, + StepPoppedEvent, + StepPushedEvent, + StepReplacedEvent, +} from "./event-types"; + +/** + * The six navigation events — a subset union of the existing domain event + * types (no new vocabulary is introduced). + */ +export type NavigationEvent = + | PushedEvent + | ReplacedEvent + | PoppedEvent + | StepPushedEvent + | StepReplacedEvent + | StepPoppedEvent; + +/** + * The events a snapshot carries: every domain event except the static ones + * (`Initialized`, `ActivityRegistered`). Statics are config/source-grade + * information — they may legitimately differ after a reload, so the current + * config re-derives them at load time instead of trusting the snapshot. + * Everything the stack recorded at runtime, `Paused`/`Resumed` included, is + * exported as-is. + */ +export type SnapshotEvent = NavigationEvent | PausedEvent | ResumedEvent; + +/** + * A plain-data value whose structure is owned by core. Encoding to a + * persistence medium (codec) is the consumer's responsibility. + */ +export type StackSnapshot = { + /** + * Structural discriminator tag. A mismatch fails the load as + * `SnapshotLoadError` — version migration is a non-goal. + */ + $schema: "stackflow.snapshot.v1"; + + /** + * The event log as recorded, minus the static events the current config + * re-derives at load time. Left in recorded order — load replays through + * `aggregate`, which sorts by eventDate and dedupes by id. Whether to + * capture a paused stack is the caller's choice — core exports the stack it + * is asked about, pause state and all. + */ + events: SnapshotEvent[]; +}; diff --git a/core/src/captureSnapshot.spec.ts b/core/src/captureSnapshot.spec.ts new file mode 100644 index 000000000..ba14bba70 --- /dev/null +++ b/core/src/captureSnapshot.spec.ts @@ -0,0 +1,459 @@ +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import type { StackSnapshot } from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +/** A settled-in-the-past timestamp with a strictly increasing tail. */ +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +const provideSnapshot = + (snapshot: StackSnapshot | null): StackflowPlugin => + () => ({ + key: "provider", + provideSnapshot: () => snapshot, + }); + +test('captureSnapshot - 반환 스냅샷의 $schema가 "stackflow.snapshot.v1"입니다', () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + const snapshot = actions.captureSnapshot(); + + expect(snapshot.$schema).toEqual("stackflow.snapshot.v1"); +}); + +test("captureSnapshot - 스냅샷 events에서 Initialized·ActivityRegistered를 제외합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + const snapshot = actions.captureSnapshot(); + + expect(snapshot.events.map((e) => e.name)).toEqual(["Pushed"]); + expect( + snapshot.events.some( + (e) => e.name === "Pushed" && e.activityId === "a1", + ), + ).toBe(true); +}); + +test("captureSnapshot - Paused·Resumed도 기록된 그대로 스냅샷 events에 포함합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + actions.pause(); + actions.push({ activityId: "a2", activityName: "hello", activityParams: {} }); + actions.resume(); + + const snapshot = actions.captureSnapshot(); + + // The pause markers are runtime history like any other event — replaying + // them reproduces the same pause/resume sequence, so they are carried as-is. + const names = snapshot.events.map((e) => e.name); + expect(names).toEqual(["Pushed", "Paused", "Pushed", "Resumed"]); +}); + +test("captureSnapshot - 6종 탐색 이벤트를 모두 보존합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + // Produce each of the six navigation events at least once. + actions.push({ activityId: "a2", activityName: "hello", activityParams: {} }); + actions.stepPush({ stepId: "s1", stepParams: {} }); + actions.stepReplace({ stepId: "s1b", stepParams: {} }); + actions.stepPop(); + actions.replace({ + activityId: "a3", + activityName: "hello", + activityParams: {}, + }); + actions.pop(); + + const snapshot = actions.captureSnapshot(); + const names = new Set(snapshot.events.map((e) => e.name)); + + expect(names).toContain("Pushed"); + expect(names).toContain("Replaced"); + expect(names).toContain("Popped"); + expect(names).toContain("StepPushed"); + expect(names).toContain("StepReplaced"); + expect(names).toContain("StepPopped"); +}); + +test("captureSnapshot - events를 정렬 없이 기록된 순서 그대로 반환합니다", () => { + const initDate = enoughPastTime(); + const regDate = enoughPastTime(); + const earlier = enoughPastTime(); + const later = enoughPastTime(); + + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: initDate, + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: regDate, + }), + // Log order is (later, earlier) — the reverse of eventDate order. + makeEvent("Pushed", { + activityId: "a-later", + activityName: "hello", + activityParams: {}, + eventDate: later, + }), + makeEvent("Pushed", { + activityId: "a-earlier", + activityName: "hello", + activityParams: {}, + eventDate: earlier, + }), + ], + plugins: [], + }); + + const snapshot = actions.captureSnapshot(); + + // Capture does not sort — load's `aggregate` does. The recorded (later, + // earlier) order is preserved verbatim. + expect(snapshot.events.map((e) => e.eventDate)).toEqual([later, earlier]); + expect( + snapshot.events.map((e) => (e.name === "Pushed" ? e.activityId : e.name)), + ).toEqual(["a-later", "a-earlier"]); +}); + +test("captureSnapshot - 동일 id 이벤트를 그대로 두고 중복 제거는 load에 맡깁니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + id: "duplicated-id", + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + id: "duplicated-id", + activityId: "a2", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + const snapshot = actions.captureSnapshot(); + + // Capture is a faithful projection of the recorded log; it does not dedupe. + // A pathological duplicate id survives capture and is collapsed at load + // time by `aggregate`'s dedupe-by-id. + expect(snapshot.events.filter((e) => e.id === "duplicated-id")).toHaveLength( + 2, + ); +}); + +test("captureSnapshot - 생성 이후 디스패치된 탐색 이벤트를 포함합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + actions.push({ activityId: "a2", activityName: "hello", activityParams: {} }); + + const snapshot = actions.captureSnapshot(); + const pushedIds = snapshot.events + .filter((e) => e.name === "Pushed") + .map((e) => (e.name === "Pushed" ? e.activityId : undefined)); + + expect(pushedIds).toContain("a1"); + expect(pushedIds).toContain("a2"); +}); + +test("captureSnapshot - pause 중 캡처하면 Paused 마커와 큐잉된 탐색 이벤트가 그대로 담깁니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + actions.pause(); + actions.push({ activityId: "a2", activityName: "hello", activityParams: {} }); + + // Queued behind the pause, a2 is not applied to the live stack — and the + // snapshot records exactly that: the queued push together with the Paused + // marker that quarantines it, so a reload reproduces the same paused stack. + expect(actions.getStack().activities.some((a) => a.id === "a2")).toBe(false); + + try { + const snapshot = actions.captureSnapshot(); + const names = snapshot.events.map((e) => e.name); + + expect(names).toEqual(["Pushed", "Paused", "Pushed"]); + expect( + snapshot.events.some( + (e) => e.name === "Pushed" && e.activityId === "a2", + ), + ).toBe(true); + } finally { + // Resume so the paused store settles and its polling interval clears, + // even when the capture above throws. + actions.resume(); + } +}); + +test("captureSnapshot - 전환이 진행 중인 탐색 이벤트도 포함되어 load 시 그대로 재생됩니다", () => { + const source = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + // Push at the current time so the new activity is mid-transition. + source.actions.push({ + activityId: "a2", + activityName: "hello", + activityParams: {}, + }); + expect(source.actions.getStack().globalTransitionState).toEqual("loading"); + + const snapshot = source.actions.captureSnapshot(); + + // A transition is only visual — a2 is recorded in the stack, so it is + // captured despite being mid-transition. + expect( + snapshot.events.some((e) => e.name === "Pushed" && e.activityId === "a2"), + ).toBe(true); + expect( + snapshot.events.some((e) => e.name === "Pushed" && e.activityId === "a1"), + ).toBe(true); + + const restored = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + ], + plugins: [provideSnapshot(snapshot)], + }); + + const restoredStack = restored.actions.getStack(); + + // Both restore, replayed at their recorded dates — the push captured + // mid-transition is still mid-transition on load, exactly as captured. + expect(restoredStack.activities.map((a) => a.id)).toEqual(["a1", "a2"]); + expect(restoredStack.activities.find((a) => a.isTop)?.id).toEqual("a2"); + expect(restoredStack.activities.find((a) => a.isTop)?.transitionState).toEqual( + "enter-active", + ); + expect(restoredStack.globalTransitionState).toEqual("loading"); +}); + +test("captureSnapshot - 전환 중 pop한 이벤트도 Pushed·Popped가 모두 담겨 load 시 pop이 반영됩니다", () => { + // a1 restored settled; push b1, then pop it while both transitions are in + // flight. The push and the pop are both recorded, so both are captured, and + // the reload replays them — the pop takes effect (b1 exits, a1 active again). + const store = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "B", + eventDate: enoughPastTime(), + }), + ], + plugins: [ + provideSnapshot({ + $schema: "stackflow.snapshot.v1", + events: [ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + }), + ], + }); + + store.actions.push({ activityId: "b1", activityName: "B", activityParams: {} }); + store.actions.pop(); + expect( + store.actions.getStack().activities.find((a) => a.id === "b1") + ?.transitionState, + ).toEqual("exit-active"); + + const snapshot = store.actions.captureSnapshot(); + + // Both the mid-transition push and the mid-transition pop are captured. + expect( + snapshot.events.some((e) => e.name === "Pushed" && e.activityId === "b1"), + ).toBe(true); + expect(snapshot.events.some((e) => e.name === "Popped")).toBe(true); + + const restored = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "B", + eventDate: enoughPastTime(), + }), + ], + plugins: [provideSnapshot(snapshot)], + }); + const restoredStack = restored.actions.getStack(); + + // The pop is reflected mid-flight, as captured: b1 is exiting, and a1 is + // the active activity again. + expect(restoredStack.activities.find((a) => a.isActive)?.id).toEqual("a1"); + expect( + restoredStack.activities.find((a) => a.id === "b1")?.transitionState, + ).toEqual("exit-active"); + expect(restoredStack.globalTransitionState).toEqual("loading"); +}); diff --git a/core/src/createPath.spec.ts b/core/src/createPath.spec.ts new file mode 100644 index 000000000..96bac7e51 --- /dev/null +++ b/core/src/createPath.spec.ts @@ -0,0 +1,318 @@ +import type { PushedEvent, StepPushedEvent } from "./event-types"; +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin, StackInitInfo } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import type { SnapshotEvent, StackSnapshot } from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +const nullProvider = (snapshot: StackSnapshot | null = null): StackflowPlugin => + () => ({ + key: "provider", + provideSnapshot: () => snapshot, + }); + +test("create - 스냅샷 공급자가 없으면 initialEvents로 create 경로를 재구성합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + const stack = actions.getStack(); + const a1 = stack.activities.find((a) => a.id === "a1"); + + expect(a1?.transitionState).toEqual("enter-done"); + expect(a1?.isActive).toBe(true); + expect(a1?.isTop).toBe(true); + expect(a1?.isRoot).toBe(true); + expect(stack.globalTransitionState).toEqual("idle"); +}); + +test('create - provideSnapshot 전원 null이면 create 경로를 타고 initInfo.kind가 "create"입니다', () => { + const onInit = jest.fn(); + + const store = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [nullProvider(), () => ({ key: "observer", onInit })], + }); + + store.init(); + + expect(store.actions.getStack().activities.map((a) => a.id)).toEqual(["a1"]); + expect(onInit).toHaveBeenCalledTimes(1); + expect(onInit.mock.calls[0][0].initInfo).toEqual({ kind: "create" }); +}); + +test('create - overrideInitialEvents가 onInit과 동일한 형태의 initInfo { kind: "create" }를 전달받습니다', () => { + const overrideInitialEvents = jest.fn( + (args: { + initialEvents: SnapshotEvent[]; + initialContext: any; + initInfo: StackInitInfo; + }) => args.initialEvents, + ); + + makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [() => ({ key: "observer", overrideInitialEvents })], + }); + + expect(overrideInitialEvents).toHaveBeenCalledTimes(1); + expect(overrideInitialEvents.mock.calls[0][0].initInfo).toEqual({ + kind: "create", + }); +}); + +test("create - overrideInitialEvents가 초기 진입을 전부 strip하면 onInitialActivityNotFound가 발화하고 빈 스택입니다", () => { + const onInitialActivityNotFound = jest.fn(); + + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + () => ({ + key: "stripper", + overrideInitialEvents: () => [], + }), + ], + handlers: { + onInitialActivityNotFound, + }, + }); + + expect(onInitialActivityNotFound).toHaveBeenCalledTimes(1); + expect(actions.getStack().activities).toHaveLength(0); +}); + +test("create - overrideInitialEvents가 초기 이벤트 참조를 바꾸면 onInitialActivityIgnored가 발화합니다", () => { + const onInitialActivityIgnored = jest.fn(); + + makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + () => ({ + key: "replacer", + overrideInitialEvents: (): (PushedEvent | StepPushedEvent)[] => [ + makeEvent("Pushed", { + activityId: "a2", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + }), + ], + handlers: { + onInitialActivityIgnored, + }, + }); + + expect(onInitialActivityIgnored).toHaveBeenCalledTimes(1); +}); + +test("create - overrideInitialEvents에서 초기 Pushed를 strip하면 해당 activity가 스택에 없습니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "B", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + () => ({ + key: "stripper", + overrideInitialEvents: ({ initialEvents }) => + initialEvents.filter( + (e) => !(e.name === "Pushed" && e.activityId === "a1"), + ), + }), + ], + }); + + const ids = actions.getStack().activities.map((a) => a.id); + expect(ids).toContain("b1"); + expect(ids).not.toContain("a1"); +}); + +test("create - overrideInitialEvents에서 초기 Pushed를 치환하면 치환된 activity로 진입합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "Redirect", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + () => ({ + key: "redirector", + overrideInitialEvents: (): (PushedEvent | StepPushedEvent)[] => [ + makeEvent("Pushed", { + activityId: "redirect", + activityName: "Redirect", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + }), + ], + }); + + const ids = actions.getStack().activities.map((a) => a.id); + expect(ids).toContain("redirect"); + expect(ids).not.toContain("a1"); +}); + +test("create - 생성 중에는 어떤 post-effect 훅(onPushed·onChanged 등)도 발화하지 않습니다", () => { + const onPushed = jest.fn(); + const onReplaced = jest.fn(); + const onChanged = jest.fn(); + + const store = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "hello", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a1", + activityName: "hello", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + () => ({ + key: "observer", + onPushed, + onReplaced, + onChanged, + }), + ], + }); + + store.init(); + + expect(onPushed).toHaveBeenCalledTimes(0); + expect(onReplaced).toHaveBeenCalledTimes(0); + expect(onChanged).toHaveBeenCalledTimes(0); +}); diff --git a/core/src/event-utils/index.ts b/core/src/event-utils/index.ts index 6ca98c51a..366804099 100644 --- a/core/src/event-utils/index.ts +++ b/core/src/event-utils/index.ts @@ -1,4 +1,5 @@ export * from "./dispatchEvent"; export * from "./filterEvents"; +export * from "./isSnapshotEvent"; export * from "./makeEvent"; export * from "./validateEvents"; diff --git a/core/src/event-utils/isSnapshotEvent.ts b/core/src/event-utils/isSnapshotEvent.ts new file mode 100644 index 000000000..ca6815331 --- /dev/null +++ b/core/src/event-utils/isSnapshotEvent.ts @@ -0,0 +1,36 @@ +import type { DomainEvent } from "../event-types"; +import type { SnapshotEvent } from "../StackSnapshot"; + +/** + * The events a snapshot carries — every domain event except the static ones + * (`Initialized`, `ActivityRegistered`), which the current config re-derives + * at load time. Single source of truth for the capture-side filter and the + * load-side structure check. + * + * Its members are constrained to `SnapshotEvent["name"]` at construction (a + * typo or a non-snapshot name fails to compile), but the set itself is typed + * `ReadonlySet` so both membership checks below can pass a plain event + * name — a `DomainEvent["name"]` or a runtime `string` — without a cast. + */ +const SNAPSHOT_EVENT_NAMES: ReadonlySet = new Set< + SnapshotEvent["name"] +>([ + "Pushed", + "Replaced", + "Popped", + "StepPushed", + "StepReplaced", + "StepPopped", + "Paused", + "Resumed", +]); + +/** Whether an event is one a snapshot carries (i.e. not a static event). */ +export function isSnapshotEvent(event: DomainEvent): event is SnapshotEvent { + return SNAPSHOT_EVENT_NAMES.has(event.name); +} + +/** Whether a value is the name of an event a snapshot carries. */ +export function isSnapshotEventName(name: unknown): boolean { + return typeof name === "string" && SNAPSHOT_EVENT_NAMES.has(name); +} diff --git a/core/src/event-utils/validateEvents.spec.ts b/core/src/event-utils/validateEvents.spec.ts index f611ecf4a..4ab039cb8 100644 --- a/core/src/event-utils/validateEvents.spec.ts +++ b/core/src/event-utils/validateEvents.spec.ts @@ -48,3 +48,21 @@ test("validateEvents - 푸시했는데 해당 액티비티가 없는 경우 thro ]); }).toThrow(); }); + +test("validateEvents - Replace했는데 해당 액티비티가 없는 경우 throw 합니다", () => { + expect(() => { + validateEvents([ + initializedEvent({ + transitionDuration: 300, + }), + registeredEvent({ + activityName: "home", + }), + makeEvent("Replaced", { + activityId: "a1", + activityName: "sample", + activityParams: {}, + }), + ]); + }).toThrow(); +}); diff --git a/core/src/event-utils/validateEvents.ts b/core/src/event-utils/validateEvents.ts index e86243cb1..da04e96de 100644 --- a/core/src/event-utils/validateEvents.ts +++ b/core/src/event-utils/validateEvents.ts @@ -18,9 +18,15 @@ export function validateEvents(events: DomainEvent[]) { activityRegisteredEvents.map((e) => e.activityName), ); - const pushedEvents = filterEvents(events, "Pushed"); + // Both Pushed and Replaced materialize an activity by name, so both must + // name a registered activity — checking only Pushed left Replaced an + // asymmetric gap. + const materializingEvents = [ + ...filterEvents(events, "Pushed"), + ...filterEvents(events, "Replaced"), + ]; - if (pushedEvents.some((e) => !registeredActivityNames.has(e.activityName))) { + if (materializingEvents.some((e) => !registeredActivityNames.has(e.activityName))) { throw new Error("the corresponding activity does not exist"); } } diff --git a/core/src/index.ts b/core/src/index.ts index a5c440d5d..dd3770f80 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -5,6 +5,7 @@ export { DispatchEvent, makeEvent } from "./event-utils"; export * from "./interfaces"; export * from "./makeCoreStore"; export { produceEffects } from "./produceEffects"; +export { SnapshotLoadError, SnapshotLoadErrorCause } from "./SnapshotLoadError"; export { Activity, ActivityStep, @@ -12,4 +13,9 @@ export { RegisteredActivity, Stack, } from "./Stack"; +export { + NavigationEvent, + SnapshotEvent, + StackSnapshot, +} from "./StackSnapshot"; export { id } from "./utils"; diff --git a/core/src/initInfo.spec.ts b/core/src/initInfo.spec.ts new file mode 100644 index 000000000..450d9f807 --- /dev/null +++ b/core/src/initInfo.spec.ts @@ -0,0 +1,112 @@ +import type { DomainEvent } from "./event-types"; +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import type { NavigationEvent, StackSnapshot } from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +const config = (activityNames: string[]): DomainEvent[] => [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + ...activityNames.map((activityName) => + makeEvent("ActivityRegistered", { + activityName, + eventDate: enoughPastTime(), + }), + ), +]; + +const provideSnapshotPlugin = ( + events: NavigationEvent[], + extra?: Partial>, +): StackflowPlugin => { + return () => ({ + key: "provider", + provideSnapshot: (): StackSnapshot => ({ + $schema: "stackflow.snapshot.v1", + events, + }), + ...extra, + }); +}; + +/** The ten existing domain events — the snapshot mechanism adds none. */ +const DOMAIN_EVENT_NAMES = new Set([ + "Initialized", + "ActivityRegistered", + "Pushed", + "Replaced", + "Popped", + "StepPushed", + "StepReplaced", + "StepPopped", + "Paused", + "Resumed", +]); + +test('initInfo - create 경로에서 onInit의 initInfo.kind가 "create"입니다', () => { + const onInit = jest.fn(); + + const store = makeCoreStore({ + initialEvents: [ + ...config(["A"]), + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [() => ({ key: "observer", onInit })], + }); + + store.init(); + + expect(onInit.mock.calls[0][0].initInfo).toEqual({ kind: "create" }); +}); + +test("initInfo - load 후 구분 신호가 Stack 상태·이벤트 로그에 남지 않고 복원 activity의 enteredBy는 원본 이벤트입니다", () => { + const store = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + id: "ep", + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Replaced", { + id: "er", + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ], + }); + + // No new domain-event vocabulary leaks into the event log. + expect( + store.pullEvents().every((e) => DOMAIN_EVENT_NAMES.has(e.name)), + ).toBe(true); + + // The restored top activity keeps the original Replaced event as enteredBy — + // no "Loaded"-style marking, id preserved. + const top = store.actions.getStack().activities.find((a) => a.isTop); + expect(top?.enteredBy?.name).toEqual("Replaced"); + expect(top?.enteredBy?.id).toEqual("er"); +}); diff --git a/core/src/interfaces/StackflowActions.ts b/core/src/interfaces/StackflowActions.ts index bc1b1bfa5..1fa953029 100644 --- a/core/src/interfaces/StackflowActions.ts +++ b/core/src/interfaces/StackflowActions.ts @@ -11,6 +11,7 @@ import type { import type { BaseDomainEvent } from "../event-types/_base"; import type { DispatchEvent } from "../event-utils"; import type { Stack } from "../Stack"; +import type { StackSnapshot } from "../StackSnapshot"; export type StackflowActions = { /** @@ -18,6 +19,12 @@ export type StackflowActions = { */ getStack: () => Stack; + /** + * Capture the current navigation history as a snapshot. Callable from any + * hook, at any time; normalizes the event log into navigation events. + */ + captureSnapshot: () => StackSnapshot; + /** * Dispatch new event to the core without pre-effect hooks */ diff --git a/core/src/interfaces/StackflowPlugin.ts b/core/src/interfaces/StackflowPlugin.ts index 83e668ffc..2e744dadc 100644 --- a/core/src/interfaces/StackflowPlugin.ts +++ b/core/src/interfaces/StackflowPlugin.ts @@ -9,10 +9,13 @@ import type { StepReplacedEvent, } from "../event-types"; import type { BaseDomainEvent } from "../event-types/_base"; +import type { SnapshotLoadError } from "../SnapshotLoadError"; +import type { SnapshotEvent, StackSnapshot } from "../StackSnapshot"; import type { - StackflowPluginHook, + StackflowPluginInitHook, StackflowPluginPostEffectHook, StackflowPluginPreEffectHook, + StackInitInfo, } from "./StackflowPluginHook"; export type StackflowPlugin = () => { @@ -24,7 +27,7 @@ export type StackflowPlugin = () => { /** * Called when the component is initialized for the first time */ - onInit?: StackflowPluginHook; + onInit?: StackflowPluginInitHook; /** * Called before the `push()` function of `useActions()` is called and the corresponding signal is delivered to the core @@ -128,10 +131,55 @@ export type StackflowPlugin = () => { onChanged?: StackflowPluginPostEffectHook<"%SOMETHING_CHANGED%">; /** - * Specifies the first `PushedEvent`, `StepPushedEvent` (Overrides the `initialActivity` option specified in the `stackflow()` function) + * Intercept the event sequence a stack is built from. Chained across + * plugins in array order — each plugin receives the previous one's + * return. `initInfo` says which path is running, in the same record shape + * `onInit` receives: + * - `{ kind: "create" }`: `initialEvents` holds the initial entry events + * (`PushedEvent`/`StepPushedEvent`, from the `initialActivity` option or + * earlier plugins). The return decides the initial entries. + * - `{ kind: "load" }`: `initialEvents` holds the provided snapshot's full + * replay sequence (structure-validated, original field values) — + * `Paused`/`Resumed` included when the snapshot recorded them. The + * return is adopted as the replay sequence with its event dates + * preserved: core never re-dates it, so replay order follows the + * recorded dates and a guarantee like "every restored activity is + * settled" is this hook's to provide, by re-dating the events itself. + * The return then runs through the same load validation as the snapshot + * itself (activity registration, replay, at least one enter-state + * activity), so a failing return surfaces as a `SnapshotLoadError` to + * the snapshot provider. Reshaping the sequence reshapes the + * reconstructed navigation history — a plugin with no load policy must + * return `initialEvents` unchanged. */ overrideInitialEvents?: (args: { - initialEvents: (PushedEvent | StepPushedEvent)[]; + initialEvents: SnapshotEvent[]; initialContext: any; - }) => (PushedEvent | StepPushedEvent)[]; + initInfo: StackInitInfo; + }) => SnapshotEvent[]; + + /** + * Called synchronously at stack creation time to provide a snapshot to load + * from. Returning `null` means "nothing to provide" and the create path + * continues. If more than one plugin returns a non-null snapshot, core + * throws a creation error naming the conflicting keys — it does not + * arbitrate (R9). + */ + provideSnapshot?: (args: { initialContext: any }) => StackSnapshot | null; + + /** + * Called — only on the plugin that provided the failing snapshot (R5) — when + * that snapshot fails to load. Returning `{ policy: "recover" }` resumes the + * create path without re-polling; returning `{ policy: "propagate" }` (or + * having no handler) throws the `SnapshotLoadError` out of `makeCoreStore` + * (R4). Both outcomes share the `policy` discriminant so a handler chooses + * recover vs propagate explicitly, rather than propagating by falling off + * the end. `"recover"` currently resumes the sole create path; a future + * recovery target would extend this branch (e.g. an added field) rather + * than the discriminant. + */ + onLoadError?: (args: { + error: SnapshotLoadError; + initialContext: any; + }) => { policy: "recover" } | { policy: "propagate" }; }; diff --git a/core/src/interfaces/StackflowPluginHook.ts b/core/src/interfaces/StackflowPluginHook.ts index 53bc15baf..f2656ef9a 100644 --- a/core/src/interfaces/StackflowPluginHook.ts +++ b/core/src/interfaces/StackflowPluginHook.ts @@ -1,7 +1,19 @@ import type { Effect } from "../Effect"; import type { StackflowActions } from "./StackflowActions"; -export type StackflowPluginHook = (args: { actions: StackflowActions }) => void; +/** + * Which path created this stack — `{ kind: "create" }` (fresh) or + * `{ kind: "load" }` (restored from a snapshot). A one-shot signal that + * leaves no trace on the stack. A record rather than a bare string so + * per-path fields can be added later without breaking hook signatures. + * `onInit` and `overrideInitialEvents` receive the signal in this same shape. + */ +export type StackInitInfo = { kind: "create" | "load" }; + +export type StackflowPluginInitHook = (args: { + actions: StackflowActions; + initInfo: StackInitInfo; +}) => void; export type StackflowPluginPreEffectHook = (args: { actionParams: T; diff --git a/core/src/loadPath.spec.ts b/core/src/loadPath.spec.ts new file mode 100644 index 000000000..b2ee00350 --- /dev/null +++ b/core/src/loadPath.spec.ts @@ -0,0 +1,1256 @@ +import type { DomainEvent } from "./event-types"; +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin, StackInitInfo } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import { SnapshotLoadError } from "./SnapshotLoadError"; +import type { + NavigationEvent, + SnapshotEvent, + StackSnapshot, +} from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +/** Static config events (Initialized + one ActivityRegistered per name). */ +const config = ( + activityNames: string[], + transitionDuration = 350, +): DomainEvent[] => [ + makeEvent("Initialized", { + transitionDuration, + eventDate: enoughPastTime(), + }), + ...activityNames.map((activityName) => + makeEvent("ActivityRegistered", { + activityName, + eventDate: enoughPastTime(), + }), + ), +]; + +const snapshot = (events: NavigationEvent[]): StackSnapshot => ({ + $schema: "stackflow.snapshot.v1", + events, +}); + +/** Escape hatch for structurally-malformed snapshots the type would reject. */ +const rawSnapshot = (value: unknown): StackSnapshot => value as StackSnapshot; + +const provideSnapshotPlugin = ( + snap: StackSnapshot, + extra?: Partial>, +): StackflowPlugin => { + return () => ({ + key: "provider", + provideSnapshot: () => snap, + ...extra, + }); +}; + +/** Run makeCoreStore with a single snapshot provider and return any throw. */ +const catchLoad = ( + snap: StackSnapshot, + initialEvents: DomainEvent[], +): unknown => { + let caught: unknown; + try { + makeCoreStore({ + initialEvents, + plugins: [provideSnapshotPlugin(snap)], + }); + } catch (error) { + caught = error; + } + return caught; +}; + +// --------------------------------------------------------------------------- +// happy path · L2 · L3 +// --------------------------------------------------------------------------- + +test("load - 유효한 스냅샷의 탐색 기록을 충실히 재구성합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ), + ], + }); + + const stack = actions.getStack(); + const a = stack.activities.find((x) => x.id === "a1"); + const b = stack.activities.find((x) => x.id === "b1"); + + expect(a?.transitionState).toEqual("enter-done"); + expect(b?.transitionState).toEqual("enter-done"); + expect(b?.isTop).toBe(true); + expect(b?.isActive).toBe(true); + // z-order A→B observed via zIndex/isTop (activities are sorted by id, not + // z-order — see aggregate post-processing). + expect((a?.zIndex ?? 0) < (b?.zIndex ?? 0)).toBe(true); + expect(stack.globalTransitionState).toEqual("idle"); +}); + +test("load - step 이벤트를 담은 스냅샷의 steps·현재 위치·stepId를 충실히 재구성합니다", () => { + // Dates follow the recorded order (a snapshot's dates are its replay + // order): the push precedes the step it hosts. + const originalPushDate = enoughPastTime(); + const originalStepDate = enoughPastTime(); + + const { actions } = makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + id: "e1", + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: originalPushDate, + }), + makeEvent("StepPushed", { + id: "e2", + targetActivityId: "a1", + stepId: "s2", + stepParams: { step: "2" }, + eventDate: originalStepDate, + }), + makeEvent("StepPushed", { + id: "e3", + targetActivityId: "a1", + stepId: "s3", + stepParams: { step: "3" }, + eventDate: enoughPastTime(), + }), + makeEvent("StepReplaced", { + id: "e4", + targetActivityId: "a1", + stepId: "s3b", + stepParams: { step: "3b" }, + eventDate: enoughPastTime(), + }), + makeEvent("StepPopped", { + id: "e5", + targetActivityId: "a1", + eventDate: enoughPastTime(), + }), + ]), + ), + ], + }); + + const a = actions.getStack().activities.find((x) => x.id === "a1"); + + // Replaying StepPushed×2 → StepReplaced → StepPopped leaves steps [a1, s2]. + expect(a?.steps.map((s) => s.id)).toEqual(["a1", "s2"]); + expect(a?.steps[0].enteredBy.id).toEqual("e1"); + expect(a?.steps[1].params.step).toEqual("2"); + expect(a?.steps[1].enteredBy.id).toEqual("e2"); + // Current position is the last remaining step. + expect(a?.params.step).toEqual("2"); + // Snapshot events replay byte-for-byte — eventDate included. + expect(a?.steps[1].enteredBy.eventDate).toEqual(originalStepDate); + expect(a?.transitionState).toEqual("enter-done"); + expect(actions.getStack().globalTransitionState).toEqual("idle"); +}); + +test("load - options의 초기 Pushed(initialActivity)를 폐기하고 스냅샷만을 탐색 기록으로 삼습니다", () => { + const { actions } = makeCoreStore({ + initialEvents: [ + ...config(["Home", "A"]), + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ), + ], + }); + + const ids = actions.getStack().activities.map((x) => x.id); + expect(ids).toContain("a1"); + expect(ids).not.toContain("home1"); +}); + +test("load - 현행 config에서 transitionDuration·등록집합을 재파생합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A"], 350), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ), + ], + }); + + const stack = actions.getStack(); + expect(stack.transitionDuration).toEqual(350); + expect(stack.activities.some((x) => x.id === "a1")).toBe(true); +}); + +// --------------------------------------------------------------------------- +// load interception — the override chain runs on the replay sequence +// --------------------------------------------------------------------------- + +test('load - overrideInitialEvents 체인이 스냅샷 재생열 전체와 initInfo { kind: "load" }로 호출됩니다', () => { + const overrideInitialEvents = jest.fn( + (args: { + initialEvents: SnapshotEvent[]; + initialContext: any; + initInfo: StackInitInfo; + }) => args.initialEvents, + ); + + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + id: "e1", + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + id: "e2", + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Popped", { + id: "e3", + eventDate: enoughPastTime(), + }), + ]), + { overrideInitialEvents }, + ), + ], + }); + + expect(overrideInitialEvents).toHaveBeenCalledTimes(1); + const args = overrideInitialEvents.mock.calls[0][0]; + // The full replay sequence flows through — including events (Popped) that + // are not initial-entry vocabulary — with original ids. + expect(args.initialEvents.map((e) => ({ name: e.name, id: e.id }))).toEqual([ + { name: "Pushed", id: "e1" }, + { name: "Pushed", id: "e2" }, + { name: "Popped", id: "e3" }, + ]); + // The same one-shot record shape onInit receives. + expect(args.initInfo).toEqual({ kind: "load" }); + // An identity return reconstructs the default faithful result: the Popped + // still applies, so a1 is back on top. + const top = actions.getStack().activities.find((x) => x.isTop); + expect(top?.id).toEqual("a1"); +}); + +test("load - 체인의 반환이 재생열로 채택됩니다 — 이벤트를 걸러내면 탐색 기록이 그에 맞게 재구성됩니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Popped", { + eventDate: enoughPastTime(), + }), + ]), + { + overrideInitialEvents: ({ initialEvents }) => + initialEvents.filter((e) => e.name !== "Popped"), + }, + ), + ], + }); + + // With the Popped dropped from the replay sequence, b1 is alive and on top. + const b = actions.getStack().activities.find((x) => x.id === "b1"); + expect(b?.transitionState).toEqual("enter-done"); + expect(b?.isTop).toBe(true); +}); + +test("load - 체인이 추가한 이벤트도 재기저 없이 그 date 그대로 재생됩니다(fresh date면 전환 진행 중으로 복원)", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A", "C"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + { + overrideInitialEvents: ({ initialEvents }) => [ + ...initialEvents, + // Appended with a fresh (now) eventDate — the chain's return + // replays as-is, so the fresh push restores mid-transition; a + // plugin wanting a settled entry dates the event itself. + makeEvent("Pushed", { + activityId: "c1", + activityName: "C", + activityParams: {}, + }), + ], + }, + ), + ], + }); + + const stack = actions.getStack(); + const c = stack.activities.find((x) => x.id === "c1"); + expect(c?.transitionState).toEqual("enter-active"); + expect(c?.isTop).toBe(true); + expect(stack.globalTransitionState).toEqual("loading"); +}); + +test("load - 낡은 스냅샷을 체인이 수선하면 검증은 수선된 재생열에 적용되어 load가 성공합니다", () => { + // The snapshot materializes unregistered "B" — on its own this fails as + // incompatible-events. A plugin that strips the dead activity's events + // repairs the sequence: validation applies to what actually replays. + const { actions } = makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Replaced", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + { + overrideInitialEvents: ({ initialEvents }) => + initialEvents.filter( + (e) => !("activityName" in e && e.activityName === "B"), + ), + }, + ), + ], + }); + + const a = actions.getStack().activities.find((x) => x.id === "a1"); + expect(a?.transitionState).toEqual("enter-done"); + expect(a?.isTop).toBe(true); +}); + +test("load - 체인 반환이 미등록 activity를 물화하면 incompatible-events로 실패합니다", () => { + let caught: unknown; + try { + makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + { + overrideInitialEvents: ({ initialEvents }) => [ + ...initialEvents, + makeEvent("Pushed", { + activityId: "x1", + activityName: "X", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + }, + ), + ], + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause.kind).toEqual( + "incompatible-events", + ); +}); + +test("load - 체인이 재생열을 비우면 empty-stack으로 실패하고 공급자의 onLoadError로 라우팅됩니다", () => { + const onLoadError = jest.fn( + (_args: { error: SnapshotLoadError; initialContext: any }) => ({ + policy: "recover" as const, + }), + ); + + const store = makeCoreStore({ + initialEvents: [ + ...config(["Home", "A"]), + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + { onLoadError }, + ), + () => ({ + key: "emptier", + // A load-only policy — the create run after recovery passes through. + overrideInitialEvents: ({ initialEvents, initInfo }) => + initInfo.kind === "load" ? [] : initialEvents, + }), + ], + }); + + expect(onLoadError).toHaveBeenCalledTimes(1); + expect(onLoadError.mock.calls[0][0].error.cause.kind).toEqual("empty-stack"); + // Recovery resumed the create path with the option's initial entry. + expect(store.actions.getStack().activities.map((x) => x.id)).toEqual([ + "home1", + ]); +}); + +test("load - 체인 훅이 throw하면 그 에러가 그대로 전파되고 onLoadError는 호출되지 않습니다", () => { + // Characterization, not contract: a throwing hook is a plugin bug, not a + // snapshot defect — it is not dressed up as a SnapshotLoadError nor routed + // to the provider. Same rationale as the provideSnapshot/onLoadError throw + // pins below. + const hookFailure = new Error("override hook failed"); + const onLoadError = jest.fn(); + + let caught: unknown; + try { + makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + { onLoadError }, + ), + () => ({ + key: "thrower", + overrideInitialEvents: () => { + throw hookFailure; + }, + }), + ], + }); + } catch (error) { + caught = error; + } + + expect(caught).toBe(hookFailure); + expect(onLoadError).toHaveBeenCalledTimes(0); +}); + +test("load - 초기 activity 핸들러를 호출하지 않습니다", () => { + const onInitialActivityIgnored = jest.fn(); + const onInitialActivityNotFound = jest.fn(); + + const { actions } = makeCoreStore({ + // No option-level initial Pushed: under the create path this would fire + // onInitialActivityNotFound. The load path must skip that evaluation. + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ), + ], + handlers: { + onInitialActivityIgnored, + onInitialActivityNotFound, + }, + }); + + expect(onInitialActivityIgnored).toHaveBeenCalledTimes(0); + expect(onInitialActivityNotFound).toHaveBeenCalledTimes(0); + expect(actions.getStack().activities.some((x) => x.id === "a1")).toBe(true); +}); + +// --------------------------------------------------------------------------- +// §3.6 signal on load · §4.3 timing +// --------------------------------------------------------------------------- + +test('load - onInit이 init()에서 정확히 1회 initInfo { kind: "load" }로 발화합니다', () => { + const onInit = jest.fn(); + + const store = makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + { onInit }, + ), + ], + }); + + // onInit does not fire during makeCoreStore — only on init(). + expect(onInit).toHaveBeenCalledTimes(0); + + store.init(); + + expect(onInit).toHaveBeenCalledTimes(1); + expect(onInit.mock.calls[0][0].initInfo).toEqual({ kind: "load" }); +}); + +test("load - 재생 중 post-effect 훅(onPushed·onReplaced·onChanged)이 발화하지 않습니다", () => { + const onPushed = jest.fn(); + const onReplaced = jest.fn(); + const onChanged = jest.fn(); + + const store = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Replaced", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + { onPushed, onReplaced, onChanged }, + ), + ], + }); + + store.init(); + + expect(onPushed).toHaveBeenCalledTimes(0); + expect(onReplaced).toHaveBeenCalledTimes(0); + expect(onChanged).toHaveBeenCalledTimes(0); + // Non-vacuous: the snapshot was actually reconstructed. + expect(store.actions.getStack().activities.some((x) => x.id === "b1")).toBe( + true, + ); +}); + +test("load - makeCoreStore 반환 시점에 복원 스택이 동기적으로 구성돼 있습니다(과거 date 스냅샷은 정착 상태)", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ), + ], + }); + + // Observed synchronously right after the call returns — no microtask/timer. + const stack = actions.getStack(); + expect(stack.activities.some((x) => x.id === "a1")).toBe(true); + expect(stack.globalTransitionState).toEqual("idle"); +}); + +// --------------------------------------------------------------------------- +// structure check → unrecognized-snapshot +// --------------------------------------------------------------------------- + +test("load - $schema 불일치 스냅샷은 SnapshotLoadError{unrecognized-snapshot}로 실패합니다", () => { + const caught = catchLoad( + rawSnapshot({ + $schema: "stackflow.snapshot.v2", + events: [ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + }), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause).toEqual({ + kind: "unrecognized-snapshot", + detail: "$schema mismatch", + }); +}); + +test("load - events가 배열이 아닌 스냅샷은 unrecognized-snapshot로 실패합니다", () => { + const caught = catchLoad( + rawSnapshot({ $schema: "stackflow.snapshot.v1", events: "nope" }), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause).toEqual({ + kind: "unrecognized-snapshot", + detail: "events is not an array", + }); +}); + +test("load - events에 스냅샷이 나를 수 없는 항목(static event)이 있으면 unrecognized-snapshot로 실패합니다", () => { + const caught = catchLoad( + rawSnapshot({ + $schema: "stackflow.snapshot.v1", + events: [ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + // A static event embedded in the snapshot. Statics are re-derived from + // the current config at load time — without a structure check this + // would be silently registered and the load would succeed. + makeEvent("ActivityRegistered", { + activityName: "B", + eventDate: enoughPastTime(), + }), + ], + }), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + // The offending item's position is named for diagnosis (the valid Pushed at + // index 0 passes; the static item sits at index 1). + expect((caught as SnapshotLoadError).cause).toEqual({ + kind: "unrecognized-snapshot", + detail: "event item at index 1 is not a snapshot event", + }); +}); + +test("load - events 항목에 id가 결손되면 unrecognized-snapshot로 실패합니다", () => { + const caught = catchLoad( + rawSnapshot({ + $schema: "stackflow.snapshot.v1", + events: [ + { + name: "Pushed", + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }, + ], + }), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause).toEqual({ + kind: "unrecognized-snapshot", + detail: "event item at index 0 is not a snapshot event", + }); +}); + +test("load - events 항목에 name이 결손되면 unrecognized-snapshot로 실패합니다", () => { + const caught = catchLoad( + rawSnapshot({ + $schema: "stackflow.snapshot.v1", + events: [ + { + id: "e1", + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }, + ], + }), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause).toEqual({ + kind: "unrecognized-snapshot", + detail: "event item at index 0 is not a snapshot event", + }); +}); + +// --------------------------------------------------------------------------- +// registration check → incompatible-events · L6 +// --------------------------------------------------------------------------- + +test("load - 미등록 activity를 물화하는 Pushed는 SnapshotLoadError{incompatible-events}로 실패합니다", () => { + const caught = catchLoad( + snapshot([ + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + const cause = (caught as SnapshotLoadError).cause; + expect(cause.kind).toEqual("incompatible-events"); + if (cause.kind === "incompatible-events") { + expect(cause.detail).toBeDefined(); + } +}); + +test("load - 미등록 activity를 물화하는 Replaced는 SnapshotLoadError{incompatible-events}로 실패합니다", () => { + const caught = catchLoad( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Replaced", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause.kind).toEqual("incompatible-events"); +}); + +test("load - 등록된 activity를 물화하는 Replaced는 정상 load됩니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Replaced", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ), + ], + }); + + const stack = actions.getStack(); + const b = stack.activities.find((x) => x.id === "b1"); + const a = stack.activities.find((x) => x.id === "a1"); + + expect(b?.isTop).toBe(true); + expect(b?.transitionState).toEqual("enter-done"); + expect(a?.transitionState).toEqual("exit-done"); +}); + +// --------------------------------------------------------------------------- +// postcondition → empty-stack · L3 +// --------------------------------------------------------------------------- + +test("load - events가 빈 스냅샷은 SnapshotLoadError{empty-stack}로 실패합니다", () => { + const caught = catchLoad(snapshot([]), config(["A"])); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause.kind).toEqual("empty-stack"); +}); + +test("load - 재생 후 enter 상태 activity가 0개인 스냅샷은 empty-stack으로 실패합니다", () => { + // A pop with no activity to pop replays to zero activities: non-empty events + // but zero enter-state activities. (Core cannot pop the root, so a + // Pushed→Popped sequence never reaches zero — a pops-only history does.) + const caught = catchLoad( + snapshot([ + makeEvent("Popped", { + eventDate: enoughPastTime(), + }), + ]), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); + expect((caught as SnapshotLoadError).cause.kind).toEqual("empty-stack"); +}); + +// --------------------------------------------------------------------------- +// consumer-transformed snapshot boundaries — accepted edges, pinned +// --------------------------------------------------------------------------- + +test("load - 중복 id 스냅샷은 거부되지 않고 마지막 출현이 이깁니다(last-wins)", () => { + // Duplicate event ids reach load from a consumer-transformed snapshot or, + // now that capture no longer dedupes, straight from core capture. They are + // an accepted boundary, not a rejection case, and load's aggregate keeps the + // LAST occurrence — pinned so the direction doesn't silently flip. + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin( + snapshot([ + makeEvent("Pushed", { + id: "dup", + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + id: "dup", + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ), + ], + }); + + // The later entry survived; the earlier one was dropped. + const ids = actions.getStack().activities.map((x) => x.id); + expect(ids).toEqual(["b1"]); + expect(actions.getStack().activities[0].transitionState).toEqual( + "enter-done", + ); +}); + +test("load - 스냅샷과 이벤트 항목의 미지 프로퍼티를 수용합니다(전방 호환)", () => { + // A snapshot written by a newer version may carry fields this version does + // not know. The structure check validates what it needs and tolerates the + // rest — pinned so the tolerance isn't tightened by accident. + const { actions } = makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + rawSnapshot({ + $schema: "stackflow.snapshot.v1", + futureField: { anything: true }, + events: [ + { + ...makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + futureEventField: "tolerated", + }, + ], + }), + ), + ], + }); + + const a = actions.getStack().activities.find((x) => x.id === "a1"); + expect(a?.transitionState).toEqual("enter-done"); +}); + +// --------------------------------------------------------------------------- +// onLoadError routing +// --------------------------------------------------------------------------- + +test('load - 실패 시 onLoadError가 {policy:"recover"}를 반환하면 throw 없이 create 경로로 재개합니다', () => { + const onInit = jest.fn(); + const onLoadError = jest.fn(() => ({ policy: "recover" as const })); + + const store = makeCoreStore({ + initialEvents: [ + ...config(["Home"]), + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + provideSnapshotPlugin( + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + { onLoadError, onInit }, + ), + ], + }); + + store.init(); + + // Recovery was actually triggered, then resumed cleanly on the create path. + expect(onLoadError).toHaveBeenCalledTimes(1); + expect(store.actions.getStack().activities.map((x) => x.id)).toEqual([ + "home1", + ]); + expect(onInit.mock.calls[0][0].initInfo).toEqual({ kind: "create" }); +}); + +test('load - policy:"recover" 재개가 overrideInitialEvents 체인과 initial-activity 핸들러를 포함한 create 파이프라인을 온전히 태웁니다', () => { + const onInit = jest.fn(); + const onInitialActivityIgnored = jest.fn(); + const overrideInitialEvents = jest.fn( + (_args: { + initialEvents: SnapshotEvent[]; + initialContext: any; + initInfo: StackInitInfo; + }) => [ + makeEvent("Pushed", { + activityId: "redirect1", + activityName: "Redirect", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + ); + + const store = makeCoreStore({ + initialEvents: [ + ...config(["Home", "Redirect"]), + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + provideSnapshotPlugin( + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + { onLoadError: () => ({ policy: "recover" as const }), onInit }, + ), + () => ({ key: "redirector", overrideInitialEvents }), + ], + handlers: { onInitialActivityIgnored }, + }); + store.init(); + + // The chain ran, over the option's initial events (not snapshot leftovers). + // It ran only once: the load attempt failed at the structure check, before + // the chain — so this single call is the recovery's create run. + expect(overrideInitialEvents).toHaveBeenCalledTimes(1); + expect(overrideInitialEvents.mock.calls[0][0].initialEvents).toMatchObject([ + { name: "Pushed", activityId: "home1" }, + ]); + expect(overrideInitialEvents.mock.calls[0][0].initInfo).toEqual({ + kind: "create", + }); + // The chain's substitution is what the stack is built from... + expect(store.actions.getStack().activities.map((x) => x.id)).toEqual([ + "redirect1", + ]); + // ...and the initial-activity handler judged that substitution, as on any + // create. + expect(onInitialActivityIgnored).toHaveBeenCalledTimes(1); + expect(onInitialActivityIgnored.mock.calls[0][0]).toMatchObject([ + { name: "Pushed", activityId: "redirect1" }, + ]); + expect(onInit.mock.calls[0][0].initInfo).toEqual({ kind: "create" }); +}); + +test("load - policy:recover 재개 시 provideSnapshot을 재폴링하지 않습니다", () => { + const provideSnapshot = jest.fn(() => + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + ); + + const { actions } = makeCoreStore({ + initialEvents: [ + ...config(["Home"]), + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + () => ({ + key: "provider", + provideSnapshot, + onLoadError: () => ({ policy: "recover" as const }), + }), + ], + }); + + // Polled exactly once — recovery does not re-poll. + expect(provideSnapshot).toHaveBeenCalledTimes(1); + expect(actions.getStack().activities.map((x) => x.id)).toEqual(["home1"]); +}); + +test('load - onLoadError가 {policy:"propagate"}를 반환하면 SnapshotLoadError를 makeCoreStore 밖으로 던집니다', () => { + let caught: unknown; + try { + makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + { onLoadError: () => ({ policy: "propagate" as const }) }, + ), + ], + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(SnapshotLoadError); +}); + +test('load - onLoadError가 {policy:"recover"} 아닌 truthy 값을 반환하면 SnapshotLoadError를 그대로 던집니다', () => { + // Recovery takes the exact { policy: "recover" } decision. A JS consumer + // returning some other truthy shape must not be mistaken for it — pinned so + // the check never loosens into truthiness. + let caught: unknown; + try { + makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + { + onLoadError: () => + ({ policy: "retry" }) as unknown as + | { policy: "recover" } + | { policy: "propagate" }, + }, + ), + ], + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(SnapshotLoadError); +}); + +test("load - onLoadError 핸들러가 없으면 SnapshotLoadError를 makeCoreStore 밖으로 던집니다", () => { + const caught = catchLoad( + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + config(["A"]), + ); + + expect(caught).toBeInstanceOf(SnapshotLoadError); +}); + +test("load - onLoadError는 스냅샷을 공급한 플러그인에게만 호출됩니다", () => { + const supplierOnLoadError = jest.fn(() => ({ policy: "recover" as const })); + const bystanderOnLoadError = jest.fn(); + + makeCoreStore({ + initialEvents: [ + ...config(["Home"]), + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [ + provideSnapshotPlugin( + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + { key: "supplier", onLoadError: supplierOnLoadError }, + ), + () => ({ + key: "bystander", + provideSnapshot: () => null, + onLoadError: bystanderOnLoadError, + }), + ], + }); + + expect(supplierOnLoadError).toHaveBeenCalledTimes(1); + expect(bystanderOnLoadError).toHaveBeenCalledTimes(0); +}); + +test("load - provideSnapshot·onLoadError는 생성 시 options.initialContext를 인자로 전달받습니다", () => { + const provideSnapshot = jest.fn(() => + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + ); + const onLoadError = jest.fn(() => ({ policy: "recover" as const })); + + makeCoreStore({ + initialEvents: [ + ...config(["Home"]), + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + initialContext: { foo: "bar" }, + plugins: [ + () => ({ + key: "provider", + provideSnapshot, + onLoadError, + }), + ], + }); + + expect(provideSnapshot).toHaveBeenCalledWith({ + initialContext: { foo: "bar" }, + }); + expect(onLoadError).toHaveBeenCalledWith( + expect.objectContaining({ initialContext: { foo: "bar" } }), + ); +}); + +// --------------------------------------------------------------------------- +// hook-thrown errors — characterization (undefined behavior, pinned) +// --------------------------------------------------------------------------- + +test("load - provideSnapshot이 throw하면 에러가 makeCoreStore 밖으로 그대로 전파되고 onLoadError는 호출되지 않습니다", () => { + // Characterization, not contract: a throwing hook is undefined behavior. + // Pinned so that changing today's raw propagation (e.g. routing provider + // failures into onLoadError) is a conscious contract decision, not a slip. + const decodeFailure = new SyntaxError("Unexpected end of JSON input"); + const onLoadError = jest.fn(); + + let caught: unknown; + try { + makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + () => ({ + key: "provider", + provideSnapshot: () => { + throw decodeFailure; + }, + onLoadError, + }), + ], + }); + } catch (error) { + caught = error; + } + + expect(caught).toBe(decodeFailure); + expect(onLoadError).toHaveBeenCalledTimes(0); +}); + +test("load - onLoadError가 throw하면 그 에러가 SnapshotLoadError 대신 makeCoreStore 밖으로 그대로 전파됩니다", () => { + // Characterization, not contract — same rationale as above. + const handlerFailure = new Error("storage cleanup failed"); + + let caught: unknown; + try { + makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin( + rawSnapshot({ $schema: "stackflow.snapshot.v2", events: [] }), + { + onLoadError: () => { + throw handlerFailure; + }, + }, + ), + ], + }); + } catch (error) { + caught = error; + } + + expect(caught).toBe(handlerFailure); +}); diff --git a/core/src/loadSnapshot.ts b/core/src/loadSnapshot.ts new file mode 100644 index 000000000..f4e41f65a --- /dev/null +++ b/core/src/loadSnapshot.ts @@ -0,0 +1,139 @@ +import { aggregate } from "./aggregate"; +import type { DomainEvent } from "./event-types"; +import { isSnapshotEventName } from "./event-utils"; +import { SnapshotLoadError } from "./SnapshotLoadError"; +import type { Stack } from "./Stack"; +import type { SnapshotEvent, StackSnapshot } from "./StackSnapshot"; + +/** + * Reconstruct a stack from a provided snapshot by replaying its events + * through the existing aggregate machinery. The snapshot's events replay + * as-is — their recorded `eventDate`s are the replay truth (replay order + * follows the dates), so a stack captured mid-transition restores + * mid-transition and a paused stack restores paused. Core imposes no + * settling or normalization on the replay; a plugin that wants a stronger + * guarantee (e.g. a fully-settled restore) re-dates the sequence in + * `overrideInitialEvents`. Static information (transitionDuration, the + * registered-activity set) is re-derived from the current config's static + * events, never from the snapshot; only those static events are re-dated + * (see `backdateStaticEvents`). + * + * `overrideSnapshotEvents` is the plugins' `overrideInitialEvents` chain: + * its return is adopted as the replay sequence. It runs after the structure + * check (hooks never see an unrecognizable value) and before every other + * step, so validation applies to the sequence that actually replays — + * whether it came straight from the snapshot or was reshaped by a plugin. + * An error thrown by the chain itself is a plugin bug, not a snapshot + * defect, and propagates raw instead of becoming a `SnapshotLoadError`. + */ +export function loadSnapshot( + snapshot: StackSnapshot, + staticEvents: DomainEvent[], + overrideSnapshotEvents?: (events: SnapshotEvent[]) => SnapshotEvent[], +): { events: DomainEvent[]; stack: Stack } { + assertSnapshotStructure(snapshot); + + const snapshotEvents = + overrideSnapshotEvents?.(snapshot.events) ?? snapshot.events; + + const events: DomainEvent[] = [ + ...backdateStaticEvents(staticEvents, snapshotEvents), + ...snapshotEvents, + ]; + + let stack: Stack; + try { + stack = aggregate(events, Date.now()); + } catch (error) { + // A structurally-valid event sequence that the replay machinery rejects + // (e.g. `validateEvents`) is an incompatible-events failure, not a crash. + // Carry the raw thrown error unflattened so `aggregate`/`validateEvents`' + // call stack stays inspectable on the failure. + throw new SnapshotLoadError({ + kind: "incompatible-events", + detail: error, + }); + } + + const hasEnteredActivity = stack.activities.some( + (activity) => + activity.transitionState === "enter-active" || + activity.transitionState === "enter-done", + ); + + if (!hasEnteredActivity) { + // Replay succeeded but left zero enter-state activities (empty events, or + // a history that pops everything — exit-done activities may remain). A + // blank screen is a silent failure — surface it. + throw new SnapshotLoadError({ kind: "empty-stack" }); + } + + return { events, stack }; +} + +/** + * A value that is not a core-known v1 snapshot must fail loudly before any + * replay (`unrecognized-snapshot`) instead of folding into a corrupt stack. + * `detail` names which structural check failed, for diagnosis. + */ +function assertSnapshotStructure(snapshot: StackSnapshot): void { + if (snapshot.$schema !== "stackflow.snapshot.v1") { + throw new SnapshotLoadError({ + kind: "unrecognized-snapshot", + detail: "$schema mismatch", + }); + } + + const events: unknown = snapshot.events; + + if (!Array.isArray(events)) { + throw new SnapshotLoadError({ + kind: "unrecognized-snapshot", + detail: "events is not an array", + }); + } + + for (const [index, event] of events.entries()) { + if ( + !event || + typeof event !== "object" || + typeof (event as { id?: unknown }).id !== "string" || + !isSnapshotEventName((event as { name?: unknown }).name) + ) { + throw new SnapshotLoadError({ + kind: "unrecognized-snapshot", + detail: `event item at index ${index} is not a snapshot event`, + }); + } + } +} + +/** + * Date the static events to strictly increasing values just before the + * earliest replayed event, preserving their relative order. Statics must + * apply first: `Initialized` seeds `transitionDuration` for every later + * reducer step, and a snapshot whose tail is an unresumed `Paused` would + * quarantine statics sorted after it. Their natural dates cannot be trusted + * for that ordering — the current config's statics are dated "now", which + * falls after a past-dated snapshot — so they are pinned relative to the + * replay sequence instead of the clock. The replayed events themselves are + * never re-dated. + */ +function backdateStaticEvents( + staticEvents: DomainEvent[], + snapshotEvents: SnapshotEvent[], +): DomainEvent[] { + if (snapshotEvents.length === 0) { + return staticEvents; + } + + const earliestReplayDate = snapshotEvents.reduce( + (earliest, event) => Math.min(earliest, event.eventDate), + Number.POSITIVE_INFINITY, + ); + + return staticEvents.map((event, index) => ({ + ...event, + eventDate: earliestReplayDate - (staticEvents.length - index), + })); +} diff --git a/core/src/makeCoreStore.ts b/core/src/makeCoreStore.ts index 297ac948c..5c5497d5a 100644 --- a/core/src/makeCoreStore.ts +++ b/core/src/makeCoreStore.ts @@ -1,10 +1,17 @@ import isEqual from "react-fast-compare"; import { aggregate } from "./aggregate"; -import type { DomainEvent, PushedEvent, StepPushedEvent } from "./event-types"; -import { makeEvent } from "./event-utils"; -import type { StackflowActions, StackflowPlugin } from "./interfaces"; +import type { DomainEvent } from "./event-types"; +import { isSnapshotEvent, makeEvent } from "./event-utils"; +import type { + StackflowActions, + StackflowPlugin, + StackInitInfo, +} from "./interfaces"; +import { loadSnapshot } from "./loadSnapshot"; import { produceEffects } from "./produceEffects"; +import { SnapshotLoadError } from "./SnapshotLoadError"; import type { Stack } from "./Stack"; +import type { SnapshotEvent, StackSnapshot } from "./StackSnapshot"; import { divideBy, once } from "./utils"; import { makeActions } from "./utils/makeActions"; import { triggerPostEffectHooks } from "./utils/triggerPostEffectHooks"; @@ -20,7 +27,7 @@ export type MakeCoreStoreOptions = { plugins: StackflowPlugin[]; handlers?: { onInitialActivityIgnored?: ( - initialPushedEvents: (PushedEvent | StepPushedEvent)[], + overriddenInitialEvents: SnapshotEvent[], ) => void; onInitialActivityNotFound?: () => void; }; @@ -49,39 +56,136 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { ...options.plugins.map((plugin) => plugin()), ]; - const [initialPushedEventsByOption, initialRemainingEvents] = divideBy( + const initialContext = options.initialContext ?? {}; + + // Split the initial events the same way a snapshot does: non-static events + // (what a snapshot carries) versus static events (`Initialized`/ + // `ActivityRegistered`, which both paths re-derive). The create path runs + // its non-static seed through the override chain; the load path re-derives + // statics and replays the snapshot in their place. + const [initialSnapshotEvents, initialStaticEvents] = divideBy( options.initialEvents, - (e) => e.name === "Pushed" || e.name === "StepPushed", + isSnapshotEvent, ); - const initialPushedEvents = pluginInstances.reduce( - (initialEvents, pluginInstance) => - pluginInstance.overrideInitialEvents?.({ - initialEvents, - initialContext: options.initialContext ?? {}, - }) ?? initialEvents, - initialPushedEventsByOption, - ); + const events: { value: DomainEvent[] } = { + value: [], + }; - const isInitialActivityIgnored = - initialPushedEvents.length > 0 && - initialPushedEventsByOption.length > 0 && - initialPushedEvents !== initialPushedEventsByOption; + // One chain for both paths: each plugin sees the previous plugin's return, + // with initInfo telling which path is running. On load the return is the + // replay sequence, so it goes back through the load validation afterwards. + const overrideInitialEvents = ( + initialEvents: SnapshotEvent[], + initInfo: StackInitInfo, + ): SnapshotEvent[] => + pluginInstances.reduce( + (events, pluginInstance) => + pluginInstance.overrideInitialEvents?.({ + initialEvents: events, + initialContext, + initInfo, + }) ?? events, + initialEvents, + ); - if (isInitialActivityIgnored) { - options.handlers?.onInitialActivityIgnored?.(initialPushedEvents); - } + /** + * The create path keeps the pre-snapshot pipeline — with no snapshot + * provider the store is built exactly as before; the only addition the + * chain sees is the initInfo signal. + */ + const createStack = (): Stack => { + const overriddenInitialEvents = overrideInitialEvents( + initialSnapshotEvents, + { kind: "create" }, + ); - if (initialPushedEvents.length === 0) { - options.handlers?.onInitialActivityNotFound?.(); - } + const isInitialActivityIgnored = + overriddenInitialEvents.length > 0 && + initialSnapshotEvents.length > 0 && + overriddenInitialEvents !== initialSnapshotEvents; - const events: { value: DomainEvent[] } = { - value: [...initialRemainingEvents, ...initialPushedEvents], + if (isInitialActivityIgnored) { + options.handlers?.onInitialActivityIgnored?.(overriddenInitialEvents); + } + + if (overriddenInitialEvents.length === 0) { + options.handlers?.onInitialActivityNotFound?.(); + } + + events.value = [...initialStaticEvents, ...overriddenInitialEvents]; + + return aggregate(events.value, new Date().getTime()); }; + // Poll every plugin for a snapshot to load from (§3.3). `null`/`undefined` + // means "nothing to provide". More than one non-null supply is a wiring bug, + // not a snapshot defect — throw a plain creation error naming the keys, + // without routing to any `onLoadError` (R9). + const suppliedSnapshots = pluginInstances + .map((pluginInstance) => ({ + pluginInstance, + snapshot: pluginInstance.provideSnapshot?.({ initialContext }) ?? null, + })) + .filter( + ( + supply, + ): supply is { + pluginInstance: ReturnType; + snapshot: StackSnapshot; + } => supply.snapshot != null, + ); + + if (suppliedSnapshots.length > 1) { + const keys = suppliedSnapshots.map((supply) => supply.pluginInstance.key); + throw new Error( + `More than one plugin provided a snapshot (${keys.join( + ", ", + )}). A stack loads from at most one snapshot; resolve which provider wins in a layer above core.`, + ); + } + + let initInfo: { kind: "create" | "load" }; + let stackValue: Stack; + + if (suppliedSnapshots.length === 1) { + const { pluginInstance, snapshot } = suppliedSnapshots[0]; + + try { + const loaded = loadSnapshot(snapshot, initialStaticEvents, (events) => + overrideInitialEvents(events, { kind: "load" }), + ); + events.value = loaded.events; + stackValue = loaded.stack; + initInfo = { kind: "load" }; + } catch (error) { + if (!(error instanceof SnapshotLoadError)) { + throw error; + } + + // The failing snapshot's provider gets first refusal (R5). An explicit + // `{ policy: "recover" }` resumes the create path without re-polling + // (C1); `{ policy: "propagate" }`, no handler, or anything else rethrows + // out of makeCoreStore (R4). + const recovery = pluginInstance.onLoadError?.({ + error, + initialContext, + }); + + if (recovery?.policy !== "recover") { + throw error; + } + + stackValue = createStack(); + initInfo = { kind: "create" }; + } + } else { + stackValue = createStack(); + initInfo = { kind: "create" }; + } + const stack = { - value: aggregate(events.value, new Date().getTime()), + value: stackValue, }; let currentInterval: ReturnType | null = null; @@ -90,6 +194,22 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { getStack() { return stack.value; }, + captureSnapshot() { + // A snapshot is the recorded event log as-is, minus the static events + // (`Initialized`/`ActivityRegistered`) the current config re-derives at + // load time. Core holds no opinion beyond that vocabulary split: + // `Paused`/`Resumed` and events queued behind a pause are exported + // exactly as recorded, so a paused stack round-trips as a paused stack. + // Whether to capture at such a moment is the caller's timing choice. + // + // Exported in recorded order, without sorting or de-duping: load replays + // through `aggregate`, which sorts by eventDate and dedupes by id itself, + // so normalizing here would only duplicate that work. + return { + $schema: "stackflow.snapshot.v1", + events: events.value.filter(isSnapshotEvent), + }; + }, dispatchEvent(name, params) { const newEvent = makeEvent(name, params); @@ -153,6 +273,7 @@ export function makeCoreStore(options: MakeCoreStoreOptions): CoreStore { pluginInstances.forEach((pluginInstance) => { pluginInstance.onInit?.({ actions, + initInfo, }); }); }), diff --git a/core/src/persisterRoundtrip.spec.ts b/core/src/persisterRoundtrip.spec.ts new file mode 100644 index 000000000..6157d8e57 --- /dev/null +++ b/core/src/persisterRoundtrip.spec.ts @@ -0,0 +1,176 @@ +import type { DomainEvent } from "./event-types"; +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import type { StackSnapshot } from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +const config = (activityNames: string[]): DomainEvent[] => [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + ...activityNames.map((activityName) => + makeEvent("ActivityRegistered", { + activityName, + eventDate: enoughPastTime(), + }), + ), +]; + +const initialHome = () => + makeEvent("Pushed", { + activityId: "home1", + activityName: "Home", + activityParams: {}, + eventDate: enoughPastTime(), + }); + +/** + * The reference decode shape for persister authors. Decoding happens inside + * `provideSnapshot`, so a stored value that cannot even be decoded never + * reaches core — the codec is the supplier's responsibility (R13) and decode + * failures are outside core's `onLoadError` contract. Self-handle them: + * discard the stored value and return null, falling back to the create path. + */ +const decodeOrDiscard = (memory: { + value: string | null; +}): StackSnapshot | null => { + if (!memory.value) { + return null; + } + try { + return JSON.parse(memory.value) as StackSnapshot; + } catch { + memory.value = null; + return null; + } +}; + +test("persister 왕복 - 캡처(onChanged)→JSON 보존→다음 생성의 provideSnapshot load가 core API만으로 닫힙니다", () => { + // A persister mimic: capture on change, JSON codec, in-memory storage. + const memory: { value: string | null } = { value: null }; + + const persister = ( + onInit: ReturnType["onInit"], + ): StackflowPlugin => { + return () => ({ + key: "persister", + onChanged({ actions }) { + memory.value = JSON.stringify(actions.captureSnapshot()); + }, + provideSnapshot: () => decodeOrDiscard(memory), + onInit, + }); + }; + + // Session 1: create Home, then navigate to Article (each change persists). + const session1 = makeCoreStore({ + initialEvents: [...config(["Home", "Article"]), initialHome()], + plugins: [persister(() => {})], + }); + session1.actions.push({ + activityId: "article1", + activityName: "Article", + activityParams: {}, + }); + + // Session 2: same config + plugin; storage now holds the snapshot. + const onInit2 = jest.fn(); + const session2 = makeCoreStore({ + initialEvents: [...config(["Home", "Article"]), initialHome()], + plugins: [persister(onInit2)], + }); + session2.init(); + + const stack = session2.actions.getStack(); + const home = stack.activities.find((x) => x.id === "home1"); + const article = stack.activities.find((x) => x.id === "article1"); + + expect(home?.name).toEqual("Home"); + expect(home?.transitionState).toEqual("enter-done"); + expect(article?.name).toEqual("Article"); + // The article was pushed (and captured) moments ago, so its as-is replay + // is still mid-transition — the restored stack picks up exactly where the + // captured one left off and settles on its own clock. + expect(article?.transitionState).toEqual("enter-active"); + expect(article?.isTop).toBe(true); + expect((home?.zIndex ?? -1) < (article?.zIndex ?? -1)).toBe(true); + expect(onInit2.mock.calls[0][0].initInfo).toEqual({ kind: "load" }); +}); + +test("persister 왕복 - 손상 스냅샷을 onLoadError가 폐기하고 policy:recover로 초기 화면 기동합니다", () => { + // Storage already holds a corrupt snapshot (wrong $schema). + const memory: { value: string | null } = { + value: JSON.stringify({ $schema: "stackflow.snapshot.v2", events: [] }), + }; + + const onInit = jest.fn(); + const persister: StackflowPlugin = () => ({ + key: "persister", + provideSnapshot: () => decodeOrDiscard(memory), + onLoadError: () => { + memory.value = null; + return { policy: "recover" as const }; + }, + onInit, + }); + + let threw = false; + let store: ReturnType | undefined; + try { + store = makeCoreStore({ + initialEvents: [...config(["Home"]), initialHome()], + plugins: [persister], + }); + store.init(); + } catch { + threw = true; + } + + expect(threw).toBe(false); + expect(store?.actions.getStack().activities.map((x) => x.id)).toEqual([ + "home1", + ]); + // The corrupt snapshot was discarded by the supplier. + expect(memory.value).toBeNull(); + expect(onInit.mock.calls[0][0].initInfo).toEqual({ kind: "create" }); +}); + +test("persister 왕복 - 디코드 불가한 보존물(잘린 write)은 공급자가 스스로 폐기하고 create로 기동합니다", () => { + // Storage holds a truncated write (quota eviction, interrupted write) — + // undecodable, so core never sees a snapshot and onLoadError has nothing + // to route. The supplier's decode shape must absorb this itself. + const memory: { value: string | null } = { + value: '{"$schema":"stackflow.snapshot.v1","ev', + }; + + const onInit = jest.fn(); + const persister: StackflowPlugin = () => ({ + key: "persister", + provideSnapshot: () => decodeOrDiscard(memory), + onInit, + }); + + const store = makeCoreStore({ + initialEvents: [...config(["Home"]), initialHome()], + plugins: [persister], + }); + store.init(); + + expect(store.actions.getStack().activities.map((x) => x.id)).toEqual([ + "home1", + ]); + // The undecodable stored value was discarded by the supplier. + expect(memory.value).toBeNull(); + expect(onInit.mock.calls[0][0].initInfo).toEqual({ kind: "create" }); +}); diff --git a/core/src/provideSnapshot.spec.ts b/core/src/provideSnapshot.spec.ts new file mode 100644 index 000000000..fdeb1a504 --- /dev/null +++ b/core/src/provideSnapshot.spec.ts @@ -0,0 +1,146 @@ +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import { SnapshotLoadError } from "./SnapshotLoadError"; +import type { StackSnapshot } from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +const staticEvents = () => [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), +]; + +const snapshotOf = (activityId: string): StackSnapshot => ({ + $schema: "stackflow.snapshot.v1", + events: [ + makeEvent("Pushed", { + activityId, + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], +}); + +type OnLoadError = ReturnType["onLoadError"]; + +const provider = ( + key: string, + snapshot: StackSnapshot | null, + onLoadError?: OnLoadError, +): StackflowPlugin => { + return () => ({ + key, + provideSnapshot: () => snapshot, + ...(onLoadError ? { onLoadError } : null), + }); +}; + +test("provideSnapshot - non-null 공급이 2개 이상이면 충돌 key를 명시한 생성 에러를 던집니다", () => { + // Distinctive keys so message assertions cannot pass on an incidental + // substring of a generic error message. + const runWith = (keys: [string, string]) => { + const onLoadErrorAlpha = jest.fn(); + const onLoadErrorBravo = jest.fn(); + + let caught: unknown; + try { + makeCoreStore({ + initialEvents: staticEvents(), + plugins: [ + provider(keys[0], snapshotOf("a1"), onLoadErrorAlpha), + provider(keys[1], snapshotOf("a2"), onLoadErrorBravo), + ], + }); + } catch (error) { + caught = error; + } + + return { caught, onLoadErrorAlpha, onLoadErrorBravo }; + }; + + for (const keys of [ + ["providerAlpha", "providerBravo"], + ["providerBravo", "providerAlpha"], + ] as [string, string][]) { + const { caught, onLoadErrorAlpha, onLoadErrorBravo } = runWith(keys); + + // A wiring bug (two suppliers), not a specific snapshot's defect: a plain + // creation Error, not SnapshotLoadError, and not routed to onLoadError. + expect(caught).toBeInstanceOf(Error); + expect(caught).not.toBeInstanceOf(SnapshotLoadError); + expect((caught as Error).message).toContain("providerAlpha"); + expect((caught as Error).message).toContain("providerBravo"); + expect(onLoadErrorAlpha).toHaveBeenCalledTimes(0); + expect(onLoadErrorBravo).toHaveBeenCalledTimes(0); + } +}); + +test("provideSnapshot - non-null 공급이 정확히 1개면 나머지 null은 무시하고 load합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: staticEvents(), + plugins: [ + provider("first", null), + provider("second", snapshotOf("a1")), + provider("third", null), + ], + }); + + const restored = actions + .getStack() + .activities.find((a) => a.id === "a1"); + + expect(restored?.name).toEqual("A"); + expect(restored?.transitionState).toEqual("enter-done"); +}); + +test("provideSnapshot - undefined 반환을 null과 동일하게(공급 없음) 취급합니다", () => { + const undefinedProvider: StackflowPlugin = () => ({ + key: "provider", + // Characterization, not contract — the type is `StackSnapshot | null`, but + // a JS consumer can still hand back `undefined`; core coerces it to null + // (nothing to provide) via `?? null`, the same defense used when a plugin + // has no `provideSnapshot` at all. + provideSnapshot: (() => undefined) as unknown as () => StackSnapshot | null, + }); + + const { actions } = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "created", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [undefinedProvider], + }); + + const activities = actions.getStack().activities; + + // Create path: the option's initial activity is present, no snapshot loaded. + expect(activities.map((a) => a.id)).toEqual(["created"]); +}); diff --git a/core/src/rebase.spec.ts b/core/src/rebase.spec.ts new file mode 100644 index 000000000..6260b2565 --- /dev/null +++ b/core/src/rebase.spec.ts @@ -0,0 +1,441 @@ +import type { DomainEvent } from "./event-types"; +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import type { SnapshotEvent, StackSnapshot } from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +/** A future timestamp — models a capture session whose clock ran ahead. */ +const futureTime = () => { + dt += 1; + return new Date(Date.now() + MINUTE).getTime() + dt; +}; + +const config = ( + activityNames: string[], + transitionDuration = 350, +): DomainEvent[] => [ + makeEvent("Initialized", { + transitionDuration, + eventDate: enoughPastTime(), + }), + ...activityNames.map((activityName) => + makeEvent("ActivityRegistered", { + activityName, + eventDate: enoughPastTime(), + }), + ), +]; + +const provideSnapshotPlugin = + (events: SnapshotEvent[]): StackflowPlugin => + () => ({ + key: "provider", + provideSnapshot: (): StackSnapshot => ({ + $schema: "stackflow.snapshot.v1", + events, + }), + }); + +test("load - 스냅샷 이벤트의 eventDate를 재기저 없이 그대로 보존해 재생합니다", () => { + const originalPushDate = enoughPastTime(); + const originalStepDate = enoughPastTime(); + + const { actions } = makeCoreStore({ + initialEvents: config(["A"]), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + id: "e1", + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: originalPushDate, + }), + makeEvent("StepPushed", { + id: "e2", + targetActivityId: "a1", + stepId: "s2", + stepParams: {}, + eventDate: originalStepDate, + }), + ]), + ], + }); + + const a = actions.getStack().activities.find((x) => x.id === "a1"); + + // The snapshot events are preserved byte-for-byte — eventDate included. + expect(a?.id).toEqual("a1"); + expect(a?.enteredBy.id).toEqual("e1"); + expect(a?.enteredBy.eventDate).toEqual(originalPushDate); + expect(a?.steps[1].id).toEqual("s2"); + expect(a?.steps[1].enteredBy.id).toEqual("e2"); + expect(a?.steps[1].enteredBy.eventDate).toEqual(originalStepDate); +}); + +test("load - 재생 순서는 기록된 eventDate 순서입니다(배열 순서 아님)", () => { + // Core capture preserves recorded array order (it no longer sorts), so a + // snapshot's array order can disagree with its date order; either way the + // dates are the replay truth (aggregate sorts by eventDate). + const earlierDate = enoughPastTime(); + const laterDate = enoughPastTime(); + + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [ + provideSnapshotPlugin([ + // Array order [A, B] disagrees with date order (A is dated later). + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: laterDate, + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: earlierDate, + }), + ]), + ], + }); + + const a = actions.getStack().activities.find((x) => x.id === "a1"); + const b = actions.getStack().activities.find((x) => x.id === "b1"); + + // Date order wins: B (earlier) below, A (later) on top. + expect((b?.zIndex ?? -1) < (a?.zIndex ?? -1)).toBe(true); + expect(a?.isTop).toBe(true); +}); + +test("load - 과거에 기록된 스냅샷은 정착 상태(enter-done·idle)로 복원됩니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"], 350), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ], + }); + + // The recorded dates predate now − transitionDuration, so the as-is replay + // itself folds to a settled stack — no re-dating involved. + const stack = actions.getStack(); + expect(stack.activities.find((x) => x.id === "a1")?.transitionState).toEqual( + "enter-done", + ); + expect(stack.activities.find((x) => x.id === "b1")?.transitionState).toEqual( + "enter-done", + ); + expect(stack.globalTransitionState).toEqual("idle"); +}); + +test("load - 미래 date 스냅샷(캡처 세션 시계 선행)도 그대로 재생되며 core는 정규화하지 않습니다", () => { + // As-is replay accepts the recorded dates as truth even when the capture + // session's clock ran ahead — the restored activities stay unsettled until + // the local clock catches up. A supplier or plugin that wants to rule this + // out normalizes the dates in overrideInitialEvents. + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"], 350), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: futureTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: futureTime(), + }), + ]), + ], + }); + + const stack = actions.getStack(); + expect(stack.activities.find((x) => x.id === "a1")?.transitionState).toEqual( + "enter-active", + ); + expect(stack.activities.find((x) => x.id === "b1")?.transitionState).toEqual( + "enter-active", + ); + expect(stack.globalTransitionState).toEqual("loading"); +}); + +test("load - 플러그인이 overrideInitialEvents에서 재기저하면 정착 복원을 스스로 보장할 수 있습니다", () => { + // The escape hatch for the test above: a plugin re-dates the replay + // sequence into the settled past, restoring a fully-settled stack from the + // same future-dated snapshot. + const settlePlugin: StackflowPlugin = () => ({ + key: "settler", + overrideInitialEvents: ({ initialEvents, initInfo }) => + initInfo.kind === "load" + ? initialEvents.map((event, index) => ({ + ...event, + eventDate: Date.now() - MINUTE + index, + })) + : initialEvents, + }); + + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"], 350), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: futureTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: futureTime(), + }), + ]), + settlePlugin, + ], + }); + + const stack = actions.getStack(); + expect( + stack.activities.every((x) => x.transitionState === "enter-done"), + ).toBe(true); + expect(stack.activities.find((x) => x.isTop)?.id).toEqual("b1"); + expect(stack.globalTransitionState).toEqual("idle"); +}); + +test("load - 이벤트 수와 무관하게 정적 이벤트가 모든 스냅샷 이벤트보다 앞 시점으로 재기저됩니다", () => { + // Only the statics are re-dated: pinned just before the earliest snapshot + // event so registration and transitionDuration apply before any replayed + // event, for any history length. + const transitionDuration = 350; + const staticEvents: DomainEvent[] = [ + makeEvent("Initialized", { + transitionDuration, + eventDate: enoughPastTime(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: enoughPastTime(), + }), + ]; + const snapshotEvents = Array.from({ length: 400 }, (_, index) => + makeEvent("Pushed", { + activityId: `a${index}`, + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ); + const originalDates = snapshotEvents.map((e) => e.eventDate); + + const store = makeCoreStore({ + initialEvents: staticEvents, + plugins: [provideSnapshotPlugin(snapshotEvents)], + }); + + const log = store.pullEvents(); + const staticDates = log + .filter((e) => e.name === "Initialized" || e.name === "ActivityRegistered") + .map((e) => e.eventDate); + const replayedDates = log + .filter((e) => e.name === "Pushed") + .map((e) => e.eventDate); + + // Statics sort strictly before every snapshot event; the snapshot events + // themselves keep their recorded dates untouched. + expect(Math.max(...staticDates)).toBeLessThan(Math.min(...replayedDates)); + expect(replayedDates).toEqual(originalDates); + + const stack = store.actions.getStack(); + expect(stack.globalTransitionState).toEqual("idle"); + expect(stack.activities.find((x) => x.isTop)?.id).toEqual("a399"); +}); + +test("load - transitionDuration이 0이어도 정적 이벤트가 스냅샷 이벤트보다 앞에 정렬됩니다", () => { + // Static backdating is pinned to the earliest snapshot event, not to the + // clock or the transition duration, so td=0 (where fresh static dates and + // fresh navigation dates would collide) plays no role in the ordering. + const staticEvents: DomainEvent[] = [ + makeEvent("Initialized", { + transitionDuration: 0, + eventDate: Date.now(), + }), + makeEvent("ActivityRegistered", { + activityName: "A", + eventDate: Date.now(), + }), + ]; + const snapshotEvents = [ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "a2", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]; + + const store = makeCoreStore({ + initialEvents: staticEvents, + plugins: [provideSnapshotPlugin(snapshotEvents)], + }); + + const log = store.pullEvents(); + const staticDates = log + .filter((e) => e.name === "Initialized" || e.name === "ActivityRegistered") + .map((e) => e.eventDate); + const replayedDates = log + .filter((e) => e.name === "Pushed") + .map((e) => e.eventDate); + + expect(Math.max(...staticDates)).toBeLessThan(Math.min(...replayedDates)); + + const stack = store.actions.getStack(); + expect(stack.globalTransitionState).toEqual("idle"); + expect( + stack.activities.every((x) => x.transitionState === "enter-done"), + ).toBe(true); + expect(stack.activities.find((x) => x.isTop)?.id).toEqual("a2"); +}); + +test("load - 스냅샷 꼬리가 unresumed Paused여도 정적 이벤트는 그보다 앞에 적용되어 격리되지 않습니다", () => { + // Statics sorted after an unresumed Paused would be quarantined with the + // queued events — leaving the restored stack with transitionDuration 0 and + // no registered activities. Backdating statics ahead of the whole replay + // sequence rules that out structurally. + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"], 350), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Paused", { + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ], + }); + + const stack = actions.getStack(); + // The statics applied before the pause: config-derived state is intact. + expect(stack.transitionDuration).toEqual(350); + expect(stack.registeredActivities.map((x) => x.name)).toEqual(["A", "B"]); + // The pause itself round-tripped: b1 stays quarantined behind it. + expect(stack.globalTransitionState).toEqual("paused"); + expect(stack.activities.map((x) => x.id)).toEqual(["a1"]); +}); + +test("load - load 후 pop이 복원 최상단을 exit 전환시키고 아래 복원 activity를 재노출합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"], 350), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ], + }); + + actions.pop(); + + const stack = actions.getStack(); + const a = stack.activities.find((x) => x.id === "a1"); + const b = stack.activities.find((x) => x.id === "b1"); + + // Pop targeted the restored top: it began its exit transition (the Popped + // event, dispatched at the current time, sorts after the past-dated + // restored events)... + expect(b?.transitionState).toEqual("exit-active"); + expect(b?.exitedBy?.name).toEqual("Popped"); + // ...and the restored activity below is re-exposed as the active one. + expect(a?.transitionState).toEqual("enter-done"); + expect(a?.isActive).toBe(true); +}); + +test("load - load 후 stepPush가 최신 활성 activity(복원 최상단)를 타깃합니다", () => { + const { actions } = makeCoreStore({ + initialEvents: config(["A", "B"], 350), + plugins: [ + provideSnapshotPlugin([ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ]), + ], + }); + + actions.stepPush({ stepId: "s1", stepParams: { step: "1" } }); + + const stack = actions.getStack(); + const a = stack.activities.find((x) => x.id === "a1"); + const b = stack.activities.find((x) => x.id === "b1"); + + // StepPushed resolves its target by eventDate (latest active activity) — + // it lands on the restored top because the preserved past dates keep the + // restored order below the new event. + expect(b?.steps.map((s) => s.id)).toEqual(["b1", "s1"]); + expect(b?.params.step).toEqual("1"); + expect(a?.steps.map((s) => s.id)).toEqual(["a1"]); +}); diff --git a/core/src/snapshotRoundtrip.spec.ts b/core/src/snapshotRoundtrip.spec.ts new file mode 100644 index 000000000..0e0d9fa4b --- /dev/null +++ b/core/src/snapshotRoundtrip.spec.ts @@ -0,0 +1,155 @@ +import type { DomainEvent } from "./event-types"; +import { makeEvent } from "./event-utils"; +import type { StackflowPlugin } from "./interfaces"; +import { makeCoreStore } from "./makeCoreStore"; +import type { StackSnapshot } from "./StackSnapshot"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +let dt = 0; + +const enoughPastTime = () => { + dt += 1; + return new Date(Date.now() - MINUTE).getTime() + dt; +}; + +const config = (activityNames: string[]): DomainEvent[] => [ + makeEvent("Initialized", { + transitionDuration: 350, + eventDate: enoughPastTime(), + }), + ...activityNames.map((activityName) => + makeEvent("ActivityRegistered", { + activityName, + eventDate: enoughPastTime(), + }), + ), +]; + +const provideSnapshotPlugin = + (snap: StackSnapshot): StackflowPlugin => + () => ({ + key: "provider", + provideSnapshot: () => snap, + }); + +test("load - load 직후 captureSnapshot이 같은 탐색 기록을 재구성하는 스냅샷을 반환합니다", () => { + const original: StackSnapshot = { + $schema: "stackflow.snapshot.v1", + events: [ + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("Pushed", { + activityId: "b1", + activityName: "B", + activityParams: {}, + eventDate: enoughPastTime(), + }), + makeEvent("StepPushed", { + targetActivityId: "b1", + stepId: "s2", + stepParams: { step: "2" }, + eventDate: enoughPastTime(), + }), + ], + }; + + const first = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [provideSnapshotPlugin(original)], + }); + + const recaptured = first.actions.captureSnapshot(); + + // The replay preserves snapshot events byte-for-byte (eventDate included) + // and capture exports them back as-is, so capture∘load is an identity on + // the snapshot events. + expect(recaptured.events).toEqual(original.events); + + const second = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [provideSnapshotPlugin(recaptured)], + }); + + const stack = second.actions.getStack(); + const a = stack.activities.find((x) => x.id === "a1"); + const b = stack.activities.find((x) => x.id === "b1"); + + // Same activity column and z-order A→B (observed via zIndex/isTop). + expect(a?.name).toEqual("A"); + expect(b?.name).toEqual("B"); + expect((a?.zIndex ?? -1) < (b?.zIndex ?? -1)).toBe(true); + expect(b?.isTop).toBe(true); + + // Same steps on B, including the preserved stepId. + expect(b?.steps.map((s) => s.id)).toEqual(["b1", "s2"]); + expect(b?.steps[1].params.step).toEqual("2"); +}); + +test("load - pause 중 캡처한 스냅샷은 paused 스택으로 복원되고 resume하면 큐잉된 항해가 적용됩니다", () => { + const source = makeCoreStore({ + initialEvents: [ + ...config(["A", "B"]), + makeEvent("Pushed", { + activityId: "a1", + activityName: "A", + activityParams: {}, + eventDate: enoughPastTime(), + }), + ], + plugins: [], + }); + + source.actions.pause(); + source.actions.push({ + activityId: "b1", + activityName: "B", + activityParams: {}, + }); + + // Queued behind the pause, b1 is not applied to the live stack. + expect(source.actions.getStack().activities.some((x) => x.id === "b1")).toBe( + false, + ); + + let captured: StackSnapshot; + try { + captured = source.actions.captureSnapshot(); + } finally { + // Resume so the paused source store settles and its polling interval + // clears, even when the capture above throws. + source.actions.resume(); + } + + // The snapshot records the paused stack as-is: the Paused marker together + // with the queued push. + expect(captured.events.map((e) => e.name)).toEqual([ + "Pushed", + "Paused", + "Pushed", + ]); + + const restored = makeCoreStore({ + initialEvents: config(["A", "B"]), + plugins: [provideSnapshotPlugin(captured)], + }); + + // The paused stack round-trips as a paused stack: b1 stays quarantined, so + // the restored visible state matches what was visible at capture time. + const stack = restored.actions.getStack(); + expect(stack.globalTransitionState).toEqual("paused"); + expect(stack.activities.map((x) => x.id)).toEqual(["a1"]); + + // Resuming the restored session applies the queued navigation — the pending + // push survived the reload instead of being lost or force-applied. The + // previous session's pauser is gone, so resuming is the new session's call. + restored.actions.resume(); + expect( + restored.actions.getStack().activities.map((x) => x.id), + ).toEqual(["a1", "b1"]); +}); diff --git a/core/src/utils/makeActions.ts b/core/src/utils/makeActions.ts index 31022aa4c..bd759dc4e 100644 --- a/core/src/utils/makeActions.ts +++ b/core/src/utils/makeActions.ts @@ -11,7 +11,10 @@ export function makeActions({ dispatchEvent, pluginInstances, actions, -}: ActionCreatorOptions): Omit { +}: ActionCreatorOptions): Omit< + StackflowActions, + "dispatchEvent" | "getStack" | "captureSnapshot" +> { return { push(params) { const { isPrevented, nextActionParams } = triggerPreEffectHook( diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts index 66a1f6b03..b77ca12e3 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts +++ b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts @@ -1,9 +1,8 @@ import type { CoreStore, - PushedEvent, + SnapshotEvent, Stack, StackflowPlugin, - StepPushedEvent, } from "@stackflow/core"; import { makeCoreStore, makeEvent } from "@stackflow/core"; import type { Location, MemoryHistory } from "history"; @@ -70,13 +69,12 @@ const stackflow = ({ * `@stackflow/react`에서 복사됨 */ const pluginInstances = plugins.map((plugin) => plugin()); - const initialPushedEvents = pluginInstances.reduce< - (PushedEvent | StepPushedEvent)[] - >( + const initialPushedEvents = pluginInstances.reduce( (initialEvents, pluginInstance) => pluginInstance.overrideInitialEvents?.({ initialEvents, initialContext: {}, + initInfo: { kind: "create" }, }) ?? initialEvents, [], ); @@ -214,6 +212,7 @@ describe("historySyncPlugin", () => { pluginInstance.overrideInitialEvents?.({ initialEvents: [], initialContext: {}, + initInfo: { kind: "create" }, }); expect(fallbackActivity).toHaveBeenCalledTimes(1);