Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<action-schema>`,只由 `replay_store(...)` 读写;业务 view 仍只使用 `(state, dispatch) = use_store(...)`。恢复策略与选择标准见 `docs/store-recovery.md`。
- `src/main.js` 负责 localStorage 与 Vite HMR hand-off。修改浏览器桥时要验证 replacement 前 flush、dispose 和 `pagehide` 三条路径。
Expand Down
12 changes: 6 additions & 6 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<action-schema>` entry,从当前 `initial` 和 decoded component actions 恢复;
- key segment 使用无碰撞 canonical encoding;现有 slug/数字路径保持不变,旧版空串、下划线开头或保留字符 key 的 snapshot 允许一次性回退 initial state;
- `src/main.js` 在事件后合并保存,并在 HMR replacement、dispose、`pagehide` 前 flush;
Expand Down Expand Up @@ -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)

Expand Down
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,22 +421,27 @@ 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:<action-schema>` entries and rebuild state
from the current `initial` plus decoded component actions;
- `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:

Expand Down
1 change: 1 addition & 0 deletions demo/tests.kk
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub fun demo_test_results() : <div> list<test_result>
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(),
Expand Down
34 changes: 34 additions & 0 deletions demo/tests/statecases.kk
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,40 @@ pub fun replay_empty_payload_test() : <div> 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() : <div> 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() : <div> test_result
val scope_name = component_local_path("tests", "action-store-transition")
val transition_codec : action_codec<string> = Action_codec(
Expand Down
2 changes: 2 additions & 0 deletions docs/component-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
12 changes: 12 additions & 0 deletions docs/store-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ Replay entry 使用 `respo/replay:<action-schema>` 和 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。
Expand Down
24 changes: 21 additions & 3 deletions explore/react/runtime.kk
Original file line number Diff line number Diff line change
Expand Up @@ -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) ++ "|" ++
Expand All @@ -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<state_entry>) : <div> string
fun encode_snapshot_entries(tree : list<state_entry>) : <div> 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<state_entry>) : <div> string
append_snapshot_line(runtime_snapshot_marker ++ "|" ++ runtime_snapshot_version.show, encode_snapshot_entries(tree))

fun decode_snapshot_entry(line : string) : maybe<state_entry>
match split(line, "|")
[encoded_path, encoded_schema, version, encoded_payload] -> {
Expand All @@ -68,4 +74,16 @@ fun decode_snapshot_lines(lines : list<string>) : list<state_entry>
Nothing -> decode_snapshot_lines(rest)

pub fun decode_state_snapshot(snapshot : string) : list<state_entry>
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))
Loading