diff --git a/Agents.md b/Agents.md index 3e008f2..7895ebf 100644 --- a/Agents.md +++ b/Agents.md @@ -7,15 +7,19 @@ 第一次回到仓库时,优先按这个顺序走: 1. 先看 `package.json` 里的脚本,确认日常入口还是 `yarn dev`、`yarn build`、`yarn test:koka`。 -2. 再看 `app.kk`,确认当前浏览器桥只暴露哪些 Koka 入口。 -3. 然后看 `explore/react/*` 和 `demo/*` 的边界:前者是库,后者是 demo 和业务。 -4. 开始改代码前,先跑一次 `yarn build`,确认自己不是站在坏状态上继续开发。 +2. 组件作者先看 `docs/quick-start.md` 与 `docs/component-authoring.md`;不要从 runtime snapshot 实现反推日常 API。 +3. 再看 `app.kk`,确认当前浏览器桥只暴露哪些 Koka 入口。 +4. 然后看 `explore/react/*` 和 `demo/*` 的边界:前者是库,后者是 demo 和业务。 +5. 开始改代码前,先跑一次 `yarn build`,确认自己不是站在坏状态上继续开发。 ## 仓库结构 - 仓库根目录:就是 Koka 源码根目录,编译时直接把 repo root 当成模块搜索根。 - `app.kk`:浏览器入口,只暴露 boot、事件桥接和 runtime snapshot 导入导出。 -- `explore/react/*`:核心 VDOM、typed component store、listener registry、render、diff/patch。这里尽量保持通用。 +- `explore/react/core.kk`、`action.kk`、`state.kk`:component authoring surface;业务 view 只从这里获取 elements、actions、stores、keyed lifecycle 与 effects。 +- `explore/react/runtime.kk`:browser/app host 使用的 registered callback、scheduled effect 与 snapshot transport。 +- `explore/react/inspection.kk`:tests/devtools 使用的 VDOM、event registry 与 runtime entry 只读查询。 +- `explore/react/renderer.kk`:host/tests 使用的 render、diff 与 patch;不要导入业务 view。 - `demo/*`:具体 demo、布局、组件、路由和测试辅助。 - `demo/runtimeframe.kk`:app 边界的 runtime owner,同时持有业务 model 和框架 state tree。 - `runtime/*`:只放 DOM 和系统边界的 FFI,不要把业务逻辑塞进来。 @@ -148,6 +152,8 @@ chrome-devtools take_screenshot --fullPage --filePath .tmp-devtools-full.png 组件交互状态以 **typed store + serializable actions** 为默认方案。它保留 React reducer 的简单心智模型,同时满足 Koka 严格类型、HMR 和 snapshot 恢复需求。 +- 普通业务 view 只导入 `explore/react/core`、`explore/react/action`、`explore/react/state` 中实际需要的模块;不得导入 `explore/react/runtime`、`explore/react/inspection` 或 `explore/react/renderer`。 + - 业务组件用 `(state, dispatch) = use_store(spec, initial = ...)` 读取 reducer pair,不额外暴露 binding record。 - UI 事件优先用 `on_store_click(name, action = ..., dispatch = ...)` / `on_store_input(...)` 发送 typed action,不直接操作 state tree。 - domain action 事件优先用 `on_action_click(name, action = ..., dispatch = ...)` / `on_action_input(...)` / `on_action_enter(...)`,不要在每个 element 内重复 forwarding closure。 diff --git a/PLAN.md b/PLAN.md index 3e24288..942c061 100644 --- a/PLAN.md +++ b/PLAN.md @@ -170,9 +170,9 @@ feature_dom_marker(group = ..., key = ..., name = ...) ### 3. 拆分 component facade 与 runtime/testing API -- 将普通组件需要的 `component/components/use_store/state_effect/on_*` 收敛到 facade; -- 将 runner、registry、snapshot 与 tree inspection 移到 runtime/testing 模块; -- opaque feature identity 已进入 `feature_root`;下一步把显式 key/path inspection API 物理拆到 runtime/testing module; +- 普通组件的 authoring surface 已明确收敛在 `core/action/state`,不额外增加只做转发的 facade; +- registered callback runner、scheduled effect、snapshot transport 已物理移到 `explore/react/runtime`;VDOM、event registry 与 entry count 查询已物理移到 `explore/react/inspection`; +- opaque feature identity 已进入 `feature_root`;显式 key/path 与 store inspection 目前仍和 state tree 实现共享私有依赖,只有能移动真实实现且不产生单行 forwarding wrapper 时再继续拆分; - 为 controlled component 固定“主 domain value 位置参数 + labelled callback/config props”的签名模板,避免每个 feature 再造 props adapter; - scope 计算只保留在确有跨组件协调的 state 模块; - reducer、codec、store spec 尽量同模块定义,view 只 import typed surface; @@ -200,8 +200,8 @@ issue、PR 及影响结论的进度更新统一使用中英双语:标题采用 - [#7 Hide feature identity and remove cross-component store path coordination](https://github.com/Respo/explore-react.koka/issues/7):已由 PR #10 合并; - [#8 减少 typed store 样板代码并显式选择 replay 恢复 / Reduce typed store boilerplate with explicit replay recovery](https://github.com/Respo/explore-react.koka/issues/8):已由 PR #14 合并;snapshot/replay recovery 选择与 Todo editor session 语义已落地; - [#9 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):当前实现批次;以四个真实调用点、action 顺序测试和组件定义可读性作为是否保留公共 API 的标准; -- [#13 发布渐进式组件作者 API / Publish a progressive-disclosure component authoring surface](https://github.com/Respo/explore-react.koka/issues/13):等待 #12 的 transition API 完成使用者评估后,整理 quick start、authoring API 与 module 边界。 +- [#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):当前实现批次;以四概念 quick start、单页 author API、advanced module import 边界作为验收标准。 ## 验证标准 diff --git a/README.md b/README.md index 901ee7b..72cad8d 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,29 @@ This repo explores a React-like component runtime in Koka, with Algebraic Effects used for browser capabilities, test substitution, component-local state, and event dispatch. +## Start with component authoring + +Learn the normal path as four tasks; runtime ownership and snapshot transport +are advanced integration topics, not prerequisites: + +1. **Elements:** write ordinary view functions with positional content and + labelled attributes/events. +2. **Keyed components:** add `component(...)` / `components(...)` only when a + 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. + +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) +- [store recovery](docs/store-recovery.md) +- [action/store transitions](docs/action-store-transitions.md) + The current design deliberately separates two kinds of state: - domain state lives in the application `model` and changes through typed @@ -472,8 +495,14 @@ diffing, and patch planning remain in Koka. - `explore/react/action.kk`: serializable action codecs, envelopes, and the observation effect. - `explore/react/state.kk`: component scopes, typed stores, listeners, effects, - snapshots, and runtime handlers. + and lifecycle authoring. +- `explore/react/runtime.kk`: host-only registered callback execution, + scheduled effects, and snapshot transport. +- `explore/react/inspection.kk`: read-only VDOM/event-registry/runtime queries + for tests and devtools. - `explore/react/renderer.kk`: rendering, diffing, and patch planning. +- `docs/quick-start.md` and `docs/component-authoring.md`: progressive + component tutorial and the compact preferred API. - `demo/*`: application shell, features, actions, stores, and workflows. - `demo/runtimeframe.kk`: pairs the domain model with the framework runtime tree and runs render/action transitions at the app boundary. diff --git a/boilerplate/browserapp.kk b/boilerplate/browserapp.kk index 5591b3a..653b040 100644 --- a/boilerplate/browserapp.kk +++ b/boilerplate/browserapp.kk @@ -14,6 +14,8 @@ import library/bridge import library/route import explore/react/action import explore/react/state +import explore/react/runtime +import explore/react/inspection noinline val root_id_ref : ref = unsafe-total { ref("app") } noinline val frame_ref : ref = unsafe-total { ref(current_runtime_frame(initial_model(), Nil)) } diff --git a/demo/tests/appharness.kk b/demo/tests/appharness.kk index b64c5da..970f547 100644 --- a/demo/tests/appharness.kk +++ b/demo/tests/appharness.kk @@ -8,6 +8,8 @@ import demo/tests/support import library/bridge import explore/react/core import explore/react/state +import explore/react/runtime +import explore/react/inspection fun app_harness_render(previous_owner : maybe, frame : runtime_frame) :
component_harness val (next_frame, tree, effects, registry) = run_runtime_render(frame, fn(owner) render_app_with_runtime(previous_owner, owner, Nil)) diff --git a/demo/tests/basics.kk b/demo/tests/basics.kk index eef0286..2413d05 100644 --- a/demo/tests/basics.kk +++ b/demo/tests/basics.kk @@ -15,6 +15,8 @@ import explore/react/action import explore/react/core import explore/react/renderer import explore/react/state +import explore/react/runtime +import explore/react/inspection fun timer_logic() : string wait_ms(20) diff --git a/demo/tests/dialogcases.kk b/demo/tests/dialogcases.kk index 0a73bb9..4feb1aa 100644 --- a/demo/tests/dialogcases.kk +++ b/demo/tests/dialogcases.kk @@ -11,6 +11,7 @@ import library/dialog import library/route import explore/react/core import explore/react/state +import explore/react/inspection pub fun dialog_alert_open_test() :
test_result with with_test_browser(False, "T+0") diff --git a/demo/tests/labcases.kk b/demo/tests/labcases.kk index 8b19166..883a711 100644 --- a/demo/tests/labcases.kk +++ b/demo/tests/labcases.kk @@ -15,6 +15,7 @@ import demo/tests/support import explore/react/core import explore/react/action import explore/react/state +import explore/react/inspection pub fun lab_local_routing_test() :
test_result with with_test_browser(False, "T+120") diff --git a/demo/tests/statecases.kk b/demo/tests/statecases.kk index beb7213..213df2d 100644 --- a/demo/tests/statecases.kk +++ b/demo/tests/statecases.kk @@ -6,6 +6,8 @@ import demo/todo/state import explore/react/action import explore/react/core import explore/react/state +import explore/react/runtime +import explore/react/inspection pub fun state_tree_test() :
test_result val incident_scope = "lab/incidents/101" diff --git a/demo/tests/support.kk b/demo/tests/support.kk index 32bc047..8c45645 100644 --- a/demo/tests/support.kk +++ b/demo/tests/support.kk @@ -15,6 +15,8 @@ import library/search import explore/react/action import explore/react/core import explore/react/state +import explore/react/runtime +import explore/react/inspection pub struct test_result(name : string, detail : string, passed : bool) diff --git a/demo/tests/todocases.kk b/demo/tests/todocases.kk index ffab9db..cdd97af 100644 --- a/demo/tests/todocases.kk +++ b/demo/tests/todocases.kk @@ -11,6 +11,8 @@ import explore/react/action import explore/react/core import explore/react/renderer import explore/react/state +import explore/react/runtime +import explore/react/inspection pub fun todo_editor_store_protocol_test() :
test_result val scope_group = "tests" diff --git a/docs/component-authoring.md b/docs/component-authoring.md new file mode 100644 index 0000000..75a0fad --- /dev/null +++ b/docs/component-authoring.md @@ -0,0 +1,139 @@ +# Component author API + +# 中文 + +普通业务组件只导入三个模块: + +```koka +import explore/react/core +import explore/react/action +import explore/react/state +``` + +这三个模块分别承载 elements/VDOM、serializable action protocol,以及 component lifecycle/store/effect authoring。不要在业务 view 中导入 `explore/react/runtime`、`explore/react/inspection` 或 `explore/react/renderer`。 + +## 1. Elements + +| 任务 | 推荐 API | +| --- | --- | +| container | `div/section/article/aside/header/nav/ul/li(children, class = ..., key = ...)` | +| text element | `span/strong/p/h1/h2/h3(text, class = ..., key = ...)` | +| interaction | `button(text, click = ...)`, `input_text(value, input = ..., enter = ...)` | +| uncommon DOM capability | `extra_attrs` / `extra_events` | + +主要内容始终是第一个位置参数;常用属性和事件保持 flat labelled arguments。不要恢复 `attrs = ..., children = ...` 的嵌套形态。 + +## 2. Keyed components + +| 任务 | 推荐 API | +| --- | --- | +| ordinary keyed child | `component(group, key) { ... }` | +| keyed collection | `components(items, group = ..., key = ..., render = ...)` | +| persistent page feature | `feature_root(group, key) { ... }` | +| feature-owned VDOM/effect identity | `feature_key()` / `feature_marker(name)` | + +普通 view helper 不建立 identity。只有 local state、effect 或 listener 需要独立 lifecycle 时才建立 keyed boundary。 + +## 3. Typed stores and actions + +| 任务 | 推荐 API | +| --- | --- | +| action protocol | `Action_codec(schema = ..., version = ..., decode = ..., encode = ...)` | +| full-state recovery | `snapshot_store(name = ..., state_codec = ..., action_codec = ..., reduce = ...)` | +| bounded session replay | `replay_store(name = ..., action_codec = ..., replay = ..., reduce = ...)` | +| component reducer pair | `(state, dispatch) = use_store(spec, initial = ...)` | +| component action event | `on_store_click/input(name, action = ..., dispatch = ...)` | +| domain action event | `on_action_click/input/enter(name, action = ..., dispatch = ...)` | +| one domain + one local action | `action_store_transition(action, dispatch = ..., store_action = ..., store = ..., store_when = ...)` | +| custom handler | `on_local_click/input/enter(name, handler)` | + +domain reducer 不读取 child store。组件需要提交 draft 时,把值放进 serializable domain action。复杂分支才使用 `on_local_*`。 + +## 4. Effects + +| 任务 | 推荐 API | +| --- | --- | +| render 后按 deps 调度 | `state_effect(name = ..., deps = ..., action = ...)` | +| browser/service capability | Koka `fun` effect + app/test handler | +| ambient read-only value | Koka `val` effect | + +effect name 在同一 component boundary 内保持稳定。业务 workflow 显式声明 confirm、timer、request 等 capability;不要隐藏到 snapshot/replay 或 arbitrary callback 中。 + +## Advanced modules + +| 模块 | 使用者 | +| --- | --- | +| `explore/react/runtime` | browser/app host:运行 registered callbacks、scheduled effects、snapshot transport | +| `explore/react/inspection` | tests/devtools:查询 VDOM、event registry 与 runtime entry count | +| `explore/react/renderer` | host/tests:render、diff、patch | + +这些模块不是 component authoring surface。更深入的恢复和生命周期规则见 [store recovery](store-recovery.md) 与 [component lifecycle](component-lifecycle.md)。 + +# English + +Ordinary business components import only three modules: + +```koka +import explore/react/core +import explore/react/action +import explore/react/state +``` + +They provide elements/VDOM, the serializable action protocol, and component lifecycle/store/effect authoring. Business views do not import `explore/react/runtime`, `explore/react/inspection`, or `explore/react/renderer`. + +## 1. Elements + +| Task | Preferred API | +| --- | --- | +| Container | `div/section/article/aside/header/nav/ul/li(children, class = ..., key = ...)` | +| Text element | `span/strong/p/h1/h2/h3(text, class = ..., key = ...)` | +| Interaction | `button(text, click = ...)`, `input_text(value, input = ..., enter = ...)` | +| Uncommon DOM capability | `extra_attrs` / `extra_events` | + +Primary content is always the first positional argument. Common attributes and events remain flat labelled arguments; do not restore a nested `attrs = ..., children = ...` shape. + +## 2. Keyed components + +| Task | Preferred API | +| --- | --- | +| Ordinary keyed child | `component(group, key) { ... }` | +| Keyed collection | `components(items, group = ..., key = ..., render = ...)` | +| Persistent page feature | `feature_root(group, key) { ... }` | +| Feature-owned VDOM/effect identity | `feature_key()` / `feature_marker(name)` | + +An ordinary view helper does not establish identity. Add a keyed boundary only when local state, effects, or listeners need an independent lifecycle. + +## 3. Typed stores and actions + +| Task | Preferred API | +| --- | --- | +| Action protocol | `Action_codec(schema = ..., version = ..., decode = ..., encode = ...)` | +| Full-state recovery | `snapshot_store(name = ..., state_codec = ..., action_codec = ..., reduce = ...)` | +| Bounded session replay | `replay_store(name = ..., action_codec = ..., replay = ..., reduce = ...)` | +| Component reducer pair | `(state, dispatch) = use_store(spec, initial = ...)` | +| Component action event | `on_store_click/input(name, action = ..., dispatch = ...)` | +| Domain action event | `on_action_click/input/enter(name, action = ..., dispatch = ...)` | +| One domain + one local action | `action_store_transition(action, dispatch = ..., store_action = ..., store = ..., store_when = ...)` | +| Custom handler | `on_local_click/input/enter(name, handler)` | + +A domain reducer never reads a child store. Put a draft or other current component value into the serializable domain action. Use `on_local_*` only for genuinely custom branching. + +## 4. Effects + +| Task | Preferred API | +| --- | --- | +| Schedule after render by dependencies | `state_effect(name = ..., deps = ..., action = ...)` | +| Browser/service capability | A Koka `fun` effect with app/test handlers | +| Ambient read-only value | A Koka `val` effect | + +Keep effect names stable within one component boundary. Business workflows declare confirm, timer, request, and similar capabilities explicitly; do not hide them in snapshot/replay or arbitrary callbacks. + +## Advanced modules + +| Module | Audience | +| --- | --- | +| `explore/react/runtime` | Browser/app hosts: registered callbacks, scheduled effects, and snapshot transport | +| `explore/react/inspection` | Tests/devtools: VDOM, event-registry, and runtime-entry queries | +| `explore/react/renderer` | Hosts/tests: render, diff, and patch | + +These modules are not part of the component-authoring surface. See [store recovery](store-recovery.md) and [component lifecycle](component-lifecycle.md) for the deeper contracts. diff --git a/docs/quick-start.md b/docs/quick-start.md new file mode 100644 index 0000000..7135328 --- /dev/null +++ b/docs/quick-start.md @@ -0,0 +1,191 @@ +# Component quick start + +# 中文 + +Respo 的日常组件代码只需要按顺序理解四件事:elements、keyed components、typed stores/actions 和 effects。下面用一个 FAQ feature 把它们串在一起。 + +## 1. 先写普通 view function + +element 的主要内容保持第一个位置参数,样式、key 和事件使用 labelled arguments: + +```koka +import explore/react/core + +fun faq_answer(text : string) : vnode + p(text, class = "faq-answer") +``` + +普通 function call 只负责拆分渲染,不创建组件 identity。没有 local state 的 helper 保持普通函数即可。 + +## 2. 定义 typed store 与 serializable action + +store 把 state、action、纯 reducer 和恢复方式定义在一起。这个 disclosure 只有一个 toggle action,所以使用内置 bool codec 的 snapshot store: + +```koka +import explore/react/action +import explore/react/state + +type disclosure_action + Toggle_disclosure + +val disclosure_action_codec : action_codec = Action_codec( + schema = "guide/disclosure-action", + version = 1, + decode = fn(version, payload) { + if version == 1 && payload == "t" then Just(Toggle_disclosure) else Nothing + }, + encode = fn(_action) "t") + +val disclosure_store : store_spec = snapshot_store( + name = "open", + state_codec = bool/state_codec, + action_codec = disclosure_action_codec, + reduce = fn(open, _action) not(open)) +``` + +action schema/version 保持显式,方便 HMR 恢复、日志和未来 agent tooling。恢复方式只在 store 定义处选择;view 始终只拿 reducer pair。 + +## 3. 在组件中使用 state/dispatch pair + +```koka +struct faq_item(id : string, question : string, answer : string) + +fun faq_item_view(item : faq_item) + val (open, dispatch) = use_store(disclosure_store, initial = False) + state_effect( + name = "log-open", + deps = [open.show], + action = fn() println(item.question ++ ": " ++ open.show)) + article([ + button( + item.question, + class = "faq-question", + click = on_store_click( + "toggle", + action = Toggle_disclosure, + dispatch = dispatch)), + if open then faq_answer(item.answer) else span(""), + ], key = item.id, class = "faq-item") +``` + +`use_store(...)` 返回熟悉的 `(state, dispatch)`。用户事件发送 typed action;`state_effect(...)` 用稳定 name 和 deps 描述 render 后的 effect。 + +## 4. 用 keyed boundary 组成 feature + +```koka +fun faq_panel(items : list, key : string = "panel") + feature_root("faq", key) { + section([ + h2("Frequently asked questions"), + div(components( + items, + group = "items", + key = fn(item) item.id, + render = faq_item_view)), + ], key = feature_key(), class = "faq-panel") + } +``` + +`components(...)` 给每个 item 建立稳定 keyed child;删除或过滤 item 等同于 unmount。`feature_root(...)` 是页面级 persistent boundary,route 暂时离开或 JavaScript hot replacement 时可以保留 feature snapshot。trailing-lambda 让 lifecycle boundary 与普通 helper call 在视觉上明显不同。 + +## 下一步 + +- [Component author API](component-authoring.md):一页内查看推荐 surface 和选择规则。 +- [Store recovery](store-recovery.md):何时选择 snapshot_store 或 replay_store。 +- [Component lifecycle](component-lifecycle.md):ordinary child 与 persistent feature 的清理/保留语义。 +- [Action/store transitions](action-store-transitions.md):同一事件如何协调 domain intent 与 component store。 + +# English + +Everyday Respo component code can be learned as four concepts in order: elements, keyed components, typed stores/actions, and effects. The following FAQ feature combines all four. + +## 1. Start with an ordinary view function + +Keep primary element content positional and use labelled arguments for styling, keys, and events: + +```koka +import explore/react/core + +fun faq_answer(text : string) : vnode + p(text, class = "faq-answer") +``` + +An ordinary function call only extracts rendering. A helper without local state does not need component identity. + +## 2. Define a typed store and serializable action + +A store groups its state, action, pure reducer, and recovery choice. This disclosure has one toggle action, so it uses a snapshot store with the built-in bool codec: + +```koka +import explore/react/action +import explore/react/state + +type disclosure_action + Toggle_disclosure + +val disclosure_action_codec : action_codec = Action_codec( + schema = "guide/disclosure-action", + version = 1, + decode = fn(version, payload) { + if version == 1 && payload == "t" then Just(Toggle_disclosure) else Nothing + }, + encode = fn(_action) "t") + +val disclosure_store : store_spec = snapshot_store( + name = "open", + state_codec = bool/state_codec, + action_codec = disclosure_action_codec, + reduce = fn(open, _action) not(open)) +``` + +The action schema/version remains explicit for HMR recovery, logging, and future agent tooling. Recovery is selected only at the store definition; the view always receives the same reducer pair. + +## 3. Use the state/dispatch pair in a component + +```koka +struct faq_item(id : string, question : string, answer : string) + +fun faq_item_view(item : faq_item) + val (open, dispatch) = use_store(disclosure_store, initial = False) + state_effect( + name = "log-open", + deps = [open.show], + action = fn() println(item.question ++ ": " ++ open.show)) + article([ + button( + item.question, + class = "faq-question", + click = on_store_click( + "toggle", + action = Toggle_disclosure, + dispatch = dispatch)), + if open then faq_answer(item.answer) else span(""), + ], key = item.id, class = "faq-item") +``` + +`use_store(...)` returns the familiar `(state, dispatch)` pair. User events send typed actions; `state_effect(...)` describes a post-render effect with a stable name and dependencies. + +## 4. Compose a feature with keyed boundaries + +```koka +fun faq_panel(items : list, key : string = "panel") + feature_root("faq", key) { + section([ + h2("Frequently asked questions"), + div(components( + items, + group = "items", + key = fn(item) item.id, + render = faq_item_view)), + ], key = feature_key(), class = "faq-panel") + } +``` + +`components(...)` creates a stable keyed child for every item; removing or filtering an item is an unmount. `feature_root(...)` is a page-level persistent boundary that can retain its feature snapshot across temporary route absence or JavaScript hot replacement. Trailing-lambda syntax makes lifecycle boundaries visually distinct from ordinary helper calls. + +## Next steps + +- [Component author API](component-authoring.md): the preferred surface and decision rules on one page. +- [Store recovery](store-recovery.md): when to choose snapshot_store or replay_store. +- [Component lifecycle](component-lifecycle.md): cleanup and retention for ordinary children and persistent features. +- [Action/store transitions](action-store-transitions.md): coordinating a domain intent and component store in one event. diff --git a/explore/react/core.kk b/explore/react/core.kk index a3717ed..b679850 100644 --- a/explore/react/core.kk +++ b/explore/react/core.kk @@ -160,69 +160,3 @@ pub fun input_listener(payload : string) : listener // Prefer `on_local_enter(...)` in app code unless you intentionally want raw string routing. pub fun enter_listener(payload : string) : listener Listener(Enter_event, payload) - -// --- Vnode query helpers (useful in tests and inspectors) --- - -// Recursively find the first vnode in the subtree whose key matches target_key. -pub fun vnode_find_key(node : vnode, target_key : string) :
maybe - if vnode/key(node) == target_key then Just(node) - else vnode_find_key_in_children(vnode/children(node), target_key) - -fun vnode_find_key_in_children(children : list, target : string) :
maybe - match children - Nil -> Nothing - Cons(child, rest) -> - match vnode_find_key(child, target) - Just(found) -> Just(found) - Nothing -> vnode_find_key_in_children(rest, target) - -pub fun vnode_find_attr_value(node : vnode, name : string, value : string) :
maybe - if vnode_get_attr(node, name) == value then Just(node) - else vnode_find_attr_value_in_children(vnode/children(node), name, value) - -fun vnode_find_attr_value_in_children(children : list, name : string, value : string) :
maybe - match children - Nil -> Nothing - Cons(child, rest) -> - match vnode_find_attr_value(child, name, value) - Just(found) -> Just(found) - Nothing -> vnode_find_attr_value_in_children(rest, name, value) - -pub fun vnode_find_text(node : vnode, target_text : string) :
maybe - if vnode/text(node) == target_text then Just(node) - else vnode_find_text_in_children(vnode/children(node), target_text) - -fun vnode_find_text_in_children(children : list, target_text : string) :
maybe - match children - Nil -> Nothing - Cons(child, rest) -> - match vnode_find_text(child, target_text) - Just(found) -> Just(found) - Nothing -> vnode_find_text_in_children(rest, target_text) - -// Return the value of an attribute by name; "" if not present. -pub fun vnode_get_attr(node : vnode, name : string) : string - find_vnode_attr(vnode/attrs(node), name) - -fun find_vnode_attr(attrs : list, name : string) : string - match attrs - Nil -> "" - Cons(item, rest) -> if attr/name(item) == name then attr/value(item) else find_vnode_attr(rest, name) - -// Return the payload of the first click listener on a node; "" if absent. -pub fun vnode_click_payload(node : vnode) : string - find_listener_payload_by_kind(vnode/listeners(node), Click_event) - -// Return the channel of the first input listener on a node; "" if absent. -pub fun vnode_input_channel(node : vnode) : string - find_listener_payload_by_kind(vnode/listeners(node), Input_event) - -fun find_listener_payload_by_kind(listeners : list, target : dom_event_kind) : string - match listeners - Nil -> "" - Cons(item, rest) -> - match (listener/kind(item), target) - (Click_event, Click_event) -> listener/payload(item) - (Input_event, Input_event) -> listener/payload(item) - (Enter_event, Enter_event) -> listener/payload(item) - _ -> find_listener_payload_by_kind(rest, target) diff --git a/explore/react/inspection.kk b/explore/react/inspection.kk new file mode 100644 index 0000000..258b4ec --- /dev/null +++ b/explore/react/inspection.kk @@ -0,0 +1,136 @@ +module explore/react/inspection + +import explore/react/core +import explore/react/state + +// Read-only VDOM and component-runtime queries belong to tests, devtools, and +// host inspection. Component authoring modules do not import this module. + +pub fun vnode_find_key(node : vnode, target_key : string) :
maybe + if vnode/key(node) == target_key then Just(node) + else vnode_find_key_in_children(vnode/children(node), target_key) + +fun vnode_find_key_in_children(children : list, target : string) :
maybe + match children + Nil -> Nothing + Cons(child, rest) -> + match vnode_find_key(child, target) + Just(found) -> Just(found) + Nothing -> vnode_find_key_in_children(rest, target) + +pub fun vnode_find_attr_value(node : vnode, name : string, value : string) :
maybe + if vnode_get_attr(node, name) == value then Just(node) + else vnode_find_attr_value_in_children(vnode/children(node), name, value) + +fun vnode_find_attr_value_in_children(children : list, name : string, value : string) :
maybe + match children + Nil -> Nothing + Cons(child, rest) -> + match vnode_find_attr_value(child, name, value) + Just(found) -> Just(found) + Nothing -> vnode_find_attr_value_in_children(rest, name, value) + +pub fun vnode_find_text(node : vnode, target_text : string) :
maybe + if vnode/text(node) == target_text then Just(node) + else vnode_find_text_in_children(vnode/children(node), target_text) + +fun vnode_find_text_in_children(children : list, target_text : string) :
maybe + match children + Nil -> Nothing + Cons(child, rest) -> + match vnode_find_text(child, target_text) + Just(found) -> Just(found) + Nothing -> vnode_find_text_in_children(rest, target_text) + +pub fun vnode_get_attr(node : vnode, name : string) : string + find_vnode_attr(vnode/attrs(node), name) + +fun find_vnode_attr(attrs : list, name : string) : string + match attrs + Nil -> "" + Cons(item, rest) -> if attr/name(item) == name then attr/value(item) else find_vnode_attr(rest, name) + +pub fun vnode_click_payload(node : vnode) : string + find_listener_payload_by_kind(vnode/listeners(node), Click_event) + +pub fun vnode_input_channel(node : vnode) : string + find_listener_payload_by_kind(vnode/listeners(node), Input_event) + +fun find_listener_payload_by_kind(listeners : list, target : dom_event_kind) : string + match listeners + Nil -> "" + Cons(item, rest) -> + match (listener/kind(item), target) + (Click_event, Click_event) -> listener/payload(item) + (Input_event, Input_event) -> listener/payload(item) + (Enter_event, Enter_event) -> listener/payload(item) + _ -> find_listener_payload_by_kind(rest, target) + +fun find_click_in_list(items : list>, target_id : string) :
maybe> + match items + Nil -> Nothing + Cons(item, rest) -> if registered_click/id(item) == target_id then Just(item) else find_click_in_list(rest, target_id) + +pub fun find_registered_click(registry : event_registry, target_id : string) :
maybe> + find_click_in_list(event_registry/clicks(registry), target_id) + +fun find_click_by_semantic_in_list(items : list>, semantic : string) :
maybe> + match items + Nil -> Nothing + Cons(item, rest) -> + if registered_click/semantic_name(item) == semantic then Just(item) + else find_click_by_semantic_in_list(rest, semantic) + +pub fun find_registered_click_by_semantic(registry : event_registry, semantic : string) :
maybe> + find_click_by_semantic_in_list(event_registry/clicks(registry), semantic) + +fun find_input_in_list(items : list>, target_id : string) :
maybe> + match items + Nil -> Nothing + Cons(item, rest) -> if registered_input/id(item) == target_id then Just(item) else find_input_in_list(rest, target_id) + +pub fun find_registered_input(registry : event_registry, target_id : string) :
maybe> + find_input_in_list(event_registry/inputs(registry), target_id) + +fun find_input_by_semantic_in_list(items : list>, semantic : string) :
maybe> + match items + Nil -> Nothing + Cons(item, rest) -> + if registered_input/semantic_name(item) == semantic then Just(item) + else find_input_by_semantic_in_list(rest, semantic) + +pub fun find_registered_input_by_semantic(registry : event_registry, semantic : string) :
maybe> + find_input_by_semantic_in_list(event_registry/inputs(registry), semantic) + +pub fun count_registered_clicks(registry : event_registry) :
int + event_registry/clicks(registry).length + +pub fun count_registered_inputs(registry : event_registry) :
int + event_registry/inputs(registry).length + +fun semantic_drift_warning(handler_id : string, previous_semantic : string, next_semantic : string) : string + "Listener semantic drift for `" ++ handler_id ++ "`: `" ++ previous_semantic ++ "` -> `" ++ next_semantic ++ "`. Consider using a new named listener or splitting the conditional branch." + +fun compare_click_semantics(previous : list>, next : list>) :
list + match next + Nil -> Nil + Cons(item, rest) -> + val tail = compare_click_semantics(previous, rest) + match find_click_in_list(previous, registered_click/id(item)) + Just(found) -> if registered_click/semantic_name(found) == registered_click/semantic_name(item) then tail else Cons(semantic_drift_warning(registered_click/id(item), registered_click/semantic_name(found), registered_click/semantic_name(item)), tail) + Nothing -> tail + +fun compare_input_semantics(previous : list>, next : list>) :
list + match next + Nil -> Nil + Cons(item, rest) -> + val tail = compare_input_semantics(previous, rest) + match find_input_in_list(previous, registered_input/id(item)) + Just(found) -> if registered_input/semantic_name(found) == registered_input/semantic_name(item) then tail else Cons(semantic_drift_warning(registered_input/id(item), registered_input/semantic_name(found), registered_input/semantic_name(item)), tail) + Nothing -> tail + +pub fun event_registry_semantic_warnings(previous : event_registry, next : event_registry) :
list + compare_click_semantics(event_registry/clicks(previous), event_registry/clicks(next)) ++ compare_input_semantics(event_registry/inputs(previous), event_registry/inputs(next)) + +pub fun count_entries(tree : list) :
int + tree.length diff --git a/explore/react/runtime.kk b/explore/react/runtime.kk new file mode 100644 index 0000000..23cd15a --- /dev/null +++ b/explore/react/runtime.kk @@ -0,0 +1,71 @@ +module explore/react/runtime + +import explore/react/state + +// Host execution and snapshot transport stay outside the component-authoring +// import path. The runtime works with opaque state entries and registered typed +// callbacks; business views never call these functions. + +pub fun run_registered_click(item : registered_click, owner : s) : s + item.run.(owner) + +pub fun run_registered_input(item : registered_input, value : string, owner : s) : s + item.run.(value, owner) + +pub fun empty_event_registry() : event_registry + Event_registry(Nil, Nil, Nil) + +pub fun run_scheduled_effect(item : scheduled_effect) : io () + item.run.() + +inline extern runtime_snapshot_escape(text : string) : string + js inline "encodeURIComponent(#1)" + +inline extern runtime_snapshot_unescape(text : string) : string + js inline "((s) => { try { return decodeURIComponent(s); } catch (_) { return ''; } })(#1)" + +inline extern runtime_snapshot_unescape_valid(text : string) : bool + js inline "((s) => { try { decodeURIComponent(s); return true; } catch (_) { return false; } })(#1)" + +fun encode_snapshot_entry(entry : state_entry) : string + runtime_snapshot_escape(entry.path) ++ "|" ++ + runtime_snapshot_escape(entry.schema) ++ "|" ++ + entry.version.show ++ "|" ++ + runtime_snapshot_escape(entry.payload) + +fun append_snapshot_line(line : string, rest : string) : string + if rest == "" then line else line ++ "\n" ++ rest + +// 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 + match tree + Nil -> "" + Cons(entry, rest) -> + val encoded_rest = encode_state_snapshot(rest) + if entry.schema == "respo/effect" then encoded_rest + else append_snapshot_line(encode_snapshot_entry(entry), encoded_rest) + +fun decode_snapshot_entry(line : string) : maybe + match split(line, "|") + [encoded_path, encoded_schema, version, encoded_payload] -> { + if not(runtime_snapshot_unescape_valid(encoded_path) && runtime_snapshot_unescape_valid(encoded_schema) && runtime_snapshot_unescape_valid(encoded_payload)) then Nothing + else { + val path = runtime_snapshot_unescape(encoded_path) + val schema = runtime_snapshot_unescape(encoded_schema) + if path == "" || schema == "" then Nothing + else parse-int(version).map(fn(value) State_entry(path, schema, value, runtime_snapshot_unescape(encoded_payload))) + } + } + _ -> Nothing + +fun decode_snapshot_lines(lines : list) : list + match lines + Nil -> Nil + Cons(line, rest) -> + match decode_snapshot_entry(line) + Just(entry) -> Cons(entry, decode_snapshot_lines(rest)) + Nothing -> decode_snapshot_lines(rest) + +pub fun decode_state_snapshot(snapshot : string) : list + if snapshot == "" then Nil else decode_snapshot_lines(split(snapshot, "\n")) diff --git a/explore/react/state.kk b/explore/react/state.kk index e84b8e2..4d6de62 100644 --- a/explore/react/state.kk +++ b/explore/react/state.kk @@ -182,15 +182,6 @@ pub val bool/state_codec : state_codec = State_codec( }, encode = fn(value) if value then "true" else "false") -pub fun run_registered_click(item : registered_click, owner : s) : s - item.run.(owner) - -pub fun run_registered_input(item : registered_input, value : string, owner : s) : s - item.run.(value, owner) - -pub fun empty_event_registry() : event_registry - Event_registry(Nil, Nil, Nil) - pub fun with_component_runtime(tree : list, action : () -> a) : e (a, list) var next_tree := tree with return(result) (result, next_tree) @@ -199,9 +190,6 @@ pub fun with_component_runtime(tree : list, action : () -> (items : list>, item : registered_click) :
list> match items Nil -> [item] @@ -212,87 +200,16 @@ fun append_registered_input(items : list>, item : Nil -> [item] Cons(head, rest) -> Cons(head, append_registered_input(rest, item)) -fun append_messages(left : list, right : list) :
list - match left - Nil -> right - Cons(head, rest) -> Cons(head, append_messages(rest, right)) - -pub fun find_registered_click(registry : event_registry, target_id : string) :
maybe> - find_click_in_list(event_registry/clicks(registry), target_id) - fun find_click_in_list(items : list>, target_id : string) :
maybe> match items Nil -> Nothing Cons(item, rest) -> if registered_click/id(item) == target_id then Just(item) else find_click_in_list(rest, target_id) -// Find the first registered click whose semantic name matches. -// Useful in tests that know the handler name but not the generated slot path. -pub fun find_registered_click_by_semantic(registry : event_registry, semantic : string) :
maybe> - find_click_by_semantic_in_list(event_registry/clicks(registry), semantic) - -fun find_click_by_semantic_in_list(items : list>, semantic : string) :
maybe> - match items - Nil -> Nothing - Cons(item, rest) -> - if registered_click/semantic_name(item) == semantic then Just(item) - else find_click_by_semantic_in_list(rest, semantic) - -pub fun find_registered_input(registry : event_registry, target_id : string) :
maybe> - find_input_in_list(event_registry/inputs(registry), target_id) - fun find_input_in_list(items : list>, target_id : string) :
maybe> match items Nil -> Nothing Cons(item, rest) -> if registered_input/id(item) == target_id then Just(item) else find_input_in_list(rest, target_id) -// Find the first registered input whose semantic name matches. -pub fun find_registered_input_by_semantic(registry : event_registry, semantic : string) :
maybe> - find_input_by_semantic_in_list(event_registry/inputs(registry), semantic) - -fun find_input_by_semantic_in_list(items : list>, semantic : string) :
maybe> - match items - Nil -> Nothing - Cons(item, rest) -> - if registered_input/semantic_name(item) == semantic then Just(item) - else find_input_by_semantic_in_list(rest, semantic) - -pub fun count_registered_clicks(registry : event_registry) :
int - count_click_items(event_registry/clicks(registry)) - -fun count_click_items(items : list>) :
int - match items - Nil -> 0 - Cons(_, rest) -> 1 + count_click_items(rest) - -pub fun count_registered_inputs(registry : event_registry) :
int - count_input_items(event_registry/inputs(registry)) - -fun count_input_items(items : list>) :
int - match items - Nil -> 0 - Cons(_, rest) -> 1 + count_input_items(rest) - -fun compare_click_semantics(previous : list>, next : list>) :
list - match next - Nil -> Nil - Cons(item, rest) -> - val tail = compare_click_semantics(previous, rest) - match find_click_in_list(previous, registered_click/id(item)) - Just(found) -> if registered_click/semantic_name(found) == registered_click/semantic_name(item) then tail else Cons(semantic_drift_warning(registered_click/id(item), registered_click/semantic_name(found), registered_click/semantic_name(item)), tail) - Nothing -> tail - -fun compare_input_semantics(previous : list>, next : list>) :
list - match next - Nil -> Nil - Cons(item, rest) -> - val tail = compare_input_semantics(previous, rest) - match find_input_in_list(previous, registered_input/id(item)) - Just(found) -> if registered_input/semantic_name(found) == registered_input/semantic_name(item) then tail else Cons(semantic_drift_warning(registered_input/id(item), registered_input/semantic_name(found), registered_input/semantic_name(item)), tail) - Nothing -> tail - -pub fun event_registry_semantic_warnings(previous : event_registry, next : event_registry) :
list - append_messages(compare_click_semantics(event_registry/clicks(previous), event_registry/clicks(next)), compare_input_semantics(event_registry/inputs(previous), event_registry/inputs(next))) - fun state_value(tree : list, entry_slot : state_slot, fallback : a, ?state_codec : state_codec) :
a match find_state(tree, state_slot/path(entry_slot)) Just(item) -> @@ -811,54 +728,6 @@ fun effect_signature(deps : list) :
string fun effect_entry(slot_item : state_slot, deps : list) :
state_entry State_entry(state_slot/path(slot_item), "respo/effect", 1, effect_signature(deps)) -pub fun count_entries(tree : list) :
int - match tree - Nil -> 0 - Cons(_, rest) -> 1 + count_entries(rest) - -fun encode_snapshot_entry(entry : state_entry) : string - snapshot_escape(entry.path) ++ "|" ++ - snapshot_escape(entry.schema) ++ "|" ++ - entry.version.show ++ "|" ++ - snapshot_escape(entry.payload) - -fun append_snapshot_line(line : string, rest : string) : string - if rest == "" then line else line ++ "\n" ++ rest - -// 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 - match tree - Nil -> "" - Cons(entry, rest) -> - val encoded_rest = encode_state_snapshot(rest) - if entry.schema == "respo/effect" then encoded_rest - else append_snapshot_line(encode_snapshot_entry(entry), encoded_rest) - -fun decode_snapshot_entry(line : string) : maybe - match split(line, "|") - [encoded_path, encoded_schema, version, encoded_payload] -> { - if not(snapshot_unescape_valid(encoded_path) && snapshot_unescape_valid(encoded_schema) && snapshot_unescape_valid(encoded_payload)) then Nothing - else { - val path = snapshot_unescape(encoded_path) - val schema = snapshot_unescape(encoded_schema) - if path == "" || schema == "" then Nothing - else parse-int(version).map(fn(value) State_entry(path, schema, value, snapshot_unescape(encoded_payload))) - } - } - _ -> Nothing - -fun decode_snapshot_lines(lines : list) : list - match lines - Nil -> Nil - Cons(line, rest) -> - match decode_snapshot_entry(line) - Just(entry) -> Cons(entry, decode_snapshot_lines(rest)) - Nothing -> decode_snapshot_lines(rest) - -pub fun decode_state_snapshot(snapshot : string) : list - if snapshot == "" then Nil else decode_snapshot_lines(split(snapshot, "\n")) - fun require_hook_scope(scope_name : string, kind : string) : string scope_name