From c9fe5ef16bac6d216669eea8faea5df6ba11339c Mon Sep 17 00:00:00 2001 From: tiye Date: Sun, 30 Aug 2026 19:32:39 +0800 Subject: [PATCH] feat: simplify domain and store transitions --- Agents.md | 18 ++++--- PLAN.md | 12 +++-- README.md | 30 ++++++++--- demo/lab/view.kk | 6 +-- demo/tests.kk | 1 + demo/tests/statecases.kk | 48 ++++++++++++++++++ demo/todo/view.kk | 18 ++----- docs/action-store-transitions.md | 85 ++++++++++++++++++++++++++++++++ explore/react/state.kk | 17 +++++++ 9 files changed, 195 insertions(+), 40 deletions(-) create mode 100644 docs/action-store-transitions.md diff --git a/Agents.md b/Agents.md index b70b170..3e008f2 100644 --- a/Agents.md +++ b/Agents.md @@ -151,7 +151,8 @@ chrome-devtools take_screenshot --fullPage --filePath .tmp-devtools-full.png - 业务组件用 `(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。 -- 只有包含额外分支、组合更新或直接 model 输入的 handler 才使用 `on_local_*`。 +- 同一事件固定按顺序发送一个完整 domain action、再按条件发送一个 component-store action 时,使用 `action_store_transition(...)` 构造 handler,并交给现有 `on_local_click(...)` / `on_local_enter(...)`;不要为每种 DOM event 增加 transition helper。 +- `action_store_transition(...)` 总是先发送 domain action;`store_when` 只控制后续 component action。包含多步更新、复杂分支或直接 model 输入时才手写 `on_local_*` handler。 - 一个 store 把 state 类型、action 类型、纯 reducer、action codec 和显式 recovery policy 定义在一起;codec 只在 store 定义处出现,不传进组件调用。 - store 统一通过 labelled `snapshot_store(...)` / `replay_store(...)` 构造,不让业务模块直接依赖 `Store_spec(...)` 的内部字段顺序。`State_codec(...)`、`Action_codec(...)` 仍显式写出 `schema/version/decode/encode`。 - `snapshot_store(...)` 适合累积、toggle 或无法安全压缩的状态;`replay_store(...)` 只适合有明确 session 边界的纯 component actions,并使用 `Replay_start` / `Replay_replace(slot)` / `Replay_reset` 声明恢复语义。 @@ -166,7 +167,7 @@ chrome-devtools take_screenshot --fullPage --filePath .tmp-devtools-full.png - `feature_node_key(...)` / `feature_dom_marker(...)` 只保留给 framework/runtime inspection 与 tests,不进入业务 view。 - 整棵 app tree 只在集成层调用一次 `run_component(owner, group = ..., key = ..., render = fn(owner) ...)`,不要让 layout 手工合并 effects/registries。 - 高层 view component 保持一个主要 domain value 位置参数,其余 callback/config props 使用 labelled arguments,例如 `on_input = ...`、`on_submit = ...`、`on_select = ...`。 -- domain reducer/workflow 不读取或写入 child component store;需要 draft 等当前值时,把值放进 serializable domain action,再由组件在同一个 `on_local_*` handler 内协调自身 store transition。 +- domain reducer/workflow 不读取或写入 child component store;需要 draft 等当前值时,把值放进 serializable domain action,再由组件用 `action_store_transition(...)` 协调一个简单 store transition,或在复杂情况下使用 `on_local_*`。 - action 必须可序列化,方便事件日志、恢复、回放,以及后续 agents/actions/store 工具链。 推荐形态: @@ -183,17 +184,20 @@ input_text( action = Change_draft, dispatch = dispatch_editor)) -val save_edit = fn(owner : model) { - val next = dispatch(Save_task(draft), owner) - if draft == "" then () else dispatch_editor(Finish_edit) - next -} +val save_edit = action_store_transition( + Save_task(draft), + dispatch = dispatch, + store_action = Finish_edit, + store = dispatch_editor, + store_when = draft != "") button( "Save", click = on_local_click("save-edit", save_edit)) ``` +完整选择规则和 action observation 顺序见 `docs/action-store-transitions.md`。 + `state(...)` / `state_pair(...)` 可以用于没有业务 action 语义的简单实验,但新业务组件只要状态由用户事件更新,就优先定义 typed store。不要继续扩散显式 codec、hook index 或手工 path 的调用形式。 ## Action observation 约定 diff --git a/PLAN.md b/PLAN.md index 2ae12b4..3e24288 100644 --- a/PLAN.md +++ b/PLAN.md @@ -63,12 +63,13 @@ ### Typed domain-action listeners - `on_action_click(...)` / `on_action_input(...)` / `on_action_enter(...)` 直接连接 typed action 与 feature dispatch; +- `action_store_transition(...)` 表达“一个 domain action,然后按条件发送一个 component-store action”,并由 click/Enter 复用同一个 handler; - action、dispatch 使用 labelled arguments,在 element 调用点保持可读; - Todo、Lab、Route 已移除仅用于 `dispatch(action, owner)` 的一次性 closure; - Search 的 `on_input` / `on_submit` / `on_select` 与 Bridge 的 `on_select` callback props 已进入 typed registry,不再由 view 制造 raw listener payload; - Search item 与 Bridge case 使用稳定业务 id,过滤或重排不会改变同一交互的 registry identity; -- `on_local_*` 只保留给直接 model 更新、分支逻辑或尚未 action 化的组件流程; -- 同一事件需要同时发送 domain intent 和更新自身 store 时使用 `on_local_*`;Todo `Save_task(title)` 与 Lab `Send_reply(id, message)` 已采用这种完整 action; +- Todo 的开始/保存/取消编辑与 Lab 的发送回复已共享同一个 event-independent transition abstraction;domain action 保持完整,store action 顺序可观察; +- `on_local_*` 只保留给直接 model 更新、多步更新、复杂分支或尚未 action 化的组件流程; - Todo、Lab、Route 迁移保留原 semantic name/path;Search、Bridge 则有意从 legacy raw payload 收敛到稳定 registry path。 ### Runtime ownership 与恢复 @@ -122,6 +123,7 @@ on_store_input(name, action = ..., dispatch = ...) on_action_click(name, action = ..., dispatch = ...) on_action_input(name, action = ..., dispatch = ...) on_action_enter(name, action = ..., dispatch = ...) +action_store_transition(action, dispatch = ..., store_action = ..., store = ..., store_when = ...) on_local_click(name, handler) on_local_input(name, handler) on_local_enter(name, handler) @@ -196,10 +198,10 @@ feature_dom_marker(group = ..., key = ..., name = ...) issue、PR 及影响结论的进度更新统一使用中英双语:标题采用 `中文 / English`,正文分别写成完整的 `# 中文` 与 `# English` 章节,避免逐行混排,确保两部分都能独立用于跟踪。 - [#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):当前实现批次;以 Todo editor 的定义成本与恢复体验作为是否推广的标准; +- [#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):等待 #8 明确 session 完成语义后再评估公共 abstraction; -- [#13 发布渐进式组件作者 API / Publish a progressive-disclosure component authoring surface](https://github.com/Respo/explore-react.koka/issues/13):在 recovery 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):当前实现批次;以四个真实调用点、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 边界。 ## 验证标准 diff --git a/README.md b/README.md index 9b62336..901ee7b 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ JavaScript hot replacement or be restored from `localStorage`. | local reducer | `(state, dispatch) = use_store(spec, initial=...)` | | 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=...)` | | Context-like value | a Koka `val` effect | | browser/service capability | a Koka `fun` effect | @@ -301,18 +302,28 @@ value into the serializable action. The domain workflow never looks the child store up by scope: ```koka -val save_edit = fn(owner : model) { - val next = dispatch(Save_task(draft), owner) - if draft == "" then () else dispatch_editor(Finish_edit) - next -} +val save_edit = action_store_transition( + Save_task(draft), + dispatch = dispatch, + store_action = Finish_edit, + store = dispatch_editor, + store_when = draft != "") button("Save", click = on_local_click("save-edit", save_edit)) ``` -This is the intended use of `on_local_*`: one event coordinates a domain intent -and its own component-store transition. The domain action remains complete -enough for inspection, persistence, or a future agent to submit directly. +`action_store_transition(...)` is intentionally independent of the DOM event, +so the same transition can be registered for both click and Enter. It always +sends the complete domain action first. `store_when` controls only whether the +single component-store action follows; it never suppresses the domain intent. +The domain action remains complete enough for inspection, persistence, or a +future agent to submit directly. + +Use this builder only for the repeated one-domain-action/one-store-action shape. +Keep `on_local_*` for direct model updates, multiple local actions, or branching +that cannot be stated as one `store_when` condition. See +[`docs/action-store-transitions.md`](docs/action-store-transitions.md) for the +decision guide, ordering contract, and complete examples. ## Listener identity and event dispatch @@ -323,6 +334,7 @@ kind and a semantic name, for example: on_action_click("set-done", action = Set_filter("done"), dispatch = dispatch) on_action_input("change-query", action = Change_search_query, dispatch = dispatch) on_action_enter("add-task", action = Add_task, dispatch = dispatch) +action_store_transition(Save_task(draft), dispatch = dispatch, store_action = Finish_edit, store = dispatch_editor, store_when = draft != "") on_local_input("draft-input", fn(value, owner) ...) ``` @@ -346,6 +358,8 @@ Store listeners are intentionally a narrower convenience layer: - `on_store_input(...)` converts the input string into one typed local action; - `on_action_*` dispatches typed domain actions and can still expose reducer effects; +- `action_store_transition(...)` builds one event-independent handler that + sends a domain action and then optionally one component-store action; - `on_local_*` remains available when an event needs custom component logic. ## Serializable action observation diff --git a/demo/lab/view.kk b/demo/lab/view.kk index 988192d..813dbd8 100644 --- a/demo/lab/view.kk +++ b/demo/lab/view.kk @@ -26,11 +26,7 @@ fun incident_card(item : incident) : app_view vnode val iid = incident/id(item) val (Incident_local_state(expanded, draft), dispatch_local) = use_store(incident_local_store, initial = Incident_local_state(False, "")) val dispatch = fn(action, owner) dispatch_lab_action(action, owner) - val send_reply = fn(owner : model) { - val next = dispatch(Send_reply(iid, draft), owner) - if draft == "" then () else dispatch_local(Finish_incident_reply) - next - } + val send_reply = action_store_transition(Send_reply(iid, draft), dispatch = dispatch, store_action = Finish_incident_reply, store = dispatch_local, store_when = draft != "") val toggle_label = if expanded then "Collapse" else "Expand" val preview_text = if draft == "" then "local draft: " else "local draft: " ++ draft val collapsed_text = if draft == "" then "Draft is local and still empty." else "Draft saved locally for this card." diff --git a/demo/tests.kk b/demo/tests.kk index 09a9436..dc2a0ef 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(), + action_store_transition_test(), malformed_store_payload_test(), auto_hook_scope_test(), listener_registry_guard_test(), diff --git a/demo/tests/statecases.kk b/demo/tests/statecases.kk index 58883fa..beb7213 100644 --- a/demo/tests/statecases.kk +++ b/demo/tests/statecases.kk @@ -170,6 +170,54 @@ 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 action_store_transition_test() :
test_result + val scope_name = component_local_path("tests", "action-store-transition") + val transition_codec : action_codec = Action_codec( + schema = "tests/transition-action", + version = 1, + decode = fn(version, payload) if version == 1 then Just(payload) else Nothing, + encode = fn(value : string) value) + val transition_store : store_spec = replay_store( + name = "transition", + action_codec = transition_codec, + replay = fn(_action) Replay_start, + reduce = fn(_current, action) action) + val dispatch_domain = fn(action : string, owner : string) { + emit_action( + source = "domain", + target = "tests/transition", + codec = transition_codec, + action = action) + owner ++ action + } + val (run, observed) = capture_actions(fn() run_local_state(Nil, fn() feature_root("tests", "action-store-transition") { + val (_current, dispatch_store) = use_store(transition_store, initial = "idle") + val complete = action_store_transition( + "save", + dispatch = dispatch_domain, + store_action = "finish", + store = dispatch_store) + val rejected = action_store_transition( + "invalid", + dispatch = dispatch_domain, + store_action = "ignored", + store = dispatch_store, + store_when = False) + rejected(complete("owner:")) + })) + val (next_owner, tree, _effects) = run + val next_store = read_store_state(tree, scope_name, transition_store, "idle") + val ordered = match observed + [domain_save, component_finish, domain_invalid] -> + domain_save.source == "domain" && domain_save.payload == "save" && + component_finish.source == "component" && component_finish.payload == "finish" && + domain_invalid.source == "domain" && domain_invalid.payload == "invalid" + _ -> False + Test_result( + "Domain/store transition preserves typed action order", + "owner=" ++ next_owner ++ ", store=" ++ next_store ++ ", actions=" ++ observed.length.show, + next_owner == "owner:saveinvalid" && next_store == "finish" && ordered) + pub fun malformed_store_payload_test() :
test_result val todo_scope = "tests/malformed-todo" val todo_initial = Task_editor_state(False, "todo fallback") diff --git a/demo/todo/view.kk b/demo/todo/view.kk index bd14fb4..932b08f 100644 --- a/demo/todo/view.kk +++ b/demo/todo/view.kk @@ -14,21 +14,9 @@ fun task_item_view(item : task) : app_view vnode val draft_marker = feature_marker("task-draft-" ++ tid.show) val (Task_editor_state(editing, draft), dispatch_editor) = use_store(task_editor_store, initial = Task_editor_state(False, item.title)) val dispatch = fn(action, owner) dispatch_task_item_action(action, item, owner) - val start_edit = fn(owner : model) { - val next = dispatch(Edit_task, owner) - dispatch_editor(Begin_edit(item.title)) - next - } - val save_edit = fn(owner : model) { - val next = dispatch(Save_task(draft), owner) - if draft == "" then () else dispatch_editor(Finish_edit) - next - } - val cancel_edit = fn(owner : model) { - val next = dispatch(Cancel_task, owner) - dispatch_editor(Cancel_edit(item.title)) - next - } + val start_edit = action_store_transition(Edit_task, dispatch = dispatch, store_action = Begin_edit(item.title), store = dispatch_editor) + val save_edit = action_store_transition(Save_task(draft), dispatch = dispatch, store_action = Finish_edit, store = dispatch_editor, store_when = draft != "") + val cancel_edit = action_store_transition(Cancel_task, dispatch = dispatch, store_action = Cancel_edit(item.title), store = dispatch_editor) state_effect(name = "focus-editor", deps = [tid.show, if editing then "editing" else "idle"], action = fn() { if editing then { dom_focus(draft_marker) diff --git a/docs/action-store-transitions.md b/docs/action-store-transitions.md new file mode 100644 index 0000000..38495d1 --- /dev/null +++ b/docs/action-store-transitions.md @@ -0,0 +1,85 @@ +# Domain 与 component-store transitions + +# 中文 + +组件事件通常只需要发送一种 action:domain intent 使用 `on_action_*`,component-local update 使用 `on_store_*`。只有当一次用户操作必须同时提交完整的业务 intent,并结束或推进当前组件 session 时,才需要组合两者。 + +## 推荐写法 + +`action_store_transition(...)` 构造一个与 DOM event 无关的 typed handler: + +```koka +val save_edit = action_store_transition( + Save_task(draft), + dispatch = dispatch, + store_action = Finish_edit, + store = dispatch_editor, + store_when = draft != "") + +input_text(draft, enter = on_local_enter("save-edit", save_edit)) +button("Save", click = on_local_click("save-edit", save_edit)) +``` + +domain action 保留组件当前值,因此 reducer、日志、恢复工具或未来 agent 不需要读取 child store。click 与 Enter 复用同一个 transition,不需要 `on_action_and_store_click`、`on_action_and_store_enter` 之类的 helper 家族。 + +## 顺序契约 + +1. `dispatch(action, owner)` 总是先运行,并发出 domain action observation。 +2. `store_when` 为 `True` 时,`store(store_action)` 随后运行,并发出 component action observation。 +3. `store_when` 为 `False` 时,只跳过 component action;domain intent 仍然发送。 +4. 返回值始终是 domain dispatch 产生的新 owner;component store 不会被 domain reducer 读取或修改。 + +observation 表示 intent 已发送,并不承诺 reducer 或外部 effect 成功。只有当组件能够在 render 时明确判断是否应该推进本地 session,才使用 `store_when`。 + +## 选择规则 + +| 事件形态 | API | +| --- | --- | +| 只发送 domain action | `on_action_click/input/enter(...)` | +| 只发送 component-store action | `on_store_click/input(...)` | +| 一个 domain action,随后至多一个 store action | `action_store_transition(...)` + `on_local_click/enter(...)` | +| 多个 store actions、依赖 effect response、复杂分支或直接修改 model | 手写 `on_local_*` handler | + +不要把 confirm、request、timer 或 service response 隐藏进 transition。需要这些能力时,让 domain workflow 显式拥有 effect,并根据明确的业务结果设计后续状态。 + +# English + +Most component events send only one kind of action: use `on_action_*` for a domain intent and `on_store_*` for a component-local update. Combine them only when one user operation must submit a complete business intent and then finish or advance the current component session. + +## Preferred form + +`action_store_transition(...)` builds a typed handler that is independent of the DOM event: + +```koka +val save_edit = action_store_transition( + Save_task(draft), + dispatch = dispatch, + store_action = Finish_edit, + store = dispatch_editor, + store_when = draft != "") + +input_text(draft, enter = on_local_enter("save-edit", save_edit)) +button("Save", click = on_local_click("save-edit", save_edit)) +``` + +The domain action carries the current component value, so reducers, logs, recovery tools, and future agents never need to read the child store. Click and Enter reuse the same transition; the framework does not grow an `on_action_and_store_click` / `on_action_and_store_enter` helper family. + +## Ordering contract + +1. `dispatch(action, owner)` always runs first and emits the domain action observation. +2. When `store_when` is `True`, `store(store_action)` runs next and emits the component action observation. +3. When `store_when` is `False`, only the component action is skipped; the domain intent is still sent. +4. The returned value is always the new owner produced by the domain dispatch. The domain reducer never reads or mutates the component store. + +An observation means that an intent was sent; it does not promise that a reducer or external effect succeeded. Use `store_when` only when the component can decide at render time whether its local session should advance. + +## Decision guide + +| Event shape | API | +| --- | --- | +| Domain action only | `on_action_click/input/enter(...)` | +| Component-store action only | `on_store_click/input(...)` | +| One domain action followed by at most one store action | `action_store_transition(...)` + `on_local_click/enter(...)` | +| Multiple store actions, an effect response, complex branching, or direct model mutation | A handwritten `on_local_*` handler | + +Do not hide confirm, request, timer, or service responses inside this transition. Keep those capabilities explicit in the domain workflow and design later state changes around an explicit business result. diff --git a/explore/react/state.kk b/explore/react/state.kk index 43da476..e84b8e2 100644 --- a/explore/react/state.kk +++ b/explore/react/state.kk @@ -557,6 +557,23 @@ pub fun on_action_enter(name : string, action : a, dispatch : (a, s) - pub fun on_action_input(name : string, action : (string) -> a, dispatch : (a, s) -> s) : ,div> listener on_local_input(name, fn(value, owner) dispatch(action(value), owner)) +// Build one local listener handler that sends a complete domain intent first, +// then optionally advances the component store. Keeping this independent of a +// DOM event lets click and Enter reuse the same transition without growing an +// event-by-target helper family. +pub fun action_store_transition( + action : a, + dispatch : (a, s) -> s, + store_action : b, + store : store_dispatch, + store_when : bool = True +) : ((s) -> s) + fn(owner) { + val next = dispatch(action, owner) + if store_when then store(store_action) else () + next + } + // Common reducer-backed controls only emit a typed component action and leave // the owner unchanged. These helpers keep that event plumbing out of views. pub fun on_store_click(name : string, action : a, dispatch : store_dispatch) : >,div> listener