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
18 changes: 11 additions & 7 deletions Agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 声明恢复语义。
Expand All @@ -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 工具链。

推荐形态:
Expand All @@ -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 约定
Expand Down
12 changes: 7 additions & 5 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 与恢复
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 边界。

## 验证标准

Expand Down
30 changes: 22 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand All @@ -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) ...)
```

Expand All @@ -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
Expand Down
6 changes: 1 addition & 5 deletions demo/lab/view.kk
Original file line number Diff line number Diff line change
Expand Up @@ -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: <empty>" else "local draft: " ++ draft
val collapsed_text = if draft == "" then "Draft is local and still empty." else "Draft saved locally for this card."
Expand Down
1 change: 1 addition & 0 deletions demo/tests.kk
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub fun demo_test_results() : <div> list<test_result>
persistent_feature_lifecycle_test(),
generic_state_codec_test(),
replay_empty_payload_test(),
action_store_transition_test(),
malformed_store_payload_test(),
auto_hook_scope_test(),
listener_registry_guard_test(),
Expand Down
48 changes: 48 additions & 0 deletions demo/tests/statecases.kk
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,54 @@ pub fun replay_empty_payload_test() : <div> test_result
Nothing -> False
Test_result("Replay framing preserves empty action payload", "restored='" ++ restored ++ "', framed=" ++ framed.show, restored == "" && framed)

pub fun action_store_transition_test() : <div> test_result
val scope_name = component_local_path("tests", "action-store-transition")
val transition_codec : action_codec<string> = Action_codec(
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<string,string> = 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() : <div> test_result
val todo_scope = "tests/malformed-todo"
val todo_initial = Task_editor_state(False, "todo fallback")
Expand Down
18 changes: 3 additions & 15 deletions demo/todo/view.kk
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading