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
3 changes: 3 additions & 0 deletions Agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,9 @@ div([
- parent component 不读取 child local store 做业务汇总;确实需要上层观察的数据提升到 domain model,纯调试统计放到 framework inspector。
- 每次 app render 只安装一个 stateful component runtime;feature 通过 scoped vnode component 在同一 handler 内组合。
- scheduled effects 按组件求值顺序收集;不要把跨组件的 effect 顺序当作数据依赖。
- 不需要释放资源的 render 后工作使用 `state_effect("name", deps) { ... }`;subscription、observer、timer handle 等使用 labelled `state_resource(name = ..., deps = ..., cleanup = ..., action = ...)`。
- resource 在 deps 变化时先 cleanup 再 setup,并在 ordinary child unmount、`reset_feature(...)` 与 HMR dispose 时 cleanup;persistent feature 仅暂时隐藏时继续保留 resource。
- cleanup closure 只存在于 host memory registry,不进入 snapshot;cleanup error 记录 host 日志且不得阻断后续 setup/boot。
- 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。
Expand Down
14 changes: 8 additions & 6 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ components(items, group = ..., key = ..., render = ...)
snapshot_store(name = ..., state_codec = ..., action_codec = ..., reduce = ...)
replay_store(name = ..., action_codec = ..., replay = ..., reduce = ...)
use_store(spec, initial = ...)
state_effect(name = ..., deps = ..., action = ...)
state_effect("name", deps) { ... }
state_resource(name = ..., deps = ..., cleanup = ..., action = ...)
on_store_click(name, action = ..., dispatch = ...)
on_store_input(name, action = ..., dispatch = ...)
on_action_click(name, action = ..., dispatch = ...)
Expand Down Expand Up @@ -163,9 +164,10 @@ feature_dom_marker(group = ..., key = ..., name = ...)

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

- 为 component author 提供清晰的 setup + cleanup authoring shape;
- 明确 deps change、ordinary child unmount、feature reset 与 runtime replacement 的 cleanup 顺序;
- snapshot flush 先于旧 runtime cleanup,cleanup closure 不进入 snapshot;
- 当前实现批次:`state_effect(...)` 保持轻量 trailing-lambda,`state_resource(...)` 显式声明 setup + cleanup;
- deps change 先 cleanup 后 setup,ordinary child unmount 与 feature reset 释放对应 resource;
- HMR 固定为 snapshot flush、旧 runtime dispose、新 runtime boot,cleanup closure 不进入 snapshot;
- cleanup failure 记录 host error,但不阻断下一次 setup 或新 runtime boot;
- 用一个真实 demo capability、deterministic tests 与浏览器 HMR 回归验证。

### 3. 发布 agent-safe store/action surface(#19)
Expand All @@ -191,8 +193,8 @@ issue、PR 及影响结论的进度更新统一使用中英双语:标题采用
- [#9 定义不可达 child component store 的生命周期清理 / Define lifecycle cleanup for unreachable child component stores](https://github.com/Respo/explore-react.koka/issues/9):已由 PR #11 合并;
- [#12 简化组件事件中的 domain 与 local-store transition / Simplify domain and local-store transitions in component events](https://github.com/Respo/explore-react.koka/issues/12):已由 PR #15 合并;四个真实调用点共享 event-independent transition,并覆盖 action 顺序与浏览器回归;
- [#13 发布渐进式组件作者 API / Publish a progressive-disclosure component authoring surface](https://github.com/Respo/explore-react.koka/issues/13):已由 PR #16 合并;四概念 quick start、单页 author API 与 advanced module import 边界已落地;
- [#17 版本化 runtime snapshot 并定义兼容迁移 / Version runtime snapshots and define compatible migration](https://github.com/Respo/explore-react.koka/issues/17):下一实现批次;先固定 snapshot envelope、legacy compatibility 与安全回退;
- [#18 定义组件 effect cleanup 生命周期 / Define the component effect cleanup lifecycle](https://github.com/Respo/explore-react.koka/issues/18):排在 #17 之后,依赖明确的 HMR snapshot/boot 边界;
- [#17 版本化 runtime snapshot 并定义兼容迁移 / Version runtime snapshots and define compatible migration](https://github.com/Respo/explore-react.koka/issues/17):已由 PR #21 合并;snapshot envelope、legacy compatibility 与安全回退已落地;
- [#18 定义组件 effect cleanup 生命周期 / Define the component effect cleanup lifecycle](https://github.com/Respo/explore-react.koka/issues/18):当前实现批次;effect/resource author API、host registry、HMR dispose、测试与文档已完成,等待 PR review;
- [#19 发布 agent-safe store catalog 与校验 action dispatch / Publish an agent-safe store catalog and validated action dispatch](https://github.com/Respo/explore-react.koka/issues/19):后续探索批次;在不暴露 raw runtime tree 的前提下服务 devtools/agents。

## 验证标准
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,17 @@ are advanced integration topics, not prerequisites:
child needs stable lifecycle identity.
3. **Typed stores/actions:** define a serializable action and use
`(state, dispatch) = use_store(spec, initial = ...)`.
4. **Effects:** declare post-render work with `state_effect(...)`, and keep
browser/service capabilities as explicit Koka effects.
4. **Effects:** use `state_effect(...)` for post-render work and
`state_resource(...)` for setup/cleanup lifecycles; keep browser/service
capabilities as explicit Koka effects.

Start with the bilingual [component quick start](docs/quick-start.md), then use
the [one-page component author API](docs/component-authoring.md) as the normal
reference. Persistent feature identity, recovery, and cross-domain/local
transitions are introduced only when needed:

- [component lifecycle](docs/component-lifecycle.md)
- [effect lifecycle](docs/effect-lifecycle.md)
- [store recovery](docs/store-recovery.md)
- [action/store transitions](docs/action-store-transitions.md)

Expand All @@ -53,7 +55,8 @@ JavaScript hot replacement or be restored from `localStorage`.
| local dispatch | `dispatch(action)` or `on_store_*` |
| app reducer/action | `on_action_click(...)` / `on_action_input(...)` / `on_action_enter(...)` |
| domain + local transition | `action_store_transition(...)` reused by `on_local_click(...)` / `on_local_enter(...)` |
| `useEffect`-like hook | `state_effect(name=..., deps=..., action=...)` |
| post-render effect | `state_effect("name", deps) { ... }` |
| managed effect resource | `state_resource(name=..., deps=..., cleanup=..., action=...)` |
| Context-like value | a Koka `val` effect |
| browser/service capability | a Koka `fun` effect |
| test double | an alternate effect handler |
Expand Down
3 changes: 3 additions & 0 deletions app.kk
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ pub fun dispatch_route_bridge(route_name : string) : _e ()
pub fun export_state_snapshot_bridge() : _e string
browserapp/export_state_snapshot_bridge()

pub fun dispose_runtime_bridge() : _e ()
browserapp/dispose_runtime_bridge()

pub fun boot_with_snapshot(root_id : string, snapshot : string) : _e ()
browserapp/boot_with_snapshot(root_id, snapshot)

Expand Down
20 changes: 14 additions & 6 deletions boilerplate/browserapp.kk
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ noinline val root_id_ref : ref<global,string> = unsafe-total { ref("app") }
noinline val frame_ref : ref<global,runtime_frame> = unsafe-total { ref(current_runtime_frame(initial_model(), Nil)) }
noinline val tree_ref : ref<global,vnode> = unsafe-total { ref(el("div", "root", Nil, Nil, "", Nil)) }
noinline val handlers_ref : ref<global,event_registry<model,browser_event_effect>> = unsafe-total { ref(empty_event_registry()) }
noinline val effect_registry_ref : ref<global,list<registered_effect>> = unsafe-total { ref(empty_effect_registry()) }

fun with_browser_host(action : () -> <browser_host,div|e> a) : <div|e> a
with handler
Expand All @@ -37,12 +38,15 @@ fun with_browser(action : () -> <browser_host,request_bridge_resource,div|e> a)
with fun request_bridge_resource(payload, delay) browser_schedule_click(payload, delay)
with_browser_host(action)

fun run_pending_effects(items : list<scheduled_effect>) : _e ()
fun log_effect_error(message : string) : _e ()
browser_log("[explore-react effect] " ++ message)

fun run_pending_effects(items : list<scheduled_effect>, registry : list<registered_effect>) : _e list<registered_effect>
match items
Nil -> ()
Nil -> registry
Cons(item, rest) -> {
run_scheduled_effect(item)
run_pending_effects(rest)
val next_registry = run_scheduled_effect(registry, item, log_effect_error)
run_pending_effects(rest, next_registry)
}

fun log_registry_warnings(messages : list<string>) : _e ()
Expand Down Expand Up @@ -74,7 +78,7 @@ fun commit(frame : runtime_frame) : _e ()
handlers_ref := next_handlers
log_registry_warnings(event_registry/warnings(next_handlers))
log_registry_warnings(event_registry_semantic_warnings(previous_handlers, next_handlers))
run_pending_effects(pending_effects)
effect_registry_ref := run_pending_effects(pending_effects, !effect_registry_ref)

fun next_click_frame(payload : string, frame : runtime_frame, handlers : event_registry<model,browser_event_effect>) : <div> (runtime_frame, list<action_envelope>)
match find_registered_click(handlers, payload)
Expand Down Expand Up @@ -113,7 +117,11 @@ pub fun dispatch_route_bridge(route_name : string) : _e ()
pub fun export_state_snapshot_bridge() : _e string
encode_state_snapshot(runtime_frame/runtime_tree(!frame_ref))

pub fun dispose_runtime_bridge() : _e ()
effect_registry_ref := dispose_effect_registry(!effect_registry_ref, log_effect_error)

pub fun boot_with_snapshot(root_id : string, snapshot : string) : _e ()
dispose_runtime_bridge()
root_id_ref := root_id
set_root_id(root_id)
attach_events(root_id)
Expand All @@ -127,7 +135,7 @@ pub fun boot_with_snapshot(root_id : string, snapshot : string) : _e ()
mount_html(root_id, render_node("0", tree))
browser_set_hash(next_model.route_name)
log_registry_warnings(event_registry/warnings(handlers))
run_pending_effects(pending_effects)
effect_registry_ref := run_pending_effects(pending_effects, !effect_registry_ref)

pub fun boot(root_id : string) : _e ()
boot_with_snapshot(root_id, "")
4 changes: 2 additions & 2 deletions demo/dialogs.kk
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ pub fun dialog_overlay(owner : model) : app_view vnode
val submit_action = on_local_click("submit-action", fn(next_owner : model) prompt_submit(next_owner))
val submit_enter = on_local_enter("submit-action", fn(next_owner : model) prompt_submit(next_owner))
val prompt_input = on_local_input("prompt-input", fn(value, next_owner : model) next_owner(dialog = update_dialog_prompt(next_owner.dialog, value)))
state_effect(name = "focus-dialog", deps = [current.kind.show_kind, current.prompt_error, current.title], action = fn() {
state_effect("focus-dialog", [current.kind.show_kind, current.prompt_error, current.title]) {
if dialog_is_open(current) then dom_focus(dialog_focus_marker(current)) else ()
})
}
dialog/render_dialog_overlay(current, spec = overlay_spec(current, prompt_input, submit_enter), controls = Dialog_overlay_controls(close_overlay, primary_action, submit_action))
}
7 changes: 7 additions & 0 deletions demo/effectspanel.kk
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import demo/dialogs
import demo/model
import demo/tests/support
import demo/shared
import runtime/dom
import explore/react/core
import explore/react/state

Expand All @@ -27,6 +28,7 @@ fun core_effect_cases(item : model) : <div> list<vnode>
val done = tasks_for("done", item.tasks)
[
effect_case("Confirm", "`confirm_action` is handled by the browser in app mode and by tests in-process.", p("Try Remove or Clear done in the todo panel.", class = "mono")),
effect_case("Effect lifecycle", "A named effect releases its previous resource before changed dependencies install the next one; HMR snapshots state before disposing live resources.", p("Switch Effects subroutes or rebuild Koka during development to observe setup and cleanup in the console.", class = "mono")),
effect_case("Ambient filter", "The reducer stays unchanged while handlers project active or done subsets.", div([
div([
strong("Active"),
Expand Down Expand Up @@ -63,6 +65,11 @@ fun effects_panel_note(route_name : string) : string

pub fun effects_panel(item : model, results : list<test_result>, key : string = "panel") : app_view vnode
feature_root("effects", key) {
state_resource(
name = "route-session",
deps = [item.route_name],
cleanup = fn() browser_log("[demo effect] cleanup " ++ item.route_name),
action = fn() browser_log("[demo effect] setup " ++ item.route_name))
val cases = route_effect_cases(item, results)
panel([
div(cases, class = "case-grid"),
Expand Down
3 changes: 2 additions & 1 deletion demo/runtimeframe.kk
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ pub fun run_runtime_render<a,e::E>(
) : <div> (runtime_frame, a, list<scheduled_effect>, event_registry<model,e>)
val (rendered, next_tree) = with_component_runtime(runtime_frame/runtime_tree(frame), fn() render(runtime_frame/owner(frame)))
val (next_owner, result, effects, registry) = rendered
(Runtime_frame(next_owner, next_tree), result, effects, registry)
val lifecycle_effects = reconcile_effect_transitions(runtime_frame/runtime_tree(frame), next_tree, effects)
(Runtime_frame(next_owner, next_tree), result, lifecycle_effects, registry)
6 changes: 6 additions & 0 deletions demo/tests.kk
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ pub fun demo_test_results() : <div> list<test_result>
generic_state_codec_test(),
replay_empty_payload_test(),
runtime_snapshot_format_test(),
effect_dependency_lifecycle_test(),
effect_unmount_lifecycle_test(),
effect_reset_and_persistence_test(),
action_store_transition_test(),
malformed_store_payload_test(),
auto_hook_scope_test(),
Expand Down Expand Up @@ -82,3 +85,6 @@ pub fun demo_test_results() : <div> list<test_result>
search_auto_cancelled_by_submit_test(),
search_pick_starts_pending_test(),
]

pub fun effect_runtime_test_result() : io test_result
statecases/effect_runtime_execution_test()
Loading
Loading