Skip to content

Commit 41ce8aa

Browse files
authored
Merge pull request #21 from Respo/codex/versioned-runtime-snapshot
版本化 runtime snapshot 并兼容旧格式 / Version runtime snapshots with legacy compatibility
2 parents 6d42afd + 4096722 commit 41ce8aa

8 files changed

Lines changed: 89 additions & 16 deletions

File tree

Agents.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ div([
238238
- 每次 app render 只安装一个 stateful component runtime;feature 通过 scoped vnode component 在同一 handler 内组合。
239239
- scheduled effects 按组件求值顺序收集;不要把跨组件的 effect 顺序当作数据依赖。
240240
- snapshot entry 必须保留稳定 path、schema、version、payload;decoder 对 malformed payload、schema/version 不匹配安全回退。
241+
- 完整 snapshot 使用 `respo/runtime-snapshot|1` 顶层 header;decoder 兼容旧无 header 格式,unknown 顶层版本回退空树,单个 malformed entry 不影响其他合法 entry。
241242
- `respo/component-scope` 是 runtime-owned lifecycle marker,会进入 snapshot;业务模块不得读取、构造或修改。ordinary child sweep 由 framework visitation 驱动,不在 reducer 中重建 path。
242243
- replay entry 使用 `respo/replay:<action-schema>`,只由 `replay_store(...)` 读写;业务 view 仍只使用 `(state, dispatch) = use_store(...)`。恢复策略与选择标准见 `docs/store-recovery.md`
243244
- `src/main.js` 负责 localStorage 与 Vite HMR hand-off。修改浏览器桥时要验证 replacement 前 flush、dispose 和 `pagehide` 三条路径。

PLAN.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@
8282
- 业务模块已删除跨 feature key 读取/写入 child store 的 helper;scope/path API 只保留给 framework/runtime inspection 与 tests;
8383
- 父组件不再读取子组件 local store 做汇总;跨组件真正需要的数据应提升为 domain state;
8484
- 旧的 `demo/runtimebridge.kk` / `demo/runtimeowner.kk` 过渡层已经删除;
85-
- snapshot 使用 path + schema + version + payload;
85+
- snapshot 使用 `respo/runtime-snapshot|1` 顶层 envelope,entry 保持 path + schema + version + payload;decoder 兼容旧无 header 格式
8686
- ordinary child ownership marker 会进入 snapshot;feature 恢复后可继续判断 stale child,旧版无 marker 的孤立 entry 不做不安全的 path 推断;
87-
- malformed snapshot、unknown schema 和 version mismatch 会安全回退
87+
- unknown/malformed 顶层版本回退空树;单个 malformed entry、unknown schema 和 entry version mismatch 只回退相关状态
8888
- replay store 使用 `respo/replay:<action-schema>` entry,从当前 `initial` 和 decoded component actions 恢复;
8989
- key segment 使用无碰撞 canonical encoding;现有 slug/数字路径保持不变,旧版空串、下划线开头或保留字符 key 的 snapshot 允许一次性回退 initial state;
9090
- `src/main.js` 在事件后合并保存,并在 HMR replacement、dispose、`pagehide` 前 flush;
@@ -156,10 +156,10 @@ feature_dom_marker(group = ..., key = ..., name = ...)
156156

157157
### 1. 版本化 runtime snapshot(#17
158158

159-
- 为完整 snapshot 增加顶层标识与格式版本,同时兼容读取现有无 envelope 格式;
160-
- unknown future version、malformed entry 和非法编码都必须安全回退,不阻断 app boot;
161-
- 继续保持 entry 级 schema/version 校验,不把 snapshot 参数带回业务 view
162-
- Koka compatibility tests 与浏览器 reload/HMR 恢复共同验收
159+
- 当前实现批次:完整 snapshot 已增加 `respo/runtime-snapshot|1` header,并兼容读取现有无 envelope 格式;
160+
- unknown/malformed 顶层版本回退空树,malformed entry 与 entry 非法编码只丢弃自身,不阻断 app boot;
161+
- entry 级 schema/version 校验保持不变,业务 view 不增加 snapshot 参数或 runtime import
162+
- Koka compatibility tests、旧 snapshot 浏览器启动、整页 reload 与真实 Vite HMR replacement 恢复均已完成验收
163163

164164
### 2. 定义 effect cleanup 生命周期(#18
165165

README.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -421,22 +421,27 @@ deduplication, and recorded responses.
421421

422422
## State snapshots, HMR, and reloads
423423

424-
The component runtime tree is encoded as versioned `state_entry` values. Each
425-
entry records its stable path, schema, version, and payload. Restore behavior is
426-
defensive:
427-
424+
The component runtime tree is encoded with a `respo/runtime-snapshot|1`
425+
top-level header followed by versioned `state_entry` values. The envelope
426+
version owns the transport format; every entry still owns its stable path,
427+
schema, version, and payload. Restore behavior is defensive:
428+
429+
- the decoder still accepts the legacy headerless four-field format;
430+
- unknown or malformed envelope versions safely restore an empty runtime tree;
431+
- a malformed entry is skipped without discarding other valid entries;
428432
- unknown schemas and unsupported versions fall back to the store's initial
429433
value;
430-
- malformed snapshot data is ignored instead of reaching a component decoder;
431434
- component state is restored only when its keyed scope and store schema still
432435
match;
433436
- replay stores use `respo/replay:<action-schema>` entries and rebuild state
434437
from the current `initial` plus decoded component actions;
435438
- `respo/component-scope` metadata preserves ordinary-child ownership across
436439
HMR/reload so stale child branches can be swept on the next feature render.
437440

438-
`src/main.js` persists the snapshot under
439-
`koka-respo:component-state:v1`. Writes are coalesced with
441+
`src/main.js` keeps using the existing
442+
`koka-respo:component-state:v1` localStorage key so legacy values remain
443+
discoverable; future wire-format evolution belongs to the snapshot envelope.
444+
Writes are coalesced with
440445
`requestAnimationFrame`, then flushed synchronously at the important
441446
boundaries:
442447

demo/tests.kk

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub fun demo_test_results() : <div> list<test_result>
2828
persistent_feature_lifecycle_test(),
2929
generic_state_codec_test(),
3030
replay_empty_payload_test(),
31+
runtime_snapshot_format_test(),
3132
action_store_transition_test(),
3233
malformed_store_payload_test(),
3334
auto_hook_scope_test(),

demo/tests/statecases.kk

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,40 @@ pub fun replay_empty_payload_test() : <div> test_result
172172
Nothing -> False
173173
Test_result("Replay framing preserves empty action payload", "restored='" ++ restored ++ "', framed=" ++ framed.show, restored == "" && framed)
174174

175+
pub fun runtime_snapshot_format_test() : <div> test_result
176+
val source_tree = [
177+
State_entry("feature/a|b", "tests/state", 3, "draft | value"),
178+
State_entry("feature/effect", "respo/effect", 1, "render-only"),
179+
]
180+
val encoded = encode_state_snapshot(source_tree)
181+
val header_is_versioned = match split(encoded, "\n")
182+
Cons(header, _entries) -> header == "respo/runtime-snapshot|1"
183+
Nil -> False
184+
val restored = decode_state_snapshot(encoded)
185+
val roundtrip = match restored
186+
[entry] -> entry.path == "feature/a|b" && entry.schema == "tests/state" && entry.version == 3 && entry.payload == "draft | value"
187+
_ -> False
188+
val empty_is_versioned = encode_state_snapshot(Nil) == "respo/runtime-snapshot|1"
189+
val legacy = decode_state_snapshot("legacy/path|legacy/schema|2|legacy%20payload")
190+
val legacy_compatible = match legacy
191+
[entry] -> entry.path == "legacy/path" && entry.schema == "legacy/schema" && entry.version == 2 && entry.payload == "legacy payload"
192+
_ -> False
193+
val legacy_reencoded = match split(encode_state_snapshot(legacy), "\n")
194+
Cons(header, _entries) -> header == "respo/runtime-snapshot|1"
195+
Nil -> False
196+
val partially_corrupt = decode_state_snapshot(
197+
"respo/runtime-snapshot|1\nvalid/one|tests/one|1|first\nbad/path|tests/bad|1|bad%\nvalid/two|tests/two|2|second")
198+
val partial_recovery = match partially_corrupt
199+
[first, second] -> first.path == "valid/one" && first.payload == "first" && second.path == "valid/two" && second.version == 2
200+
_ -> False
201+
val unknown_version_safe = is-empty(decode_state_snapshot("respo/runtime-snapshot|9\nvalid/path|tests/state|1|value"))
202+
val malformed_version_safe = is-empty(decode_state_snapshot("respo/runtime-snapshot|next\nvalid/path|tests/state|1|value"))
203+
val malformed_header_safe = is-empty(decode_state_snapshot("respo/runtime-snapshot|1|extra\nvalid/path|tests/state|1|value"))
204+
Test_result(
205+
"Runtime snapshot envelope is versioned and backward compatible",
206+
"header=" ++ header_is_versioned.show ++ ", legacy=" ++ legacy_compatible.show ++ ", partial=" ++ partially_corrupt.length.show ++ ", unknown=" ++ unknown_version_safe.show,
207+
header_is_versioned && roundtrip && empty_is_versioned && legacy_compatible && legacy_reencoded && partial_recovery && unknown_version_safe && malformed_version_safe && malformed_header_safe)
208+
175209
pub fun action_store_transition_test() : <div> test_result
176210
val scope_name = component_local_path("tests", "action-store-transition")
177211
val transition_codec : action_codec<string> = Action_codec(

docs/component-lifecycle.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ Dialog overlay 这类全局 singleton 在关闭或 kind 改变时,可以由 in
8686

8787
`respo/component-scope` marker 会随 state entry 一起进入 snapshot,因此 HMR 或整页 reload 后,runtime 仍然知道哪些 entry 属于普通 keyed child。`respo/effect` dependency entry 仍然不会持久化;effect 会在新 runtime 中重新建立。
8888

89+
runtime snapshot 使用 `respo/runtime-snapshot|1` 顶层 header。decoder 仍兼容旧的无 header snapshot;unknown 顶层版本安全回退为空树,已识别格式中的单个 malformed entry 只丢弃自身。顶层 transport version 不替代 entry schema/version,两层分别负责 wire format 和 typed store recovery。
90+
8991
旧版本 snapshot 没有 lifecycle marker。当前仍然渲染的 child 会在下一次 render 自动获得 marker;旧 snapshot 中已经孤立、且从未再次出现的 entry 无法可靠推断归属,因此不会用 path 猜测并删除。显式 feature reset 或后续 snapshot 版本迁移可以处理这类历史数据。
9092

9193
Snapshot 仍然只是临时 UI 状态的 best-effort 恢复机制,不替代 domain persistence。

docs/store-recovery.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ Replay entry 使用 `respo/replay:<action-schema>` 和 action codec version。pa
8282

8383
从 snapshot recovery 切换到 replay recovery 会改变 entry schema。旧 state snapshot 无法可靠反推出原 action,因此允许一次性回退 `initial`;新 replay snapshot 产生后,后续 HMR/reload 正常恢复。
8484

85+
### Runtime snapshot envelope
86+
87+
完整 runtime snapshot 的第一行是 `respo/runtime-snapshot|1`。这是 transport format version,与每个 store entry 自己的 schema/version 分开:顶层版本决定如何拆解 snapshot,entry version 决定某个 typed store 是否能恢复。
88+
89+
- 当前 decoder 继续读取旧的无 header、每行四字段 snapshot;下一次保存会自然写成 versioned envelope;
90+
- unknown 或 malformed 顶层版本会把整棵 runtime tree 安全回退为空,再由 component 使用各自的 `initial`
91+
- 已识别格式中的单个 malformed entry 会被跳过,其他合法 entry 仍可恢复;
92+
- 空 runtime tree 也编码为 header,而不是无版本的空字符串;传入空字符串仍表示“没有 snapshot”;
93+
- `respo/effect` render metadata 不进入 snapshot,domain model 也不走这条持久化通道。
94+
95+
浏览器继续使用现有 `koka-respo:component-state:v1` localStorage key,以便发现升级前保存的值。wire format 的后续演进由 snapshot envelope 管理,不由业务 view 或 store 调用点管理。
96+
8597
## 与 action observation 的区别
8698

8799
Replay log 只属于一个明确选择 `replay_store(...)` 的 component store,并且只包含它自己的纯 typed actions。

explore/react/runtime.kk

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ inline extern runtime_snapshot_unescape(text : string) : string
2727
inline extern runtime_snapshot_unescape_valid(text : string) : bool
2828
js inline "((s) => { try { decodeURIComponent(s); return true; } catch (_) { return false; } })(#1)"
2929

30+
val runtime_snapshot_marker = "respo/runtime-snapshot"
31+
val runtime_snapshot_version = 1
32+
3033
fun encode_snapshot_entry(entry : state_entry) : string
3134
runtime_snapshot_escape(entry.path) ++ "|" ++
3235
runtime_snapshot_escape(entry.schema) ++ "|" ++
@@ -38,14 +41,17 @@ fun append_snapshot_line(line : string, rest : string) : string
3841

3942
// Effect dependency signatures are render metadata. They are intentionally
4043
// rebuilt after HMR instead of being persisted with component state.
41-
pub fun encode_state_snapshot(tree : list<state_entry>) : <div> string
44+
fun encode_snapshot_entries(tree : list<state_entry>) : <div> string
4245
match tree
4346
Nil -> ""
4447
Cons(entry, rest) ->
45-
val encoded_rest = encode_state_snapshot(rest)
48+
val encoded_rest = encode_snapshot_entries(rest)
4649
if entry.schema == "respo/effect" then encoded_rest
4750
else append_snapshot_line(encode_snapshot_entry(entry), encoded_rest)
4851

52+
pub fun encode_state_snapshot(tree : list<state_entry>) : <div> string
53+
append_snapshot_line(runtime_snapshot_marker ++ "|" ++ runtime_snapshot_version.show, encode_snapshot_entries(tree))
54+
4955
fun decode_snapshot_entry(line : string) : maybe<state_entry>
5056
match split(line, "|")
5157
[encoded_path, encoded_schema, version, encoded_payload] -> {
@@ -68,4 +74,16 @@ fun decode_snapshot_lines(lines : list<string>) : list<state_entry>
6874
Nothing -> decode_snapshot_lines(rest)
6975

7076
pub fun decode_state_snapshot(snapshot : string) : list<state_entry>
71-
if snapshot == "" then Nil else decode_snapshot_lines(split(snapshot, "\n"))
77+
if snapshot == "" then Nil
78+
else match split(snapshot, "\n")
79+
Nil -> Nil
80+
Cons(header, entries) ->
81+
match split(header, "|")
82+
Cons(marker, version_parts) ->
83+
if marker != runtime_snapshot_marker then decode_snapshot_lines(Cons(header, entries))
84+
else match version_parts
85+
[version] -> match parse-int(version)
86+
Just(value) -> if value == runtime_snapshot_version then decode_snapshot_lines(entries) else Nil
87+
Nothing -> Nil
88+
_ -> Nil
89+
Nil -> decode_snapshot_lines(Cons(header, entries))

0 commit comments

Comments
 (0)