Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
71814a8
add context
ENvironmentSet Jul 7, 2026
33e0805
docs: add FEP-2548 mechanism design run plan, extend domain glossary
ENvironmentSet Jul 7, 2026
16e6a5c
docs: add FEP-2548 init/load distinction mechanism design
ENvironmentSet Jul 7, 2026
fb20c55
docs: revise FEP-2548 mechanism — drop dedicated init-interception ho…
ENvironmentSet Jul 7, 2026
0006fb3
add impl plan
ENvironmentSet Jul 7, 2026
a353f51
opus 4.8
ENvironmentSet Jul 7, 2026
3100588
test(core): add create/load mechanism harness + §3 type-signature stubs
ENvironmentSet Jul 7, 2026
d83506e
feat(core): implement create/load distinction mechanism
ENvironmentSet Jul 7, 2026
fc9fbce
fix(core): allow undefined/void return from provideSnapshot (C1#8)
ENvironmentSet Jul 8, 2026
66705fe
docs(changeset): fix hook signature typos, cause.kind notation, and s…
ENvironmentSet Jul 8, 2026
86351e2
test(core): pin raw propagation when provideSnapshot/onLoadError thro…
ENvironmentSet Jul 8, 2026
ac5c7ba
test(core): verify recover:create resumes the full create pipeline (C…
ENvironmentSet Jul 8, 2026
2268a24
test(core): pin load contract edges — non-create recovery rethrows, d…
ENvironmentSet Jul 8, 2026
62cef36
test(core): cover pop/step navigation after load (C2#3)
ENvironmentSet Jul 8, 2026
382a209
test(core): load postcondition for a snapshot captured while paused (…
ENvironmentSet Jul 8, 2026
ab22fe4
chore(core): trim HOW-restating comments to WHY-only (C1#11)
ENvironmentSet Jul 8, 2026
6bb72db
fix(core): rebase snapshot events into the window after static events…
ENvironmentSet Jul 8, 2026
8cc091b
plans
ENvironmentSet Jul 8, 2026
b4be141
refactor(core): rename SnapshotLoadError cause kinds to read as the s…
ENvironmentSet Jul 9, 2026
1a74f70
refactor(core): promote onInit's initializedBy string to an initInfo …
ENvironmentSet Jul 9, 2026
c0f3d32
feat(core): run the overrideInitialEvents chain on the load path with…
ENvironmentSet Jul 9, 2026
1ff0b48
refactor(core): drop the dead setPrototypeOf workaround in SnapshotLo…
ENvironmentSet Jul 9, 2026
7929873
refactor(core): fold the load registration check into validateEvents …
ENvironmentSet Jul 9, 2026
7f2c7b2
refactor(core): rebase static events with the navigation events and d…
ENvironmentSet Jul 9, 2026
c382340
fix(core): capture only committed navigation in captureSnapshot (revi…
ENvironmentSet Jul 9, 2026
c03e3ee
fix(core): drop a whole uncommitted activity's events when capturing,…
ENvironmentSet Jul 9, 2026
4c11f32
docs(core): note the unified registration check now covers Replaced (…
ENvironmentSet Jul 9, 2026
6068107
fix(core): capture every recorded navigation event, excluding only un…
ENvironmentSet Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/fep-2548-create-load-distinction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@stackflow/core": minor
---

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 `NavigationEvent[]` (implementations with inferred parameters are unaffected).

- `StackSnapshot` (and the `NavigationEvent` union) — a plain-data value, owned by core, that carries only navigation events. Encoding to a persistence medium (codec) is the consumer's responsibility.
- `actions.captureSnapshot()` — capture the current navigation history as a snapshot; callable from any hook, at any time.
- `provideSnapshot?({ initialContext })` plugin hook — called synchronously at creation time to supply a snapshot to load from. Returning `null`/`undefined` 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 `{ recover: "create" }` resumes the create path; returning nothing (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 `NavigationEvent[]` 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, and its return is adopted as the replay sequence — re-dated in array order so the restored stack settles, then run 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 { recover: "create" };
},
});
```

A load reconstructs the stack by replaying the snapshot's navigation events — as passed through the plugins' `overrideInitialEvents` chain — through the existing aggregate machinery: it re-derives static information (transition duration, the registered-activity set) from the current config, re-dates the events so the restored stack settles synchronously, and preserves each event's `id`/`activityId`/`stepId` byte-for-byte. 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.
71 changes: 71 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Stackflow — Ubiquitous Language

## Terms

### 생성 (Create)

새 Stack을 처음부터(from scratch) 만들어내는 진입 경로. 보존된 탐색 맥락이 없는 상태에서
시작한다. defaultActivity 진입과 deep link(URL) 진입이 여기에 속하며, 둘 다 의미상 push다 —
따라서 activity 진입 가드의 적용 대상이다.

### 복원 (Load)

보존된 탐색 맥락(스냅샷)으로부터 Stack을 재구성하는 모든 진입 경로. 저장 매체와
무관하다 — persister의 storage 스냅샷이든 `history.state` 복원이든, 스냅샷에서
스택을 되살리면 load다. load로 만들어진 스택은 보존 시점에 이미 검증된 것으로
간주하므로 activity 진입 가드를 건너뛴다.

### 진입 (Entry)

Activity가 Stack에 나타나게 되는 사건. Create 경로의 최초 진입은 의미상 push이며,
플러그인이 가로챌(검사·차단·변경) 수 있어야 한다. Load 경로의 진입은 push가
아니다 — 이미 검증된 맥락의 재구성이므로 가로채기 대상이 아니다.

### 탐색 기록 (Navigation History)

Stack이 표현하는 항해의 기록 — 무엇을 지금 보고 있고, 무엇을 봤었으며, 어떤 순서로
보았는가. 탐색 맥락의 필수 핵심이다. 복원 후 이전과 같이 네비게이션할 수 있으면
탐색 기록은 보존된 것이다.

### 스냅샷 (Snapshot)

보존된 탐색 맥락. load 경로의 입력이 되는, 과거에 정상 상태였던 Stack의 보존물.
필수 내용은 탐색 기록이다 — 전환 진행 상태, 일시정지 상태, 등록 activity 목록 같은
나머지 상태는 부가적이며 복원 비필수다(특히 전환 진행 정보는 프로세스 생애주기를
넘나들며 다루기 어려워 폐기가 열린 선택이다).
형식은 core가 소유한다 — 현재 Stack에서 스냅샷을 얻는 방법(캡처)과 스냅샷으로부터
Stack을 만드는 방법(load)이 모두 core 계약에 속하며, 캡처→보존→load 왕복은 core
계약만으로 성립한다. 공급자(persister)는 언제 캡처하고 어디에 보존하며 언제
버릴지만 책임진다.
단, core가 소유하는 형식은 구조(무엇이 담기는가)다 — 직렬화(codec)는 core 계약이
아니며 스냅샷을 보존 매체에 싣는 방법은 사용하는 쪽의 책임이다. core는 스냅샷 안의
값(activityParams·activityContext 등)이 직렬화 가능한지 전제하지 않는다.

### 정상 상태 (Normal State)

Stack의 불변식(invariants)이 모두 충족된 상태. stackflow core + plugins
(+ react integration) 조합의 동작으로 만들어질 수 있는 상태는 모두 정상 상태다.
활성 activity 개수(1개 이상 무엇이든), exit 상태 activity의 존재, 전환 진행 여부와
무관하다 — 도달 가능성(reachability)이 기준이다. Load의 사후조건은 "스냅샷이
보존한 정상 상태의 충실한 재구성"이며, 그 충실성의 필수 범위는 탐색 기록이다.

## Relationships

- Stack은 Create 또는 Load 중 정확히 하나의 경로로 만들어진다.
- Create ↔ Load 구분은 core가 표현한다 — 플러그인(guard, persister, history-sync)이
진입 경로별 정책을 세우는 근거가 된다.
- Load는 Stack 생성 시점에만 일어난다. 스냅샷은 생성 시점에 동기적으로 주어져야
하며, 생성된 Stack은 그 순간부터 항상 정상 상태다 — "복원 대기 중" 같은 중간
상태는 존재하지 않는다. 비동기 스냅샷 소스의 수용은 스택 생성을 지연하는 상위
레이어의 부트스트랩 문제다.
- Load 실패(손상된 스냅샷, 등록 해제된 activity 등)는 조용한 폴백이 아니라
명시적 에러다. 유효하지 않은 스냅샷으로부터 정상 상태 Stack이 몰래 만들어지는
일은 없다.
- Load 실패 에러의 1차 처리 책임은 스냅샷 공급자(persister 등)에게 있다.
공급자가 복구 정책(스냅샷 폐기, create 재시도 등)을 결정하며, 앱 개발자는
기본적으로 이 에러를 다루지 않는다.
- Create ↔ Load 구분은 Stack 생성 시점의 일회성 신호다. 구분이 Stack 상태에 지속
속성으로 남지는 않는다 — 이를 알아야 하는 소비자는 생성 시점에 부착되어 있어야
한다.
- Stack 생성은 스냅샷을 최대 하나만 받는다. 복수 복원 후보의 선택·조정은 Stack
생성 이전 계층(공급자들)의 책임이며, core는 조정자 역할을 맡지 않는다.
36 changes: 36 additions & 0 deletions core/src/SnapshotLoadError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* 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.
* - `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;
}
}
40 changes: 40 additions & 0 deletions core/src/StackSnapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type {
PoppedEvent,
PushedEvent,
ReplacedEvent,
StepPoppedEvent,
StepPushedEvent,
StepReplacedEvent,
} from "./event-types";

/**
* The six navigation events — a subset union of the existing domain event
* types (no new vocabulary is introduced). A snapshot carries only these.
*/
export type NavigationEvent =
| PushedEvent
| ReplacedEvent
| PoppedEvent
| StepPushedEvent
| StepReplacedEvent
| StepPoppedEvent;

/**
* 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";

/**
* Navigation events only. `Initialized` and `ActivityRegistered` are not
* carried — the current config re-derives them at load time. `Paused` and
* `Resumed` are not carried either — they are discarded as transition and
* pause information.
*/
events: NavigationEvent[];
};
Loading
Loading