diff --git a/docs/js-runtime.html b/docs/js-runtime.html
index 2d29f57..d6256dd 100644
--- a/docs/js-runtime.html
+++ b/docs/js-runtime.html
@@ -152,7 +152,7 @@
| owner | mount 建根;Show 每次切分支建一个;For 每建一行建一个 | 根 owner 被运行时持有;分支 owner 被 Show 的 cleanup 持有;行 owner 被 For 持有 | dispose:跑名下 effect 的解绑与 cleanups。分支切换、行删除、卸载 |
| effect | h() 遇到 thunk prop;effect();For / Show 自己 | 创建时的当前 owner | owner dispose 时;或它所属的 effect 重跑前(effect 内 onCleanup) |
-| signal | signal()、memo()、createResource()、For 的 item accessor | 不挂任何名下,靠引用存活 | 没人引用时被 GC。订阅它的 effect 被解绑后,signal 若无其他引用即回收 |
+| signal | signal()、memo()、resource()、For 的 item accessor | 不挂任何名下,靠引用存活 | 没人引用时被 GC。订阅它的 effect 被解绑后,signal 若无其他引用即回收 |
| 节点 id | h() 对内置类型分配 | 不是对象,只是整数;Kotlin 侧才有节点 | 不回收。r 之后不再出现 |
| 槽位表 | h() 处理 children 时 | 内置节点 | 随节点 r |
| handler | h() 遇到 onXxx | 通过 onCleanup 登记到当前 owner | owner dispose |
@@ -241,13 +241,13 @@ 3. 响应式闭环
4. 一个页面的一生
页面:拉一个待办列表,加载中显示占位,每行点击切换完成态。
// pages/todos.tsx
-import { signal, createResource, Column, Row, Text, Show, For } from "@tiny-ui/core";
+import { signal, resource, Column, Row, Text, Show, For } from "@tiny-ui/core";
import { http } from "@tiny-ui/native";
interface Todo { id: number; title: string; done: boolean }
export default function Todos() {
- const [todos] = createResource(() => http.get<Todo[]>("/todos"));
+ const [todos] = resource(() => http.get<Todo[]>("/todos"));
return (
<Column>
<Text text={`${(todos() ?? []).length} todos`} />
@@ -276,7 +276,7 @@ 4. 一个页面的一生
4.1 编译
CLI 只做两件事:含调用 / 属性访问的属性表达式包成 thunk,JSX 变成 h()。事件、key、fallback、渲染函数是函数,原样保留。
function Todos() {
- const [todos] = createResource(() => http.get("/todos"));
+ const [todos] = resource(() => http.get("/todos"));
return h(Column, null,
h(Text, { text: thunk(() => `${(todos() ?? []).length} todos`) }),
h(Show, { when: thunk(() => todos()), fallback: () => h(Text, { text: "loading…" }) },
@@ -336,14 +336,14 @@ 4.2 挂载(事务 1)与数据回来(事务 2)
| 步骤 | 运行时里发生的 | patch |
-createResource | 建 data / loading / error 三个 signal;分配 cbId 1 存进 pending 表;__host_call("http.get", 1, …) | |
+resource | 建 data / loading / error 三个 signal;分配 cbId 1 存进 pending 表;__host_call("http.get", 1, …) | |
h(Text, {text: thunk}) | 分配 id 1;建 effect E1,首跑读 data(undefined)→ 订阅 | ["c",1,"Text"] ["p",1,"text","0 todos"] |
h(Show, …) | 建 effect E2,首跑读 data → 假 → 建分支 owner O1,在其中调 fallback:id 2 | ["c",2,"Text"] ["p",2,"text","loading…"] |
h(Column, null, 节点1, Show槽位) | 分配 id 3;槽位表 [节点1, Show(O1: [2])] | ["i",3,1,0] ["i",3,2,1] |
mount 收尾 | 根节点 3 挂到根容器 | ["i",0,3,0] |
mount() 返回,Kotlin 调 flush():pending 队列为空,缓冲里 8 条 op 一次交给 J1。
-Kotlin 拿到响应,resolve(1, "[{id:1,title:'Buy milk',done:false},{id:2,title:'Call mom',done:true}]")。运行时从 pending 表取出 resolver 调用,返回;quickjs-kmp 排空微任务:createResource 的 .then 写 data,E1、E2 入队。Kotlin 调 flush():
+Kotlin 拿到响应,resolve(1, "[{id:1,title:'Buy milk',done:false},{id:2,title:'Call mom',done:true}]")。运行时从 pending 表取出 resolver 调用,返回;quickjs-kmp 排空微任务:resource 的 .then 写 data,E1、E2 入队。Kotlin 调 flush():
| effect | 做什么 | patch |
@@ -384,7 +384,7 @@ 5. 规则表
children 创建后不可变;结构变化只经 For / Show | 编译期报错 | §1:只有 For / Show 开 owner,别处建的节点没人负责删 |
For / Show 只能直接写在内置节点的 children 里 | h() 抛 E2 | §1:槽位表挂在内置节点上,它们需要一个父节点来算 index |
内置 / 宿主节点的 prop 不能是函数(on* 除外);业务组件的 prop 不限 | h() / bindProp 抛 E2,抓的是 text={fmt} 忘了调用 | ADR-002:函数不过桥,边界就是 patch;业务组件之间是纯 JS 调用 |
-| 数组 / 对象整体替换才触发 | list().push(x) 静默无效 | §3:=== 判等;v1 没有 Proxy 追踪 |
+signal 里的数组 / 对象整体替换才触发;要按字段触发用 observable | list().push(x) 静默无效 | §3:=== 判等;Proxy 追踪只在 store 里(runtime-api.md §2.6) |
For 的 item 是 accessor | 写 todo.title 类型错 | §4.4:同 key 元素换引用时更新行而不重建行 |
Show 只在真值性变化时切分支 | | §4.2:切分支 = 重建整棵分支,值变化不该付这个代价 |
| 同一 flush 内一个 effect 重跑超过 100 次 | 抛 E2 | §3:effect 写自己读的 signal 会成环 |
diff --git a/docs/jsx-transform.md b/docs/jsx-transform.md
index 9f35916..995a8b2 100644
--- a/docs/jsx-transform.md
+++ b/docs/jsx-transform.md
@@ -58,7 +58,7 @@ h(Text, { text: thunk(() => "Count: " + count()), color: thunk(() => theme.prima
| `{...props}` 展开属性 | 展开会立刻求值 getter,丢失响应式 | 显式列出属性 |
| children 里的表达式:`{cond && }`、`{cond ? : }`、`{list.map(...)}`、`{value}` | children 创建后不可变(runtime-api.md §3.1) | `` / ``;文本走 `text` prop |
| 非空白的 JSXText:`hi` | 同上 | `` |
-| `async` 组件函数上的 JSX(`export default async function`) | 组件必须同步 | `createResource` |
+| `async` 组件函数上的 JSX(`export default async function`) | 组件必须同步 | `resource` |
例外:children 里恰好一个箭头 / 函数表达式(`For` / `Show` 的渲染函数)合法,原样传为 `children`。
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 5cbf7a8..a2c4b62 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -28,7 +28,7 @@
| 项 | 来源 | 状态 |
|---|---|---|
-| JS 运行时 API:`signal` / `memo` / `effect` / `onCleanup` / `For` / `Show` / `ref` + `cmd` / **`createResource`**;"组件函数必须同步"规则;J3 / K3 用原生 Promise | ADR-001 / 004 / 005 | 已定(2026-09-16,[js-runtime.html](./js-runtime.html) + [runtime-api.md](./runtime-api.md)) |
+| JS 运行时 API:`signal` / `memo` / `effect` / `onCleanup` / `For` / `Show` / `ref` + `cmd` / **`resource`**(原拟名 `createResource`);"组件函数必须同步"规则;J3 / K3 用原生 Promise | ADR-001 / 004 / 005 | 已定(2026-09-16,[js-runtime.html](./js-runtime.html) + [runtime-api.md](./runtime-api.md)) |
| CLI 的 JSX 变换:自动 thunk(Solid 式 getter 包裹)、source map 输出 | ADR-005 | 已定(2026-09-16,[jsx-transform.md](./jsx-transform.md)) |
| patch 协议:六种 op、JSON 形态、是否带协议版本号 | ADR-001 / 002 / 004 | 已定(2026-09-16,[patch-protocol.md](./patch-protocol.md)) |
| schema DSL 形态 + 由 schema 生成 TS 类型定义的工具链 | ADR-003 | 已定([components.md](./components.md) §1,`tinyui schema`) |
@@ -43,7 +43,7 @@
| 项 | 来源 | 触发条件 |
|---|---|---|
-| `createStore`(Solid 式 Proxy 深层响应式) | ADR-005 | **v1.1 计划内**:M2 列表页跑通后做 |
+| ~~`createStore`(Solid 式 Proxy 深层响应式)~~ | ADR-005 | 已完成(2026-09-17,定名 `observable`,[runtime-api.md](./runtime-api.md) §2.6):直接赋值、属性级订阅、只深代理纯对象与数组;`reconcile` / `produce` 不做,触发条件"服务端全量刷新导致整表重绑成为性能问题" |
| 热下发(页面包分发与版本兼容;引擎侧 Kotlin 回调式 module loader 已在 quickjs-kmp 完成,`JsEngineConfig.moduleLoader`) | ADR-005 | 内核稳定后另立 ADR |
| `qjsc-kmp` 宿主二进制随 quickjs-kmp tag 发 GitHub Release,CLI 按版本下载 | build-chain.md §5 | CI 现编耗时或业务方接入成为问题;属 quickjs-kmp 仓改动 |
| App 级共享模块(业务共享代码进 external 列表与模块表) | build-chain.md §3 | 页面间重复代码让安装包体积成问题 |
diff --git a/docs/runtime-api.md b/docs/runtime-api.md
index 0ed015b..aa99458 100644
--- a/docs/runtime-api.md
+++ b/docs/runtime-api.md
@@ -1,7 +1,7 @@
# JS 运行时 API:`@tiny-ui/core` v1
-- 状态:已定(2026-09-16);M1 的实现依据。系统说明见 [js-runtime.html](./js-runtime.html),本文只放定义
-- 来源:ADR-001(Signal、所有权)、ADR-002(桥入口、事务、错误)、ADR-004(ref + cmd、事件 payload)、ADR-005(组件函数必须同步、`createResource`、原生 Promise)
+- 状态:已定(2026-09-16;2026-09-17 加 §2.6 `observable`、§1 命名规则,`createResource` 改名 `resource`、`host()` 改名 `manifest()`);M1 的实现依据。系统说明见 [js-runtime.html](./js-runtime.html),本文只放定义
+- 来源:ADR-001(Signal、所有权)、ADR-002(桥入口、事务、错误)、ADR-004(ref + cmd、事件 payload)、ADR-005(组件函数必须同步、`resource`、原生 Promise)
- 范围:业务可见的 API、它们的精确语义、页面模块契约、以及运行时与 Kotlin 之间的桥入口(内部契约)。JSX 写法如何变成对这些 API 的调用见 [jsx-transform.md](./jsx-transform.md);产出的 patch 形态见 [patch-protocol.md](./patch-protocol.md)
## 1. 一览
@@ -13,18 +13,24 @@
| | `effect(fn)` | 副作用,依赖变化后在 flush 时重跑 |
| | `onCleanup(fn)` | 登记清理,随最近的 effect 或结构作用域执行 |
| | `untrack(fn)` | 读取但不订阅 |
+| | `observable(init)` → 代理对象 | 对象 / 数组的属性级响应式,直接赋值 |
+| | `unwrap(store)` | 取回代理背后的原始数据 |
| 节点 | `h(type, props, ...children)` → `Node` | 建节点 / 调组件,JSX 的目标 |
| | `Fragment` | 多个兄弟节点 |
| | `thunk(fn)` | 编译器包动态 prop 用;业务一般不手写 |
| 结构 | `For` | keyed 列表 |
| | `Show` | 条件分支 |
| 命令 | `ref()` → `Ref`,`ref.cmd(name, args?)` | 对节点发一次性动作 |
-| 异步 | `createResource(fetcher)` | 同步期建 signal,异步期写 |
+| 异步 | `resource(fetcher)` | 同步期建 signal,异步期写 |
| | `setTimeout` / `clearTimeout` | 全局,经宿主定时器实现 |
| 页面 | `pageVisible()` | 页面是否可见(K1 `visible`) |
+| | `manifest()` | Kotlin 下发的组件 / 能力清单 |
| 版本 | `VERSION`、`PROTOCOL` | 包版本;patch 协议版本号 |
+| 内部 | `internal.{call, query, send, onEmit}` | core 与 `@tiny-ui/native` 之间的桥契约(§9),业务不用 |
-不在 v1:`createStore`(v1.1)、`createContext`、`ErrorBoundary`、`Suspense`、`Switch/Match`、`Index`、`Portal`、`lazy`、`setInterval`。页内跨组件共享状态直接用模块顶层的 `signal`(每页一个引擎,模块作用域就是页面作用域)。
+不在 v1:`createContext`、`ErrorBoundary`、`Suspense`、`Switch/Match`、`Index`、`Portal`、`lazy`、`setInterval`。页内跨组件共享状态直接用模块顶层的 `signal` / `observable`(每页一个引擎,模块作用域就是页面作用域)。
+
+**命名规则**(2026-09-17 定):状态与异步原语一律无前缀小写(`signal` / `memo` / `effect` / `resource` / `observable`),不用 `create*`、`use*` 前缀——`use*` 会被读成 React hooks 的"每次渲染重跑",而组件只跑一次;组件与结构原语首字母大写。`observable` 原拟名 `createStore`(Solid),改名因 "store" 留给 `@tiny-ui/native` 的跨页 KV(业界 store 的主流含义)。
## 2. 响应式原语
@@ -79,6 +85,22 @@ function untrack(fn: () => T): T
执行 `fn` 期间不建立任何订阅。用于 effect 里"读一下但不想因它重跑"。
+### 2.6 `observable`
+
+```ts
+function observable(init: T): T
+function unwrap(value: T): T
+```
+
+- `init` 必须是纯对象或数组,返回它的 Proxy;传入已是 observable 的对象原样返回。可在任何地方创建,不要求渲染期
+- **读即订阅,按属性**:effect 里读 `state.user.name` 只订阅 `user` 对象上的 `name`;`Object.keys` / `for…in` / `JSON.stringify` 订阅键集合;`"k" in state` 订阅 `k` 这个键(增删它时重跑)
+- **写即通知,直接赋值**:`state.user.name = "x"`、`state.list.push(x)`、`splice` / `sort` / `length = 0` / `delete` 都触发;`===` 同值写入不触发。写入时机与 `signal` 相同,在 flush 统一重跑,受同一个更新环检测
+- 嵌套的纯对象 / 数组在第一次读到时才被代理,代理按原对象缓存:`state.list[0] === state.list[0]`。class 实例、`Map` / `Set` / `Date` 按值存放,不深追踪,只有替换引用才触发;冻结对象同样按值
+- 存进去的对象被直接持有(不拷贝):绕过 Proxy 改原对象不会触发
+- 与 `For` 的配合:`each={state.list}` 时行 accessor `item()` 返回缓存的代理,行内绑定落到属性级——改一行的一个字段只重跑读了它的绑定,`For` 不重算;`push` / `splice` 触发一次 reconcile
+- `unwrap(value)`:返回代理背后的原对象,并把嵌套的代理原地换回原对象;发请求体、打日志时用。非代理原样返回
+- 分工:原始值用 `signal`;有独立变化字段的对象 / 数组用 `observable`;跨页共享走 `@tiny-ui/native` 的 `store`(native-api.md §3);`signal` 整体替换仍合法。不做 `reconcile` / `produce` / 只读视图(roadmap D 组)
+
## 3. 节点与组件
### 3.1 `h`
@@ -144,7 +166,7 @@ interface ForProps {
- 在一个 effect 里读 `each`,对新旧 key 序列做 keyed reconcile,只产出 `i` / `m` / `r`(算法与 bench `reconcileKeys` 一致)
- 建行:新建行 owner,在其中调 `children(item, index)`,返回值必须是一个 `Node`,插到对应 index。行 owner 由 `For` 持有,不挂在 `For` 的 effect 之下
- 删行:dispose 行 owner(解绑该行全部 effect、跑 cleanups、删 handler),发一条 `["r", 行id]`
-- `item()` 是行内的 accessor:同一 key 的元素引用变了(`!==`)就更新,读它的绑定随之重算,行不重建。`index()` 同理。这是 v1 没有 `createStore` 时"整体替换数组"仍能做到行级更新的机制
+- `item()` 是行内的 accessor:同一 key 的元素引用变了(`!==`)就更新,读它的绑定随之重算,行不重建。`index()` 同理。这是不用 `observable`、"整体替换数组"时仍能做到行级更新的机制
- key 重复抛 E2
### 4.2 `Show`
@@ -177,10 +199,10 @@ interface Ref { cmd(name: string, args?: Record` 后 `list.cmd("scrollTo", { index: 0 })` 往本事务的 patch 推 `["x", id, "scrollTo", {"index":0}]`。语义见 ADR-004 §3.2:一次性、晚于组合、无回执。未绑定节点时调用抛错。
-## 6. `createResource`
+## 6. `resource`
```ts
-function createResource(fetcher: () => Promise): [
+function resource(fetcher: () => Promise): [
data: () => T | undefined,
{ loading: () => boolean; error: () => unknown; refetch: () => void },
]
@@ -242,7 +264,7 @@ export default function Home(props: HomeProps): Node { … }
| J4 | `__host_send` | `(name: string, argsJson: string) => void` | 即发即忘 |
| J5 | `__host_report` | `(kind: "E1", detailJson: string) => void` | 运行时捕获但不中断事务的业务错误 `{ entry, message, stack }`;`console.*` 走引擎 logger |
-`@tiny-ui/native` 是这五个全局的类型化封装(`http.get` = `__host_call("http.get", …)` 包成 Promise),业务不直接碰 `__host_*`。
+`@tiny-ui/native` 是这五个全局的类型化封装(`http.get` = `__host_call("http.get", …)` 包成 Promise),经 core 的 `internal.{call, query, send, onEmit}` 调用;业务不直接碰 `__host_*` 也不碰 `internal`。`internal` 留在主入口而不是子路径 `@tiny-ui/core/internal`:运行时模块按名字注册进引擎的模块表,子路径会成为第三个模块名,要么再注册一个模块、要么被打进 native 的字节码而复制一份 core 的模块状态(pending 表分裂)。
错误分类的运行时侧行为(ADR-002 §3.5):
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index ab52b54..a7d67a5 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,14 +1,16 @@
export const VERSION = "0.0.0";
-export { PROTOCOL, pageVisible, host, onEmit } from "./page.ts";
+export { PROTOCOL, pageVisible, manifest } from "./page.ts";
export type { HostManifest } from "./page.ts";
export { signal, memo, effect, onCleanup, untrack } from "./reactive.ts";
+export { observable, unwrap } from "./observable.ts";
export { h, Fragment, thunk, ref } from "./node.ts";
export type { Node, Ref, Component } from "./node.ts";
export { For, Show } from "./control.ts";
export type { ForProps, ShowProps } from "./control.ts";
-export { createResource } from "./resource.ts";
+export { resource } from "./resource.ts";
export type { ResourceActions } from "./resource.ts";
-export { call, query, send, HostError } from "./host.ts";
+export { HostError } from "./host.ts";
+export * as internal from "./internal.ts";
export * from "./components.ts";
// Globals the engine provides to page code; there is no DOM lib to declare them (docs/adr-005-engine.md).
diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts
new file mode 100644
index 0000000..a4cfc33
--- /dev/null
+++ b/packages/core/src/internal.ts
@@ -0,0 +1,3 @@
+// The contract between @tiny-ui/core and @tiny-ui/native (docs/runtime-api.md §9); page code has no business here.
+export { call, query, send } from "./host.ts";
+export { onEmit } from "./page.ts";
diff --git a/packages/core/src/node.ts b/packages/core/src/node.ts
index 3d76e61..ae4742f 100644
--- a/packages/core/src/node.ts
+++ b/packages/core/src/node.ts
@@ -123,7 +123,7 @@ function callComponent(type: Component, props: Props | null, children: Chil
const result: unknown = type(p);
if (type[CONTROL]) return result as Node;
if (typeof result !== "number") {
- const what = result instanceof Promise ? "a Promise (async components are not supported; use createResource)"
+ const what = result instanceof Promise ? "a Promise (async components are not supported; use resource())"
: Array.isArray(result) ? "several nodes (wrap them in one container)"
: result instanceof Slot ? "a For / Show (wrap it in one container)"
: String(result);
diff --git a/packages/core/src/observable.ts b/packages/core/src/observable.ts
new file mode 100644
index 0000000..8a78812
--- /dev/null
+++ b/packages/core/src/observable.ts
@@ -0,0 +1,133 @@
+// observable: docs/runtime-api.md §2.6. Property-level subscriptions over lazily created proxies.
+import { notify, track, tracking, type Effect } from "./reactive.ts";
+
+const RAW: unique symbol = Symbol("tinyui.raw");
+/** Subscriptions to the key set itself (`Object.keys`, `for…in`, `JSON.stringify`); array length changes notify it too. */
+const KEYS: unique symbol = Symbol("tinyui.keys");
+
+type Subs = Set;
+type Key = string | symbol;
+
+const proxies = new WeakMap