diff --git a/.changeset/fep-2548-create-load-distinction.md b/.changeset/fep-2548-create-load-distinction.md new file mode 100644 index 000000000..a5b280fcf --- /dev/null +++ b/.changeset/fep-2548-create-load-distinction.md @@ -0,0 +1,17 @@ +--- +"@stackflow/core": major +--- + +Distinguish how a stack is initialized — created fresh (`create`) or restored from a snapshot (`load`) — and add the surface a snapshot round-trip needs: `StackSnapshot`, `actions.captureSnapshot()`, the `provideSnapshot` / `onLoadError` plugin hooks, and `initInfo` on `onInit`. Runtime behavior is unchanged when no snapshot is provided; the breaks are type-level only — the `StackflowPluginHook` → `StackflowPluginInitHook` rename, plus new required members on `StackflowActions` and on the `onInit` / `overrideInitialEvents` hook signatures — hence the major. + +**Added** + +- `StackSnapshot` (with the `SnapshotEvent` / `NavigationEvent` unions) — a plain-data record of the events a stack recorded at runtime — plus `SnapshotLoadError`. +- `actions.captureSnapshot()` to export that record, and the `provideSnapshot` / `onLoadError` plugin hooks to supply a snapshot at creation time and route a failed load. +- `initInfo: { kind: "create" | "load" }` on `onInit`; `overrideInitialEvents` now runs on the load path too and receives the same `initInfo`. + +**Changed** + +- Renamed the `StackflowPluginHook` type to `StackflowPluginInitHook`. +- `StackflowActions` now requires `captureSnapshot`, the `onInit` / `overrideInitialEvents` hook arguments now require `initInfo`, and `overrideInitialEvents`' parameter widens from `(PushedEvent | StepPushedEvent)[]` to `SnapshotEvent[]` — hand-written mocks and wrap-and-forward plugins must adopt these. +- `validateEvents` now also rejects a `Replaced` that names an unregistered activity (does not fire in config-first usage). 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/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.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/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/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/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);