From 40967227ea8d2cc774d87daddee65c121925e889 Mon Sep 17 00:00:00 2001 From: tiye Date: Mon, 31 Aug 2026 00:52:12 +0800 Subject: [PATCH] feat: version runtime snapshots --- Agents.md | 1 + PLAN.md | 12 ++++++------ README.md | 19 ++++++++++++------- demo/tests.kk | 1 + demo/tests/statecases.kk | 34 ++++++++++++++++++++++++++++++++++ docs/component-lifecycle.md | 2 ++ docs/store-recovery.md | 12 ++++++++++++ explore/react/runtime.kk | 24 +++++++++++++++++++++--- 8 files changed, 89 insertions(+), 16 deletions(-) diff --git a/Agents.md b/Agents.md index 7895ebf..90afb19 100644 --- a/Agents.md +++ b/Agents.md @@ -238,6 +238,7 @@ div([ - 每次 app render 只安装一个 stateful component runtime;feature 通过 scoped vnode component 在同一 handler 内组合。 - scheduled effects 按组件求值顺序收集;不要把跨组件的 effect 顺序当作数据依赖。 - snapshot entry 必须保留稳定 path、schema、version、payload;decoder 对 malformed payload、schema/version 不匹配安全回退。 +- 完整 snapshot 使用 `respo/runtime-snapshot|1` 顶层 header;decoder 兼容旧无 header 格式,unknown 顶层版本回退空树,单个 malformed entry 不影响其他合法 entry。 - `respo/component-scope` 是 runtime-owned lifecycle marker,会进入 snapshot;业务模块不得读取、构造或修改。ordinary child sweep 由 framework visitation 驱动,不在 reducer 中重建 path。 - replay entry 使用 `respo/replay:`,只由 `replay_store(...)` 读写;业务 view 仍只使用 `(state, dispatch) = use_store(...)`。恢复策略与选择标准见 `docs/store-recovery.md`。 - `src/main.js` 负责 localStorage 与 Vite HMR hand-off。修改浏览器桥时要验证 replacement 前 flush、dispose 和 `pagehide` 三条路径。 diff --git a/PLAN.md b/PLAN.md index 65d4185..7a51413 100644 --- a/PLAN.md +++ b/PLAN.md @@ -82,9 +82,9 @@ - 业务模块已删除跨 feature key 读取/写入 child store 的 helper;scope/path API 只保留给 framework/runtime inspection 与 tests; - 父组件不再读取子组件 local store 做汇总;跨组件真正需要的数据应提升为 domain state; - 旧的 `demo/runtimebridge.kk` / `demo/runtimeowner.kk` 过渡层已经删除; -- snapshot 使用 path + schema + version + payload; +- snapshot 使用 `respo/runtime-snapshot|1` 顶层 envelope,entry 保持 path + schema + version + payload;decoder 兼容旧无 header 格式; - ordinary child ownership marker 会进入 snapshot;feature 恢复后可继续判断 stale child,旧版无 marker 的孤立 entry 不做不安全的 path 推断; -- malformed snapshot、unknown schema 和 version mismatch 会安全回退; +- unknown/malformed 顶层版本回退空树;单个 malformed entry、unknown schema 和 entry version mismatch 只回退相关状态; - replay store 使用 `respo/replay:` entry,从当前 `initial` 和 decoded component actions 恢复; - key segment 使用无碰撞 canonical encoding;现有 slug/数字路径保持不变,旧版空串、下划线开头或保留字符 key 的 snapshot 允许一次性回退 initial state; - `src/main.js` 在事件后合并保存,并在 HMR replacement、dispose、`pagehide` 前 flush; @@ -156,10 +156,10 @@ feature_dom_marker(group = ..., key = ..., name = ...) ### 1. 版本化 runtime snapshot(#17) -- 为完整 snapshot 增加顶层标识与格式版本,同时兼容读取现有无 envelope 格式; -- unknown future version、malformed entry 和非法编码都必须安全回退,不阻断 app boot; -- 继续保持 entry 级 schema/version 校验,不把 snapshot 参数带回业务 view; -- 用 Koka compatibility tests 与浏览器 reload/HMR 恢复共同验收。 +- 当前实现批次:完整 snapshot 已增加 `respo/runtime-snapshot|1` header,并兼容读取现有无 envelope 格式; +- unknown/malformed 顶层版本回退空树,malformed entry 与 entry 非法编码只丢弃自身,不阻断 app boot; +- entry 级 schema/version 校验保持不变,业务 view 不增加 snapshot 参数或 runtime import; +- Koka compatibility tests、旧 snapshot 浏览器启动、整页 reload 与真实 Vite HMR replacement 恢复均已完成验收。 ### 2. 定义 effect cleanup 生命周期(#18) diff --git a/README.md b/README.md index 72cad8d..e9aa177 100644 --- a/README.md +++ b/README.md @@ -421,13 +421,16 @@ deduplication, and recorded responses. ## State snapshots, HMR, and reloads -The component runtime tree is encoded as versioned `state_entry` values. Each -entry records its stable path, schema, version, and payload. Restore behavior is -defensive: - +The component runtime tree is encoded with a `respo/runtime-snapshot|1` +top-level header followed by versioned `state_entry` values. The envelope +version owns the transport format; every entry still owns its stable path, +schema, version, and payload. Restore behavior is defensive: + +- the decoder still accepts the legacy headerless four-field format; +- unknown or malformed envelope versions safely restore an empty runtime tree; +- a malformed entry is skipped without discarding other valid entries; - unknown schemas and unsupported versions fall back to the store's initial value; -- malformed snapshot data is ignored instead of reaching a component decoder; - component state is restored only when its keyed scope and store schema still match; - replay stores use `respo/replay:` entries and rebuild state @@ -435,8 +438,10 @@ defensive: - `respo/component-scope` metadata preserves ordinary-child ownership across HMR/reload so stale child branches can be swept on the next feature render. -`src/main.js` persists the snapshot under -`koka-respo:component-state:v1`. Writes are coalesced with +`src/main.js` keeps using the existing +`koka-respo:component-state:v1` localStorage key so legacy values remain +discoverable; future wire-format evolution belongs to the snapshot envelope. +Writes are coalesced with `requestAnimationFrame`, then flushed synchronously at the important boundaries: diff --git a/demo/tests.kk b/demo/tests.kk index dc2a0ef..476e85c 100644 --- a/demo/tests.kk +++ b/demo/tests.kk @@ -28,6 +28,7 @@ pub fun demo_test_results() :
list persistent_feature_lifecycle_test(), generic_state_codec_test(), replay_empty_payload_test(), + runtime_snapshot_format_test(), action_store_transition_test(), malformed_store_payload_test(), auto_hook_scope_test(), diff --git a/demo/tests/statecases.kk b/demo/tests/statecases.kk index 213df2d..08b69e1 100644 --- a/demo/tests/statecases.kk +++ b/demo/tests/statecases.kk @@ -172,6 +172,40 @@ pub fun replay_empty_payload_test() :
test_result Nothing -> False Test_result("Replay framing preserves empty action payload", "restored='" ++ restored ++ "', framed=" ++ framed.show, restored == "" && framed) +pub fun runtime_snapshot_format_test() :
test_result + val source_tree = [ + State_entry("feature/a|b", "tests/state", 3, "draft | value"), + State_entry("feature/effect", "respo/effect", 1, "render-only"), + ] + val encoded = encode_state_snapshot(source_tree) + val header_is_versioned = match split(encoded, "\n") + Cons(header, _entries) -> header == "respo/runtime-snapshot|1" + Nil -> False + val restored = decode_state_snapshot(encoded) + val roundtrip = match restored + [entry] -> entry.path == "feature/a|b" && entry.schema == "tests/state" && entry.version == 3 && entry.payload == "draft | value" + _ -> False + val empty_is_versioned = encode_state_snapshot(Nil) == "respo/runtime-snapshot|1" + val legacy = decode_state_snapshot("legacy/path|legacy/schema|2|legacy%20payload") + val legacy_compatible = match legacy + [entry] -> entry.path == "legacy/path" && entry.schema == "legacy/schema" && entry.version == 2 && entry.payload == "legacy payload" + _ -> False + val legacy_reencoded = match split(encode_state_snapshot(legacy), "\n") + Cons(header, _entries) -> header == "respo/runtime-snapshot|1" + Nil -> False + val partially_corrupt = decode_state_snapshot( + "respo/runtime-snapshot|1\nvalid/one|tests/one|1|first\nbad/path|tests/bad|1|bad%\nvalid/two|tests/two|2|second") + val partial_recovery = match partially_corrupt + [first, second] -> first.path == "valid/one" && first.payload == "first" && second.path == "valid/two" && second.version == 2 + _ -> False + val unknown_version_safe = is-empty(decode_state_snapshot("respo/runtime-snapshot|9\nvalid/path|tests/state|1|value")) + val malformed_version_safe = is-empty(decode_state_snapshot("respo/runtime-snapshot|next\nvalid/path|tests/state|1|value")) + val malformed_header_safe = is-empty(decode_state_snapshot("respo/runtime-snapshot|1|extra\nvalid/path|tests/state|1|value")) + Test_result( + "Runtime snapshot envelope is versioned and backward compatible", + "header=" ++ header_is_versioned.show ++ ", legacy=" ++ legacy_compatible.show ++ ", partial=" ++ partially_corrupt.length.show ++ ", unknown=" ++ unknown_version_safe.show, + header_is_versioned && roundtrip && empty_is_versioned && legacy_compatible && legacy_reencoded && partial_recovery && unknown_version_safe && malformed_version_safe && malformed_header_safe) + pub fun action_store_transition_test() :
test_result val scope_name = component_local_path("tests", "action-store-transition") val transition_codec : action_codec = Action_codec( diff --git a/docs/component-lifecycle.md b/docs/component-lifecycle.md index d5b978f..583bbe6 100644 --- a/docs/component-lifecycle.md +++ b/docs/component-lifecycle.md @@ -86,6 +86,8 @@ Dialog overlay 这类全局 singleton 在关闭或 kind 改变时,可以由 in `respo/component-scope` marker 会随 state entry 一起进入 snapshot,因此 HMR 或整页 reload 后,runtime 仍然知道哪些 entry 属于普通 keyed child。`respo/effect` dependency entry 仍然不会持久化;effect 会在新 runtime 中重新建立。 +runtime snapshot 使用 `respo/runtime-snapshot|1` 顶层 header。decoder 仍兼容旧的无 header snapshot;unknown 顶层版本安全回退为空树,已识别格式中的单个 malformed entry 只丢弃自身。顶层 transport version 不替代 entry schema/version,两层分别负责 wire format 和 typed store recovery。 + 旧版本 snapshot 没有 lifecycle marker。当前仍然渲染的 child 会在下一次 render 自动获得 marker;旧 snapshot 中已经孤立、且从未再次出现的 entry 无法可靠推断归属,因此不会用 path 猜测并删除。显式 feature reset 或后续 snapshot 版本迁移可以处理这类历史数据。 Snapshot 仍然只是临时 UI 状态的 best-effort 恢复机制,不替代 domain persistence。 diff --git a/docs/store-recovery.md b/docs/store-recovery.md index 244e6c2..7e6e4ab 100644 --- a/docs/store-recovery.md +++ b/docs/store-recovery.md @@ -82,6 +82,18 @@ Replay entry 使用 `respo/replay:` 和 action codec version。pa 从 snapshot recovery 切换到 replay recovery 会改变 entry schema。旧 state snapshot 无法可靠反推出原 action,因此允许一次性回退 `initial`;新 replay snapshot 产生后,后续 HMR/reload 正常恢复。 +### Runtime snapshot envelope + +完整 runtime snapshot 的第一行是 `respo/runtime-snapshot|1`。这是 transport format version,与每个 store entry 自己的 schema/version 分开:顶层版本决定如何拆解 snapshot,entry version 决定某个 typed store 是否能恢复。 + +- 当前 decoder 继续读取旧的无 header、每行四字段 snapshot;下一次保存会自然写成 versioned envelope; +- unknown 或 malformed 顶层版本会把整棵 runtime tree 安全回退为空,再由 component 使用各自的 `initial`; +- 已识别格式中的单个 malformed entry 会被跳过,其他合法 entry 仍可恢复; +- 空 runtime tree 也编码为 header,而不是无版本的空字符串;传入空字符串仍表示“没有 snapshot”; +- `respo/effect` render metadata 不进入 snapshot,domain model 也不走这条持久化通道。 + +浏览器继续使用现有 `koka-respo:component-state:v1` localStorage key,以便发现升级前保存的值。wire format 的后续演进由 snapshot envelope 管理,不由业务 view 或 store 调用点管理。 + ## 与 action observation 的区别 Replay log 只属于一个明确选择 `replay_store(...)` 的 component store,并且只包含它自己的纯 typed actions。 diff --git a/explore/react/runtime.kk b/explore/react/runtime.kk index 23cd15a..a5abfa5 100644 --- a/explore/react/runtime.kk +++ b/explore/react/runtime.kk @@ -27,6 +27,9 @@ inline extern runtime_snapshot_unescape(text : string) : string inline extern runtime_snapshot_unescape_valid(text : string) : bool js inline "((s) => { try { decodeURIComponent(s); return true; } catch (_) { return false; } })(#1)" +val runtime_snapshot_marker = "respo/runtime-snapshot" +val runtime_snapshot_version = 1 + fun encode_snapshot_entry(entry : state_entry) : string runtime_snapshot_escape(entry.path) ++ "|" ++ runtime_snapshot_escape(entry.schema) ++ "|" ++ @@ -38,14 +41,17 @@ fun append_snapshot_line(line : string, rest : string) : string // Effect dependency signatures are render metadata. They are intentionally // rebuilt after HMR instead of being persisted with component state. -pub fun encode_state_snapshot(tree : list) :
string +fun encode_snapshot_entries(tree : list) :
string match tree Nil -> "" Cons(entry, rest) -> - val encoded_rest = encode_state_snapshot(rest) + val encoded_rest = encode_snapshot_entries(rest) if entry.schema == "respo/effect" then encoded_rest else append_snapshot_line(encode_snapshot_entry(entry), encoded_rest) +pub fun encode_state_snapshot(tree : list) :
string + append_snapshot_line(runtime_snapshot_marker ++ "|" ++ runtime_snapshot_version.show, encode_snapshot_entries(tree)) + fun decode_snapshot_entry(line : string) : maybe match split(line, "|") [encoded_path, encoded_schema, version, encoded_payload] -> { @@ -68,4 +74,16 @@ fun decode_snapshot_lines(lines : list) : list Nothing -> decode_snapshot_lines(rest) pub fun decode_state_snapshot(snapshot : string) : list - if snapshot == "" then Nil else decode_snapshot_lines(split(snapshot, "\n")) + if snapshot == "" then Nil + else match split(snapshot, "\n") + Nil -> Nil + Cons(header, entries) -> + match split(header, "|") + Cons(marker, version_parts) -> + if marker != runtime_snapshot_marker then decode_snapshot_lines(Cons(header, entries)) + else match version_parts + [version] -> match parse-int(version) + Just(value) -> if value == runtime_snapshot_version then decode_snapshot_lines(entries) else Nil + Nothing -> Nil + _ -> Nil + Nil -> decode_snapshot_lines(Cons(header, entries))