feat(core): create/load distinction mechanism for snapshot restoration (FEP-2548)#723
feat(core): create/load distinction mechanism for snapshot restoration (FEP-2548)#723ENvironmentSet wants to merge 30 commits into
Conversation
- CONTEXT.md: add Navigation History term; narrow load fidelity scope to navigation history (R12); snapshot codec is outside core contract (R13) - run-plan-fep-2548-mechanism.md: diverge-converge-refine run plan (7 seeded generators -> curation -> league tournament -> review-loop) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Confirmed mechanism design for distinguishing Stack init (creation) from load (snapshot restoration) in stackflow core. - Snapshot = navigation event log; load = replay through existing aggregate (reachability + fidelity hold by construction — no new validator) - Public contract: StackSnapshot, actions.captureSnapshot(), provideSnapshot / onLoadError / onBeforeInitialPush hooks, onInit(createdBy) one-shot signal - Non-breaking: unchanged creation path when no snapshot provided; overrideInitialEvents preserved and treated as init - Load validation rejects any snapshot event (Pushed/Replaced/future activity-introducing) whose activity is unregistered in load-time config - Resolves the four deferred decisions; proves persister / guard / history-sync consumers close over the core contract alone Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ok, rename init→create path
Maintainer-confirmed revision of the init/load distinction design.
- Remove onBeforeInitialPush; public surface 5 → 4. Create-path entry
interception is done via the existing overrideInitialEvents chain
(inspect / strip = block / replace = redirect), since initial events are
pre-aggregate array data — stripping is the pre-aggregate equivalent of
preventDefault, so no dedicated hook is warranted. Guard now requires zero
new core surface; ordering discipline + an onInit validation belt cover it.
- Terminology: createdBy → initializedBy ("create" | "load"); the "init"
creation path is renamed "create" (built from scratch). "initialize" is the
bootstrap umbrella (onInit / store.init, fires on both paths), so
onInit({ initializedBy: "load" }) is no longer self-contradictory. The
initial* inputs (initialEvents, initialActivity, …) and the Initialized
event keep their names.
- CONTEXT.md ubiquitous language updated: 초기화(Init) → 생성(Create).
- The anti-unification rejection (post-effect pushState duplication, loader
plugin pause counterexample) still stands; only "realize it via a dedicated
hook" is reversed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the FEP-2548 create/load verification suite (core/src/*.spec.ts), 1:1 with the confirmed test plan, plus type-signature stubs for the new public surface so the suite compiles. Implementation is deferred: the new harness is red at the unimplemented points (stub throw / assertion), while the existing core suite stays green (no behavioral change). New public surface (stubs, per design §3): - StackSnapshot / NavigationEvent types (navigation-event subset union) - SnapshotLoadError class with a three-kind cause (incompatible-schema / invalid-events / empty-navigation) - StackflowActions.captureSnapshot() — throws "not implemented yet" - StackflowPlugin.provideSnapshot? / onLoadError? optional hooks - StackflowPluginHook gains initializedBy: "create" | "load", with a "create" placeholder at the onInit call site Suites (core/src): - captureSnapshot: snapshot format + capture edges (transition/pause) - provideSnapshot: single snapshot slot, conflict, null/undefined - createPath: create sequence, override interception, N1/N2, no-hook - loadPath: structure/registration/replay/postcondition/routing, L1/L3/L6, sync - rebase: order (RB1), settle (RB2), clock reversal (RB4), id preserve (RB5) - initializedBy: create signal, no persisted trace - snapshotRoundtrip: capture∘load∘capture stability (L5) - persisterRoundtrip: capture→JSON→load round trip + corrupt-snapshot recovery The empty-navigation "all-popped" case uses a pops-only snapshot (events:[Popped]) because core cannot pop the root activity, so a Pushed→Popped history never reaches zero enter-state activities; the pops-only snapshot reaches that state and preserves the item's intent (non-empty events, zero enter activities). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fill the create/load stubs so a stack knows how it was born — freshly
(`create`) or restored from a snapshot (`load`) — and close the snapshot
round-trip (capture → persist → load) through additive plugin contracts.
A store with no snapshot provider takes the same code path as before.
- captureSnapshot() normalizes the raw event log (sort by eventDate,
dedupe by id, keep only navigation events), so it reflects pause-queued
events and the array order equals the replay order.
- makeCoreStore polls provideSnapshot across plugins once: zero non-null
-> create; exactly one -> load; more than one -> a plain creation error
naming the conflicting keys (not a SnapshotLoadError, not routed).
- loadSnapshot reconstructs the stack by replaying the snapshot's
navigation events through the existing aggregate machinery: structure
check (incompatible-schema), registration check over Pushed and Replaced
(invalid-events), rebase to a settled past window, replay, then an
enter-state postcondition (empty-navigation). Static info
(transitionDuration, registered set) is re-derived from the current
config; id/activityId/stepId are byte-preserved, only eventDate rebased.
- A failed load is routed only to the providing plugin's onLoadError;
{ recover: "create" } resumes the create path without re-polling, and
void/no-handler throws out of makeCoreStore.
- onInit now receives the real initializedBy signal.
No new domain events, stack state properties, makeCoreStore options, or
changes to aggregate/validateEvents/reducers/overrideInitialEvents.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 1c58ead The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
@stackflow/core
@stackflow/link
@stackflow/plugin-basic-ui
@stackflow/plugin-blocker
@stackflow/plugin-devtools
@stackflow/plugin-google-analytics-4
@stackflow/plugin-history-sync
@stackflow/plugin-lifecycle
@stackflow/plugin-renderer-basic
@stackflow/plugin-renderer-web
@stackflow/plugin-sentry
@stackflow/plugin-stack-depth-change
@stackflow/react-ui-core
@stackflow/react
commit: |
Deploying stackflow-demo with
|
| Latest commit: |
1c58ead
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://79058110.stackflow-demo.pages.dev |
| Branch Preview URL: | https://feature-fep-2548.stackflow-demo.pages.dev |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
stackflow-docs | 1c58ead | Commit Preview URL | Jul 10 2026, 09:03 AM |
리뷰 — create/load distinction mechanism (FEP-2548)총평방향과 뼈대는 탄탄합니다. 기존 검증 방식: PR head( 🔴 머지 전 필수1. [major/correctness] 구조 검사가 payload 없는 이벤트를 통과시켜 — 조용히 오염된 스택이 만들어지고
|
리뷰 — 설계 충실성 · 요구사항(FEP-2548) 검증결론: 승인 권장 (draft 해제 전 minor 수정 몇 건 권장). 설계 문서(§3 계약 · §4 시퀀스 · §5 불변식)를 충실히 구현했고, Linear 요구사항 R1–R13과 완료 기준을 모두 충족합니다. Blocking 이슈 없음 — 발견 사항은 전부 minor(changeset 표기 1, 방어 강화 1, 테스트 공백 2, 설계 문서와의 자구 차이 1, 관찰 가능한 미세 차이 1)입니다. 검증 방법: 7개 차원(공개 계약 / 생성 시퀀스 / 불변식 / 요구사항 / 코드 정확성 / non-breaking / 테스트)으로 병렬 정밀 조사 후, finding 각각을 2개 렌즈(반증 시도 · 영향 평가)로 적대 재검증. 추가로 핵심 구현·스펙 직접 재독 + 빌드/타입체크/테스트 재현 실행. 실행 검증
설계 충실성 (§3–§5)
요구사항 커버리지 (Linear comment-3dbff893)
Findings (전부 minor/info — blocking 없음)1. [minor] 재기저가 RB3의 일반 케이스 창 배치를 구현하지 않음 — loadSnapshot.ts:136-141 2. [minor] 구조 검사 깊이 — payload 결손 이벤트가 4개 검사를 전부 통과 — loadSnapshot.ts:109-118 3. [minor] 테스트 공백 — load 후 pop/step 미검증 (R11 균질성) — rebase.spec.ts:116 인근 4. [minor] 테스트 공백 — pause 중 캡처 스냅샷의 load 사후조건 미검증 — captureSnapshot.spec.ts:266 인근 5. [minor] changeset이 6. [info] N1 미세 차이 — 7. [참고] 중복 id 스냅샷의 경계 동작 미고정 PR body의 open questions에 대한 소견
기타
🤖 Generated with Claude Code |
The JSDoc, changeset, and runtime (`?? null`) all promise that returning `undefined` means "nothing to provide", but the declared return type `StackSnapshot | null` rejected a bare `return;`. Widen it to `StackSnapshot | null | void`, following the sibling hook `onLoadError`'s existing `| void` precedent (including the biome-ignore rationale), and drop the forced cast the old type required in provideSnapshot.spec.ts. Runtime behavior is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…often additive claim (C1#9, C2#5)
- `provideSnapshot?(({ initialContext }))` / `onLoadError?(({ error, ... }))`
double-parenthesis typos — these render verbatim in the CHANGELOG.
- `SnapshotLoadError`'s cause is a discriminated object, not a string enum:
describe it as `cause.kind` so release-note readers don't write
`error.cause === "invalid-events"` (always false).
- "entirely additive" overstated the change: `StackflowActions.captureSnapshot`
and `onInit`'s `initializedBy` are required members, which is compile-breaking
for mock constructors and wrap-and-forward hook callers. State the claim as
runtime-additive with a type-level caution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…w; make persister mimic self-handle decode failure (C1#4) A throwing hook is undefined behavior today: the error propagates raw out of makeCoreStore and onLoadError is never involved. Two characterization tests pin that propagation so any future change to it is a conscious contract decision. The persister mimic previously demonstrated the fragile pattern (raw JSON.parse inside provideSnapshot — a truncated storage write would crash boot). As the reference implementation persister authors will copy, it now self-handles decode failure: discard the stored value and return null so creation falls back to the create path. A third round-trip test exercises that branch end-to-end. Existing assertions are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…1#10)
The recover contract is that a { recover: "create" } return resumes the
create path from its start — not merely that some stack comes out. Existing
recover tests only asserted the resulting activity list, so a regression
that skipped the overrideInitialEvents chain or the initial-activity
handlers on the recovery path would have passed unnoticed.
Pin the coexistence case of a failing provider and an
overrideInitialEvents plugin: the chain runs over the option's initial
events, its substitution is what the stack is built from, the
initial-activity handler judges that substitution, and onInit reports
initializedBy "create".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uplicate-id last-wins, unknown-property tolerance (C1-nit-3, C2#7)
Three edges of the load contract existed only as unstated behavior:
- onLoadError returning a truthy value other than the exact
{ recover: "create" } decision rethrows the SnapshotLoadError — the
recovery check must never loosen into truthiness.
- A consumer-transformed snapshot with duplicate event ids is accepted,
and dedup keeps the last occurrence (the earlier event is dropped).
Note the direction: last-wins, the opposite of what C2#7's prose
described.
- Unknown properties — both on the snapshot object and on individual
event items — are tolerated (forward compatibility), so the structure
check isn't tightened by accident.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-load navigation was only tested for push, leaving half of the homogeneity postcondition unpinned: a rebase regression that broke pop/step targeting would have passed the suite. Both interact directly with rebased dates — pop exits the latest activity and StepPushed resolves its target as the latest active activity by eventDate. Pin both on a restored two-activity stack: pop starts the exit transition on the restored top and re-exposes the restored activity below as active; stepPush lands its step on the restored top, not the activity underneath. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…C2#4) The capture contract promises that replaying a pause-time snapshot yields the state as if the pause never happened — queued navigation fully applied. Only the capture half was tested (the queued event is included in the snapshot); nothing verified that loading such a snapshot actually materializes the queued activity. Close the round trip: pause, push (queued, not yet visible), capture, then load into a fresh store — the queued activity comes out settled (enter-done, on top) and the restored stack is idle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three comments restated what the code (or a type) already says and would drift as the code changes: - loadSnapshot.ts header enumerated the four checks and their error mapping — that truth lives in the SnapshotLoadError cause type and the function body. The WHY that stays: static info is re-derived from the current config, never the snapshot. - assertSnapshotStructure's JSDoc walked through its own checks; keep only why the check exists (fail loudly before replay, not fold into a corrupt stack). - createStack's JSDoc narrated chain/handlers/aggregate steps; keep only the guarantee it encodes (no provider → observably identical to before). Design-doc marker references (WHY pointers) are intentionally kept. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (C1#5, C2#1) The rebase used a fixed 1ms integer spacing that never looked at static event dates. The react integration backdates static events by only 2×transitionDuration, so a snapshot with more events than transitionDuration ms (350 by default) had its earliest events folding before Initialized/ActivityRegistered — the design's general placement rule (inside the window after the static events, fractional dates fitting any count) existed only on paper, while the code comment claimed RB1–RB5. Give the rebase the latest static event date as the window's exclusive lower bound and space events fractionally: min(1, window/(N+1)) ms keeps any history length inside the window, and a roomy window degrades to the previous 1ms spacing. A degenerate window (static events dated at or past the settled bound) falls back to settledness alone, as the design's exception path argues is safe. Pin the placement with a 400-event snapshot against react-style static events: every rebased date lands after every static event, at or before creation − transitionDuration, strictly increasing, and the replay settles. The pin fails on the previous fixed-spacing implementation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
꼼꼼한 리뷰 감사합니다. finding별 처분 결과입니다 — 수용 항목의 커밋은 이 브랜치에 반영되어 있습니다. 조치 완료 상태에서 처분 요약
상세#1 — 기각 (스코프/비용-효용 결정). 재현된 실패 모드(payload 결손 이벤트가 4개 검사를 통과해 #2 — 기각. 루트의 plan 문서 2개·설계 문서·CONTEXT.md 배치는 의도된 결정입니다(지적하신 파일·행 위치의 사실관계는 확인했습니다). 삭제·이동하지 않습니다. #3 — 무조치. core의 load는 계약대로 성공했습니다 — 재현하신 크래시는 아직 load path 지원이 준비되지 않은 현행 plugin-history-sync가 자기 #4 — 수용 (86351e2). ① #5 — 수용 (6bb72db). 지적대로 구현이 RB3 일반 규칙(정적 이벤트 뒤 창 안 배치)을 구현하지 않은 채 주석만 "RB1–RB5"를 주장하고 있었습니다. rebase가 정적 이벤트 max date를 창의 배타 하한으로 받아 #6 — 기각 (스코프 결정). 동작 자체는 as-designed임을 확인했고(설계 §3.2가 "복사 후 변환, 직접 변이는 미정의 동작"을 명문화), shipped 표면(JSDoc)으로의 전사는 문서화 작업으로서 이번 PR 스코프가 아닙니다. #7 — 타당, 별도 선행 PR로 이연. activity 정렬이 id에 의존하는 것 자체가 문제라는 지적에 동의합니다 — id에 순서(order)가 부여되어 있으면 안 됩니다. 증상 방어(id 단조 검증 추가)가 아니라, activity 정렬이 id 대신 eventDate나 z-index 같은 더 적절한 값을 쓰도록 바꾸는 별도 PR을 이 PR에 선행해서 만들어 구조적으로 해결하겠습니다. 이번 PR에서는 조치하지 않습니다. #8 — 수용 (fc9fbce). #9 — 수용 (66705fe). 이중 괄호 오타 2곳 수정, #10 — 수용 (ac5c7ba). 실패 공급자 + #11 — 부분 수용 (ab22fe4). 코드 재서술 주석 3곳(loadSnapshot.ts 헤더의 4-검사 열거, #12 — 기각 (유예 결정 기록). 문서 사이트·CLAUDE.md 갱신은 이번 PR 스코프가 아닙니다. finding이 요구한 "명시적 결정 기록"은 이 답글이 그 기록입니다 — 새 공개 표면의 문서 사이트 반영은 FEP-2546과 함께 다룹니다. #13 — 무조치. 복원 액티비티의 loaderData 재주입은 core 계약의 문제가 아니라 소비자(plugin-loader)의 책임이며, major 릴리즈에서 plugin-loader를 함께 업데이트해 다룰 계획입니다. §7.1.3의 승격은 하지 않습니다. nit-1 — 수용. PR 본문의 해당 문구를 "polled synchronously at store creation (in React, inside nit-2 — 기각. 의도된 구분이고 설계 §3.3에 명문입니다: 다중 프로바이더 충돌은 특정 스냅샷의 결함이 아니라 배선 버그라 nit-3 — 수용 (2268a24). 세 엣지 전부 핀했습니다: nit-4 / nit-5 / 기각후보 3건 — 제안 그대로 따릅니다: 커밋 이력은 squash-merge(리라이트 없이 큐레이션 커밋을 위에 쌓음 — 이번 조치 커밋들이 그 방식입니다), stack-depth-change의 |
설계 충실성·요구사항 검증 감사합니다. finding별 처분 결과입니다 — 수용 항목의 커밋은 이 브랜치에 반영되어 있고, 조치 완료 상태에서 처분 요약
상세1 — 수용 (6bb72db). 권고하신 두 방향 중 전자(소수 간격으로 창 안에 채우기)를 택했습니다 — 설계 문서는 무변경이고 구현을 RB3에 맞췄습니다. rebase가 정적 이벤트 max date를 창의 배타 하한으로 받아 2 — 기각 (스코프/비용-효용 결정). "설계 §3.4의 검사 범위 명세에는 충실하므로 구현 잘못은 아니"라는 판단 그대로입니다 — 검사 심화는 §3.4의 검사 범위 개정을 수반하는 설계 결정인데, 지금 stackflow 구조에서 payload 수준 검증까지 들어가는 복잡도 대비 이 시나리오("유효 JSON + 유효 3 — 수용 (62cef36). 복원 2-스택 위에서 pop(복원 최상단 exit 전환 + 아래 복원 activity 재노출·활성화)과 stepPush(eventDate 기반 최신 활성 activity 타깃이 복원 최상단에 적중)를 핀하는 테스트 2건을 추가했습니다 — 지적하신 대로 둘 다 재기저 date와 직접 상호작용하는 지점입니다. 4 — 수용 (382a209). pause 중 push → 캡처(큐잉 이벤트가 가시 activity가 아님을 전제로 확인) → 새 스토어 load까지 왕복을 닫는 테스트를 추가했습니다 — 큐잉됐던 activity가 enter-done 최상단으로 물화되고 복원 스택이 idle임을 단언합니다. §3.2 계약("pause가 없었던 것처럼")의 load 측 절반이 이제 고정됩니다. 5 — 수용 (66705fe). 6 — 기록 접수 (무조치). 실측으로 확인했습니다 — main은 7 — 수용 + 방향 서술 정정 (2268a24). 의도된 경계가 맞고, 테스트 한 줄로 고정하자는 제안을 수용했습니다. 다만 방향 서술을 정정합니다: open question 1 — "미정의로 두는 것에 동의" 확인했습니다. 계약은 승격하지 않고, 현재의 raw 전파 동작을 특성화 테스트 2건으로만 핀해 회귀 감지 장치를 두었습니다(86351e2 — 테스트 주석에 특성화 취지 명시). open question 2 — 설계 수정 불요 판단 동의 확인, 조치 없습니다. 기타 — 마커 주석 sweep은 전제가 소멸했습니다: 설계 문서·CONTEXT.md의 루트 배치는 의도된 결정으로 유지되어, 코드 주석의 §마커 참조 대상이 그대로 남습니다. 스펙 품질 평가(재생 결과 기준 계약 고정·compaction 진화 여지 보존)는 감사히 받았습니다 — 이번에 추가한 테스트들도 같은 기준(재기저 date 구체값이 아니라 창 경계·재생 결과)으로 작성했습니다. |
…napshot→events→stack pipeline (review: error-kind rename) The three kinds misnamed what they detect. `incompatible-schema` suggested a version incompatibility, but the check is a catch-all for any unrecognizable snapshot shape ($schema mismatch, non-array events, an item that is not a navigation event) — now `unrecognized-snapshot`. `invalid-events` suggested a defect intrinsic to the events, but the failure is relational — the events are fine, they just don't fit the current config (e.g. they materialize an unregistered activity) — now `incompatible-events`. `empty-navigation` describes a replay that settles with zero enter-state activities, i.e. an empty stack — now `empty-stack`. Alongside the rename: - `unrecognized-snapshot` gains a required `detail: string` naming which structural check failed (its sibling `incompatible-events` already carried one); the item check includes the offending index. It is a `string` (not `unknown`) because core authors the message itself, whereas `incompatible-events` wraps arbitrary thrown values. - The cause JSDoc states `empty-stack`'s precise condition: zero enter-state activities, not an empty `activities` array — exit-done activities may remain. All three kinds are public exports but unreleased, so the rename is free now and breaking later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…record (review: initInfo record)
The one-shot create/load signal was a bare string
(initializedBy: "create" | "load"). Promote it to a discriminated
record, initInfo: { kind: "create" | "load" }, because the two shapes
age differently: promoting a string to a record later is a breaking
change to the hook signature, while adding fields to an existing record
is additive. Per-path payloads are already latent — "create" is set
both on plain creation and on recover-after-load-failure, which a
consumer cannot currently tell apart but a record could expose
additively (e.g. { kind: "create", recoveredFrom }), and the load side
has room for provenance (e.g. { kind: "load", providedBy }). The
discriminant is named kind, matching SnapshotLoadErrorCause's
discriminated union.
The signal is unreleased, so the rename is free now and breaking later.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… an initInfo signal (review: load interception)
Activities restored from a snapshot never met an interception point:
the replay bypassed both overrideInitialEvents (skipped on load) and
onBeforePush (replay is not the action pipeline), so a consumer like
plugin-loader could not attach loaderData to restored activities —
"an entered activity runs its loader" broke exactly on the load path.
Run the plugins' overrideInitialEvents chain on load too:
- The hook signature widens to NavigationEvent[] in and out and gains
initInfo: { kind: "create" | "load" } — the same one-shot record
onInit receives (StackInitInfo, now a shared exported type). On load
the chain receives the snapshot's full replay sequence and its
return is adopted as the replay sequence.
- The chain runs after the structure check (hooks never see an
unrecognizable value) and before everything else, so the
registration check, rebase, replay, and enter-state postcondition
all apply to the sequence that actually replays. A reshaped return
that fails validation surfaces as a SnapshotLoadError routed to the
snapshot provider; a throwing hook is a plugin bug, not a snapshot
defect, and propagates raw.
- Load non-interception is no longer structural — it is each
consumer's responsibility. A plugin with no load policy must return
initialEvents unchanged, and plugins that fabricate initial events
(history-sync's URL interpretation) or write activityContext from
creation-scoped input (plugin-loader's initialLoaderData branch)
must guard on initInfo.kind === "load" in their own packages before
any snapshot provider ships alongside them — required follow-ups
within the same unreleased window as this mechanism.
- MakeCoreStoreOptions' onInitialActivityIgnored handler parameter
widens with the chain's return type, and the history-sync spec's
hand-built hook-argument call sites add the now-required initInfo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
|
등록 검사:
|
|
…adError (review: dead prototype workaround) Object.setPrototypeOf(this, SnapshotLoadError.prototype) restores the prototype chain that breaks only when `class extends Error` is down-level transpiled to ES5. This package never targets that low: the JS build (esbuild) is es2015 and the type build (tsc) is ESNext, both of which emit a native `class` whose native `extends Error` keeps the prototype chain — so `instanceof SnapshotLoadError` (makeCoreStore's load recovery branches) already holds without it. The workaround guards against an emission mode this build never produces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(review: unify registration check) loadSnapshot ran its own Pushed/Replaced registration check before replay. The Pushed half was already redundant — aggregate calls validateEvents, whose throw loadSnapshot wraps into incompatible-events — and validateEvents only omitted Replaced, though Replaced materializes an activity by name exactly as Pushed does. Add the Replaced arm to validateEvents (correcting that asymmetry at the source) and delete loadSnapshot's dedicated block. rebase touches only eventDate, and validateEvents is order-independent, so moving the check from pre-rebase to the post-rebase aggregate leaves the result identical; both routes surface as incompatible-events, so the observable failure is unchanged. The Replaced check is a missing-check correction, not a contract change: config-first replace only targets registered names, so it does not fire on the live path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rop the window math (review: rebase static together) The load rebase carried a latestStaticEventDate reduce, a static→settled window, fractional spacing, and a degenerate-window fallback for one reason: the self-imposed rule that static events (Initialized, ActivityRegistered) keep their original eventDate. But static events are navigation-inert — aggregate reads their eventDate only as a sort key, transitionDuration is a payload field rather than that event's date, and captureSnapshot never persists static events — so they need only stay ordered before the navigation events. Re-date static and navigation events in a single backward walk from now − transitionDuration, one step per event, and the window machinery falls away. Static-before-navigation becomes structural at any history length. It also fixes a latent td=0 defect: the react integration backdates static by 2·td, so at td=0 static and freshly re-dated navigation collided and the degenerate fallback sorted navigation ahead of static — surviving only because the reducer tolerates event order. The shared re-dating removes that reliance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ew: per-event settled capture) captureSnapshot took every navigation event from the raw log, including events whose own transition had not settled. Load is effect-silent — it assigns the reconstructed stack directly, replaying no PUSHED/onChanged effect — and the rebase drives every restored event to done, so a mid-transition or pause-queued event, once captured, reappeared on reload as a settled state the live session never committed and never fired its done-effect for. A push queued behind a pause that never resumed came back as a done activity that was never shown. Re-aggregate the log at capture time (flooring now at the latest event date, as dispatchEvent does, so a just-committed event reads as settled) and drop any event whose transition is still in flight: the entering event of an enter-active activity (its activity has not committed) and the exiting event of an exit-active one (its last committed state is preserved), plus everything queued in an unresumed pause. The predicate is per-event, not per-activity — a settled Pushed and an unsettled Popped can share a target, and only the Popped drops. This reverses two capture behaviors: a pause-queued push is no longer carried into the snapshot (previously it re-materialized on load), and a mid-transition event is no longer captured until it commits. The snapshot now equals the last committed navigation history. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… not just its entering event (review B1: capture dependency closure) The committed-navigation capture predicate collected uncommitted events per element — the entering event of an enter-active activity, the exiting event of an exit-active one, and pause-queued events. But a step event settles the instant it applies (Step* reducers have no transition gate), so a step dispatched onto an activity still mid-enter stayed "committed" and survived into the snapshot even as its parent's Pushed was dropped. On reload that step is an orphan: core's stepPush leaves targetActivityId unset, so findTargetActivityIndices falls back to the latest active activity and grafts the step onto a different, committed activity below — silent corruption, no error. A persister capturing on every onChanged reaches this through any step taken inside an enter transition. Bind the drop to the activity, not the element. Fold the log once (as aggregate does) and attribute every event to the activity it enters (Pushed/Replaced) or targets (Popped/Step*), resolving step and pop targets against the running stack the same way the reducer does — so a step's parent is known even after the step is superseded. An event is kept only if its activity committed (entry settled), which drops an uncommitted activity's entire span — its steps, surviving or superseded, included — alongside the exit-in-flight Popped and pause-queued events already handled. No step outlives its parent, so no orphan can graft. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…review A1: validateEvents Replaced note) Record in the changeset that folding the snapshot load's registration check into validateEvents also makes aggregate reject an unregistered Replaced on every path, matching its existing Pushed check — dormant in config-first usage where a replace only targets registered activities. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…resumed-paused (review: capture records, not settlement) An earlier revision restricted captureSnapshot to committed (settled) navigation, excluding mid-transition events and adding activity-level attribution so a dropped activity's steps did not orphan. Revisit the model: a transition is only a visual effect, and a navigation event's effect hook (onPushed/onPopped) already fires in the active state — so reconstructing an unsettled event as state on load loses no effect and produces no state/effect mismatch. What makes an event part of the history is that it entered the aggregate, not that its transition settled. Capture every navigation event the log records, excluding only events queued behind a pause that never resumed — those are quarantined out of the aggregate (never applied), so they were never part of the recorded history. This drops the settlement predicate and the attribution machinery that existed only to keep steps from outliving an excluded parent: with mid-transition parents now included, no step is orphaned. Snapshot tests move to the new contract — mid-transition push and mid-transition pop both round-trip, and only an unresumed-paused event is excluded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
네 가지 구현 리뷰를 모두 반영했습니다. 지시 하나당 커밋 하나로, 각 커밋에서 편집→typecheck/build/test 검증을 거쳤고 F1 —
|
…atics (review: as-is capture) captureSnapshot previously held one semantic opinion: events queued behind a pause that never resumed were excluded as "not recorded". Drop that opinion — core exports the stack it is asked about, as-is. The only remaining filter is vocabulary: static events (Initialized, ActivityRegistered) are config-grade information that may legitimately change across reloads, so the current config re-derives them at load time instead of trusting the snapshot. Everything else the stack recorded now rides along, Paused/Resumed included. A snapshot captured during a pause restores a paused stack — the queued navigation stays quarantined, the restored visible state matches what was visible at capture, and resuming in the new session applies the pending navigation. Whether to capture at such a moment is the caller's timing choice; deciding it is no longer core's business. The snapshot event union widens from the six navigation events to SnapshotEvent (navigation + Paused/Resumed): StackSnapshot.events, the overrideInitialEvents chain, the onInitialActivityIgnored handler param, and the load-side structure check all speak the widened vocabulary. Capture no longer re-aggregates the log — it is a pure log transform (sort by eventDate, dedupe by id, drop statics). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g only statics (review: as-is load) loadSnapshot previously re-dated the whole replay sequence into the settled past so every restored activity folded to enter-done. Drop that normalization: the snapshot's recorded eventDates are the replay truth. A stack captured mid-transition restores mid-transition, a paused stack restores paused, and capture∘load is an identity on the snapshot events (byte-for-byte, eventDate included). Replay order follows the dates, not the array (core-captured snapshots normalize the array to date order, so the two only disagree in hand-crafted snapshots). Only the static events are re-dated, pinned just before the earliest replayed event: Initialized seeds transitionDuration for every later reducer step, and a snapshot whose tail is an unresumed Paused would quarantine statics sorted after it — while their natural "now" dates would sort after a past-dated snapshot. Snapshot events are never touched. Guarantees core no longer provides — a fully-settled restore, and protection from a capture clock that ran ahead (future dates restore unsettled until the local clock catches up, and later navigation sorts after them only once it does) — belong to plugins now: re-date the sequence in overrideInitialEvents. Tests pin both the as-is behavior and that escape hatch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
스냅샷 capture/load 모델을 논의 결론에 따라 as-is 모델로 바꿨습니다. 핵심은 core가 "무엇이 보존할 가치가 있는 상태인가"에 대한 의미론적 opinion을 갖지 않게 하는 것입니다 — capture는 요청받은 시점의 기록을 그대로 내보내고, load는 그 기록을 그대로 재생합니다. 커밋 2건: capture — static만 제외하고 전부 as-is (
|
|
checkpoint: 1c58ead |
Summary
Adds a create vs. load distinction to
@stackflow/coreso a stack can be initialized either by creating a fresh navigation (today's behavior) or by restoring a prior navigation from a snapshot. This is the core primitive that a persister (FEP-2546) and related consumers build on: it lets a plugin hand the store a previously-captured navigation history at initialization time and have the store faithfully reconstruct it.The change is purely additive — with no snapshot provider present, initialization is observationally identical to today's code path.
New public surface (
@stackflow/core)StackSnapshot(+NavigationEvent, the subset of the six navigation events) —$schema: "stackflow.snapshot.v1", carrying navigation events only.actions.captureSnapshot()— filters and normalizes the event log into a snapshot (navigation events, ordered byeventDate, de-duplicated by id); callable from any hook.provideSnapshot({ initialContext })— optional plugin hook, polled synchronously at store creation (in React, inside<Stack/>'s first render — including SSR renders); a single snapshot slot (two or more non-null providers is a construction-time conflict error).onLoadError({ error, initialContext })+SnapshotLoadError— optional plugin hook and error type with a three-way cause (incompatible-schema/invalid-events/empty-navigation); returning{ recover: "create" }resumes fresh creation, otherwise the error is thrown out ofmakeCoreStore.onInitnow receivesinitializedBy: "create" | "load".Behavior
Pushed/Replacedmust name a registered activity) → rebase of event timestamps → replay through the existingaggregate+validateEvents→ postcondition (at least one entered activity) → on failure, routed to the provider'sonLoadError.Non-breaking
No new domain events, no new stack-state properties, no new app-developer-facing surface in the React layer, no new
makeCoreStoreoptions, and no changes toaggregate/validateEvents/ the reducers /overrideInitialEvents. Existing consumers (React integration and all extensions) type-check unchanged.Tests & release
core/src/*.spec.tssuites cover the public contract, the create/load sequences, the invariants, and a snapshot round-trip / persister-style integration scenario. The full@stackflow/coresuite is green with no regression to existing tests; type-check and lint are clean.@stackflow/coreminor changeset.Notes for reviewers
design-fep-2548-init-load-mechanism.md, the run plan, glossary) committed during design. They're kept here for context; curate what should actually land onmainbefore marking ready. The implementation's code comments reference this design document's section markers — self-contained while the doc is present, so if you drop the doc, sweep those references.provideSnapshot/onLoadErrorhook itself throws is left undefined by the design and is intentionally not pinned by tests; (2) the design's "all-popped history" example is imprecise (a root activity can't be popped, so that exact state is unreachable) — theempty-navigationmechanism itself is sound, so no design change is required.Opened as a draft for review and curation before merge.
🤖 Generated with Claude Code