Skip to content

Commit cccb4ba

Browse files
committed
add keyed child lifecycle cleanup
1 parent 8cba523 commit cccb4ba

8 files changed

Lines changed: 334 additions & 8 deletions

File tree

Agents.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ chrome-devtools take_screenshot --fullPage --filePath .tmp-devtools-full.png
148148
- `Store_spec(...)``State_codec(...)``Action_codec(...)` 的定义统一使用 labelled fields,显式写出 `name/schema/version/decode/encode/reduce`,不要依赖难以辨认的位置参数顺序。
149149
- scope/path/slot/tree 属于 framework/runtime 细节;业务 view 只声明 keyed boundary,不手工拼 path。
150150
- 单个 keyed boundary 优先写成 `component(group, key) { ... }` / `feature_root(group, key) { ... }`,用 Koka trailing-lambda 语法让 lifecycle 边界有鲜明特征;列表仍用 labelled `components(...)` 保持 key/render 映射清楚。
151+
- `component(...)` / `components(...)` 表示 ordinary child:当前 feature 仍 render、但 child 不再出现时,其 local store 与 effect metadata 会自动清理。filter/条件分支隐藏 child 等同 unmount。
152+
- `feature_root(...)` 表示 persistent boundary:整个 feature 未 render 时保留 snapshot,供 route 返回、HMR 和 reload 恢复。不要只为保留一个 input draft 就滥用 feature root。
151153
- feature component 自己持有稳定的 `feature_root(...)`,签名返回 `app_view vnode`,并用 labelled `key` 隔离 local store、effect、listener 与 DOM marker;domain props 是否共享由调用方决定。不要暴露 runtime 四元组或新增 `run_*_panel` adapter。
152154
- 只有真正支持多实例隔离的 feature 才公开 `key`;app-owned singleton(例如全局 Dialog overlay)使用固定 identity,不提供只隔离一部分 runtime surface 的伪多实例参数。
153155
- `feature_root(...)` 会安装 opaque feature identity;内部 helper 使用 `feature_key()` / `feature_marker(name)` 派生 VDOM sibling key 与 DOM effect marker,不层层转发 `panel_key`,也不读取 raw group/key。
@@ -216,8 +218,10 @@ div([
216218
- 每次 app render 只安装一个 stateful component runtime;feature 通过 scoped vnode component 在同一 handler 内组合。
217219
- scheduled effects 按组件求值顺序收集;不要把跨组件的 effect 顺序当作数据依赖。
218220
- snapshot entry 必须保留稳定 path、schema、version、payload;decoder 对 malformed payload、schema/version 不匹配安全回退。
221+
- `respo/component-scope` 是 runtime-owned lifecycle marker,会进入 snapshot;业务模块不得读取、构造或修改。ordinary child sweep 由 framework visitation 驱动,不在 reducer 中重建 path。
219222
- `src/main.js` 负责 localStorage 与 Vite HMR hand-off。修改浏览器桥时要验证 replacement 前 flush、dispose 和 `pagehide` 三条路径。
220223
- snapshot 只是组件临时状态恢复机制,不替代业务数据持久化。
224+
- lifecycle 规则和旧 snapshot 兼容限制见 `docs/component-lifecycle.md`
221225

222226
## Koka 常见易错点
223227

PLAN.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@
3535
- 业务组件不再手工拼装 listener/effect/store path;
3636
- state、effect 和 named listener 共享稳定的 keyed component scope。
3737

38+
### Component lifecycle
39+
40+
- `feature_root(...)` 是 persistent boundary:整个 feature 未 render 时保留 snapshot,支持 route 返回、HMR 和 reload;
41+
- `component(...)` / `components(...)` 是 ordinary child boundary:当前 feature 仍 render、但 child 不再访问时执行 unmount sweep;
42+
- runtime 使用可持久化的 `respo/component-scope` marker 记录 child 归属,不根据 path 形状猜测业务结构;
43+
- sweep 删除普通 child 的 store、effect metadata 与 lifecycle marker;listener registry 每轮重建,不需要持久化清理;
44+
- Todo 删除单项与 Clear done 都由 render lifecycle 自动释放 editor state,不再由 domain reducer 清理 path;
45+
- app-owned persistent singleton 仍可在 integration boundary 使用 `reset_feature(...)` 显式重置;
46+
- 完整契约见 [`docs/component-lifecycle.md`](docs/component-lifecycle.md)
47+
3848
### Typed component store
3949

4050
- `store_spec<s,a>` 将 state codec、action codec 和纯 reducer 组合在一起;
@@ -68,6 +78,7 @@
6878
- 父组件不再读取子组件 local store 做汇总;跨组件真正需要的数据应提升为 domain state;
6979
- 旧的 `demo/runtimebridge.kk` / `demo/runtimeowner.kk` 过渡层已经删除;
7080
- snapshot 使用 path + schema + version + payload;
81+
- ordinary child ownership marker 会进入 snapshot;feature 恢复后可继续判断 stale child,旧版无 marker 的孤立 entry 不做不安全的 path 推断;
7182
- malformed snapshot、unknown schema 和 version mismatch 会安全回退;
7283
- key segment 使用无碰撞 canonical encoding;现有 slug/数字路径保持不变,旧版空串、下划线开头或保留字符 key 的 snapshot 允许一次性回退 initial state;
7384
- `src/main.js` 在事件后合并保存,并在 HMR replacement、dispose、`pagehide` 前 flush;
@@ -160,7 +171,7 @@ feature_dom_marker(group = ..., key = ..., name = ...)
160171
### 4. 完善 effect 生命周期
161172

162173
- 重新评估 `state_effect(...)` cleanup 契约;
163-
- effect component identity 与 snapshot state identity 保持一致
174+
- effect metadata 已跟随 ordinary component identity 执行 unmount sweep;下一步定义真正的 cleanup callback 契约
164175
- HMR 前确认旧 runtime 的 cleanup、snapshot flush 和新 runtime boot 顺序;
165176
- 继续保持 browser host 只负责能力实现,不接管 feature workflow。
166177

@@ -174,9 +185,9 @@ feature_dom_marker(group = ..., key = ..., name = ...)
174185

175186
## GitHub 跟踪
176187

177-
- [#7 Hide feature identity and remove cross-component store path coordination](https://github.com/Respo/explore-react.koka/issues/7)当前实现批次
188+
- [#7 Hide feature identity and remove cross-component store path coordination](https://github.com/Respo/explore-react.koka/issues/7)已由 PR #10 合并
178189
- [#8 Prototype action-replay component stores for HMR recovery](https://github.com/Respo/explore-react.koka/issues/8):后续独立实验,不把外部 effect replay 混入本轮重构。
179-
- [#9 Define lifecycle cleanup for unreachable child component stores](https://github.com/Respo/explore-react.koka/issues/9):由 framework lifecycle 处理永久移除的 keyed child,避免业务 reducer 重建 path 清理
190+
- [#9 Define lifecycle cleanup for unreachable child component stores](https://github.com/Respo/explore-react.koka/issues/9)当前实现批次;由 framework visitation 处理永久移除的 keyed child。
180191

181192
## 验证标准
182193

@@ -189,7 +200,8 @@ feature_dom_marker(group = ..., key = ..., name = ...)
189200
5. 快速 input 后立即触发 click/Enter 不丢最后一次值;
190201
6. HMR replacement 后 component store 能从最新 snapshot 恢复;
191202
7. malformed/旧版本 snapshot 不导致 boot 失败;
192-
8. listener registry 没有 duplicate id 或 semantic drift warning。
203+
8. listener registry 没有 duplicate id 或 semantic drift warning;
204+
9. ordinary child unmount 会清理 store/effect/marker,整个 feature unmount 仍保留 snapshot。
193205

194206
## 非目标
195207

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,31 @@ older ambiguous encoding for empty, underscore-leading, or reserved-character
152152
keys fall back to the component's initial value once; current application keys
153153
are unaffected.
154154

155+
## Component lifecycle
156+
157+
Persistent features and ordinary keyed children deliberately have different
158+
unmount behavior:
159+
160+
| Boundary | When it is not rendered | State policy |
161+
| --- | --- | --- |
162+
| `component(...)` / `components(...)` | its current feature still renders | release the child state branch |
163+
| `feature_root(...)` | the whole feature is absent | retain the feature snapshot |
164+
| `reset_feature(...)` | integration explicitly resets a feature | release the complete feature branch |
165+
166+
Each rendered feature records which ordinary child scopes it visited. At the
167+
end of that feature render, old child markers that were not visited are swept
168+
together with their state and effect metadata. If the feature itself is not
169+
rendered—for example after changing routes—no sweep runs for it, so its state
170+
can still return after route navigation, HMR, or a reload.
171+
172+
This means filtering an item out of a still-mounted feature has normal unmount
173+
semantics and clears that item's local state. Domain reducers only remove
174+
domain entities; they do not reconstruct component paths for cleanup.
175+
176+
The complete lifecycle contract, marker format, migration behavior, and usage
177+
guidance are documented in
178+
[`docs/component-lifecycle.md`](docs/component-lifecycle.md).
179+
155180
Only the app integration boundary installs the runtime:
156181

157182
```koka
@@ -356,7 +381,9 @@ defensive:
356381
value;
357382
- malformed snapshot data is ignored instead of reaching a component decoder;
358383
- component state is restored only when its keyed scope and store schema still
359-
match.
384+
match;
385+
- `respo/component-scope` metadata preserves ordinary-child ownership across
386+
HMR/reload so stale child branches can be swept on the next feature render.
360387

361388
`src/main.js` persists the snapshot under
362389
`koka-respo:component-state:v1`. Writes are coalesced with

demo/tests.kk

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ pub fun demo_test_results() : <div> list<test_result>
2424
state_tree_test(),
2525
component_key_canonicalization_test(),
2626
ambient_feature_identity_test(),
27+
ordinary_component_lifecycle_test(),
28+
persistent_feature_lifecycle_test(),
2729
generic_state_codec_test(),
2830
malformed_store_payload_test(),
2931
auto_hook_scope_test(),
@@ -42,6 +44,7 @@ pub fun demo_test_results() : <div> list<test_result>
4244
add_task_focus_test(),
4345
start_edit_focus_test(),
4446
todo_edit_local_state_test(),
47+
todo_child_lifecycle_cleanup_test(),
4548
todo_edit_diff_test(),
4649
workflow_sync_test(),
4750
workflow_reply_test(),

demo/tests/statecases.kk

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,64 @@ pub fun ambient_feature_identity_test() : <div> test_result
7070
"resolved=" ++ resolved.show,
7171
passed)
7272

73+
fun lifecycle_seed_tree() : <div> list<state_entry>
74+
val (_unit, tree, _effects) = run_local_state(Nil, fn() {
75+
feature_root("lifecycle", "panel") {
76+
component("rows", "left") {
77+
val (_draft, set_draft) = state_pair(name = "draft", initial = "")
78+
set_draft("left draft")
79+
}
80+
component("rows", "right") {
81+
val (_draft, set_draft) = state_pair(name = "draft", initial = "")
82+
set_draft("right draft")
83+
}
84+
}
85+
})
86+
tree
87+
88+
pub fun ordinary_component_lifecycle_test() : <div> test_result
89+
val root_scope = component_local_path("lifecycle", "panel")
90+
val left_scope = component_child_path(root_scope, "rows", "left")
91+
val right_scope = component_child_path(root_scope, "rows", "right")
92+
val restored_tree = decode_state_snapshot(encode_state_snapshot(lifecycle_seed_tree()))
93+
val (_unit, next_tree, _effects) = run_local_state(restored_tree, fn() {
94+
feature_root("lifecycle", "panel") {
95+
component("rows", "right") {
96+
state(name = "draft", initial = "")
97+
()
98+
}
99+
}
100+
})
101+
val left_draft = read_named_local_state(next_tree, left_scope, "draft", "<removed>")
102+
val right_draft = read_named_local_state(next_tree, right_scope, "draft", "<missing>")
103+
val left_marker_removed = not(any(next_tree, fn(entry) entry.schema == "respo/component-scope" && entry.payload == left_scope))
104+
val right_marker_kept = any(next_tree, fn(entry) entry.schema == "respo/component-scope" && entry.payload == right_scope)
105+
Test_result(
106+
"Ordinary child state is swept after unmount",
107+
"left=" ++ left_draft ++ ", right=" ++ right_draft ++ ", markers=" ++ left_marker_removed.show ++ "/" ++ right_marker_kept.show,
108+
left_draft == "<removed>" && right_draft == "right draft" && left_marker_removed && right_marker_kept)
109+
110+
pub fun persistent_feature_lifecycle_test() : <div> test_result
111+
val root_scope = component_local_path("lifecycle", "panel")
112+
val left_scope = component_child_path(root_scope, "rows", "left")
113+
val (_hidden, hidden_tree, _hidden_effects) = run_local_state(lifecycle_seed_tree(), fn() {
114+
feature_root("other", "panel") { () }
115+
})
116+
val restored_tree = decode_state_snapshot(encode_state_snapshot(hidden_tree))
117+
val (_returned, return_tree, _return_effects) = run_local_state(restored_tree, fn() {
118+
feature_root("lifecycle", "panel") {
119+
component("rows", "left") {
120+
state(name = "draft", initial = "")
121+
()
122+
}
123+
}
124+
})
125+
val restored_draft = read_named_local_state(return_tree, left_scope, "draft", "<missing>")
126+
Test_result(
127+
"Unmounted feature root preserves child snapshot",
128+
"draft=" ++ restored_draft ++ ", hidden-entries=" ++ hidden_tree.length.show,
129+
restored_draft == "left draft")
130+
73131
pub fun generic_state_codec_test() : <div> test_result
74132
val scope_name = component_local_path("tests", "implicit-codec")
75133
val ((draft, open_before), next_tree, _pending_effects) = run_local_state(Nil, fn() {

demo/tests/todocases.kk

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,38 @@ pub fun todo_edit_local_state_test() : <div> test_result
120120
"title=" ++ title ++ ", local=" ++ local_entries.show ++ ", editing=" ++ task_editor_state/editing(started_task).show,
121121
title == "Prototype local title edits before save" && task_editor_state/editing(started_task) && not(task_editor_state/editing(saved_task)))
122122

123+
fun todo_scope_is_gone(tree : list<state_entry>, scope_name : string) : <div> bool
124+
not(any(tree, fn(entry) {
125+
entry.path == named_local_state_path(scope_name, "editor") ||
126+
entry.path == named_local_effect_path(scope_name, "focus-editor") ||
127+
(entry.schema == "respo/component-scope" && entry.payload == scope_name)
128+
}))
129+
130+
pub fun todo_child_lifecycle_cleanup_test() : <div> test_result
131+
with with_test_browser(True, "T+0")
132+
val task_one_scope = component_child_path(component_local_path("todo", "panel"), "tasks", "1")
133+
val task_two_scope = component_child_path(component_local_path("todo", "panel"), "tasks", "2")
134+
val h0 = todo_harness(initial_model())
135+
val h1 = todo_harness_click_payload(h0, keyed_button_payload(h0.tree, "1", "Edit"))
136+
val h2 = todo_harness_click_payload(h1, keyed_button_payload(h1.tree, "1", "Cancel"))
137+
val h3 = todo_harness_click_payload(h2, keyed_button_payload(h2.tree, "2", "Edit"))
138+
val h4 = todo_harness_click_payload(h3, keyed_button_payload(h3.tree, "2", "Cancel"))
139+
val stores_created =
140+
any(h4.runtime_tree, fn(entry) entry.path == named_local_state_path(task_one_scope, "editor")) &&
141+
any(h4.runtime_tree, fn(entry) entry.path == named_local_state_path(task_two_scope, "editor"))
142+
val h5 = harness_fire_named_click(h4, "clear-completed", todo_harness_render)
143+
val completed_removed = not(any(h5.owner.tasks, fn(item) task/id(item) == 1))
144+
val remaining_store_kept = any(h5.runtime_tree, fn(entry) entry.path == named_local_state_path(task_two_scope, "editor"))
145+
val h6 = todo_harness_click_payload(h5, keyed_button_payload(h5.tree, "2", "Remove"))
146+
val task_removed = not(any(h6.owner.tasks, fn(item) task/id(item) == 2))
147+
val passed =
148+
stores_created && completed_removed && todo_scope_is_gone(h5.runtime_tree, task_one_scope) && remaining_store_kept &&
149+
task_removed && todo_scope_is_gone(h6.runtime_tree, task_two_scope)
150+
Test_result(
151+
"Removed Todo children release component state",
152+
"created=" ++ stores_created.show ++ ", clear-done=" ++ completed_removed.show ++ ", remove=" ++ task_removed.show ++ ", entries=" ++ h6.runtime_tree.length.show,
153+
passed)
154+
123155
pub fun todo_edit_diff_test() : <div> test_result
124156
with with_test_browser(False, "T+0")
125157
val base_harness = todo_harness(initial_model())

0 commit comments

Comments
 (0)