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 @@

1. 结构:一个页面在内存里有什么

ownermount 建根;Show 每次切分支建一个;For 每建一行建一个根 owner 被运行时持有;分支 owner 被 Show 的 cleanup 持有;行 owner 被 For 持有dispose:跑名下 effect 的解绑与 cleanups。分支切换、行删除、卸载 effecth() 遇到 thunk prop;effect();For / Show 自己创建时的当前 ownerowner dispose 时;或它所属的 effect 重跑前(effect 内 onCleanup) -signalsignal()、memo()、createResource()、For 的 item accessor不挂任何名下,靠引用存活没人引用时被 GC。订阅它的 effect 被解绑后,signal 若无其他引用即回收 +signalsignal()、memo()、resource()、For 的 item accessor不挂任何名下,靠引用存活没人引用时被 GC。订阅它的 effect 被解绑后,signal 若无其他引用即回收 节点 idh() 对内置类型分配不是对象,只是整数;Kotlin 侧才有节点不回收。r 之后不再出现 槽位表h() 处理 children 时内置节点随节点 r handlerh() 遇到 onXxx通过 onCleanup 登记到当前 ownerowner 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():

@@ -384,7 +384,7 @@

5. 规则表

- + 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(); +const subscribers = new WeakMap>(); + +/** + * Wraps a plain object or array so that reads inside effects subscribe per property and writes + * through the proxy notify. Nested plain objects and arrays are wrapped on first read; anything + * else (class instances, Map / Set, Date) is stored by value and only a reference swap triggers. + */ +export function observable(init: T): T { + const raw = unwrapShallow(init); + if (!isWrappable(raw)) throw new Error("observable() takes a plain object or an array"); + return wrap(raw) as T; +} + +/** The plain data behind a store, with nested proxies replaced in place; for request bodies and logs. */ +export function unwrap(value: T): T { + return unwrapDeep(value, new Set()) as T; +} + +function unwrapDeep(value: unknown, seen: Set): unknown { + const raw = unwrapShallow(value); + if (!isWrappable(raw) || seen.has(raw)) return raw; + seen.add(raw); + const r = raw as Record; + for (const key of Object.keys(r)) { + const v = r[key]; + const u = unwrapDeep(v, seen); + if (u !== v) r[key] = u; + } + return raw; +} + +function unwrapShallow(value: unknown): unknown { + return (value !== null && typeof value === "object" && (value as { [RAW]?: object })[RAW]) || value; +} + +function isWrappable(value: unknown): value is object { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) return false; + const proto = Object.getPrototypeOf(value) as unknown; + return proto === Object.prototype || proto === null || Array.isArray(value); +} + +function wrap(value: unknown): unknown { + if (!isWrappable(value) || RAW in value) return value; + let proxy = proxies.get(value); + if (!proxy) { + proxy = new Proxy(value, handlers); + proxies.set(value, proxy); + } + return proxy; +} + +function subs(target: object, key: Key, create: boolean): Subs | undefined { + let keys = subscribers.get(target); + if (!keys) { + if (!create) return undefined; + subscribers.set(target, keys = new Map()); + } + let s = keys.get(key); + if (!s && create) keys.set(key, s = new Set()); + return s; +} + +function observe(target: object, key: Key): void { + if (tracking()) track(subs(target, key, true)!); +} + +function changed(target: object, key: Key): void { + const s = subs(target, key, false); + if (s) notify(s); +} + +const isIndex = (key: Key): boolean => typeof key === "string" && String(Number(key) >>> 0) === key; + +const handlers: ProxyHandler = { + get(target, key, receiver) { + if (key === RAW) return target; + const value = Reflect.get(target, key, receiver) as unknown; + if (typeof key === "symbol") return value; + // array methods are read through here too; only elements and length are state + if (!Array.isArray(target) || key === "length" || isIndex(key)) observe(target, key); + if (!isWrappable(value)) return value; + // the get invariant: a non-configurable, non-writable property must come back as its own value + const d = Reflect.getOwnPropertyDescriptor(target, key); + return d && !d.configurable && !d.writable ? value : wrap(value); + }, + has(target, key) { + if (typeof key !== "symbol") observe(target, key); + return Reflect.has(target, key); + }, + ownKeys(target) { + observe(target, KEYS); + return Reflect.ownKeys(target); + }, + set(target, key, value, receiver) { + const raw = unwrapShallow(value); + const had = Object.prototype.hasOwnProperty.call(target, key); + const old = (target as Record)[key]; + const length = Array.isArray(target) ? target.length : -1; + const ok = Reflect.set(target, key, raw, receiver); + if (!ok) return false; + if (!had) changed(target, KEYS); + // read back rather than compare with `raw`: a setter may store something else + if (!had || (target as Record)[key] !== old) changed(target, key); + if (length >= 0) { + const now = (target as unknown[]).length; + // assigning past the end grows an array without a `length` set; truncating drops elements without deletes + if (key !== "length" && now !== length) changed(target, "length"); + if (key === "length") for (let i = now; i < length; i++) changed(target, String(i)); + if (now !== length) changed(target, KEYS); + } + return ok; + }, + deleteProperty(target, key) { + const had = Object.prototype.hasOwnProperty.call(target, key); + const ok = Reflect.deleteProperty(target, key); + if (ok && had) { + changed(target, key); + changed(target, KEYS); + } + return ok; + }, +}; diff --git a/packages/core/src/page.ts b/packages/core/src/page.ts index a604343..4b66b5a 100644 --- a/packages/core/src/page.ts +++ b/packages/core/src/page.ts @@ -12,7 +12,7 @@ export interface HostManifest { } let root: Owner | null = null; -let manifest: HostManifest = { components: {}, capabilities: [] }; +let current: HostManifest = { components: {}, capabilities: [] }; const [visible, setVisible] = signal(true); const topics = new Map void>>(); @@ -20,8 +20,8 @@ export function pageVisible(): boolean { return visible(); } -export function host(): HostManifest { - return manifest; +export function manifest(): HostManifest { + return current; } /** K5 subscription; the subscription dies with the current owner. */ @@ -47,7 +47,7 @@ export const entries = { mount(page: (props: Props) => Node, propsJson: string, hostJson: string): void { if (root) throw new Error("mount() called twice"); - manifest = JSON.parse(hostJson) as HostManifest; + current = JSON.parse(hostJson) as HostManifest; const props = JSON.parse(propsJson) as Props; resetIds(); root = createOwner(); diff --git a/packages/core/src/reactive.ts b/packages/core/src/reactive.ts index e6cdffe..e51e62e 100644 --- a/packages/core/src/reactive.ts +++ b/packages/core/src/reactive.ts @@ -6,7 +6,7 @@ export interface Owner { disposed: boolean; } -interface Effect { +export interface Effect { fn: () => void; deps: Set[]; owner: Owner | null; @@ -61,6 +61,11 @@ export function insideRender(): boolean { return currentOwner !== null; } +/** True while an effect is collecting dependencies; stores use it to skip allocating subscriber sets. */ +export function tracking(): boolean { + return currentEffect !== null; +} + export function signal(init: T): [get: () => T, set: (next: T | ((prev: T) => T)) => void] { let value = init; const subs = new Set(); @@ -167,14 +172,14 @@ function runCleanups(e: Effect): void { for (const c of cleanups) c(); } -function track(subs: Set): void { +export function track(subs: Set): void { const e = currentEffect; if (!e || subs.has(e)) return; subs.add(e); e.deps.push(subs); } -function notify(subs: Set): void { +export function notify(subs: Set): void { for (const e of subs) { if (e.memo) { if (!e.memo.dirty) { diff --git a/packages/core/src/resource.ts b/packages/core/src/resource.ts index 31138c8..47f1def 100644 --- a/packages/core/src/resource.ts +++ b/packages/core/src/resource.ts @@ -7,8 +7,8 @@ export interface ResourceActions { } /** docs/runtime-api.md §6: signals are created now, written when the promise settles. */ -export function createResource(fetcher: () => Promise): [data: () => T | undefined, actions: ResourceActions] { - if (!insideRender()) throw new Error("createResource() called outside a synchronous render period"); +export function resource(fetcher: () => Promise): [data: () => T | undefined, actions: ResourceActions] { + if (!insideRender()) throw new Error("resource() called outside a synchronous render period"); const [data, setData] = signal(undefined); const [loading, setLoading] = signal(false); const [error, setError] = signal(undefined); diff --git a/packages/core/test/async.test.ts b/packages/core/test/async.test.ts index 4951788..e042812 100644 --- a/packages/core/test/async.test.ts +++ b/packages/core/test/async.test.ts @@ -2,16 +2,17 @@ import assert from "node:assert/strict"; import { afterEach, describe, it } from "node:test"; import { bridge } from "./host-stub.ts"; import { drain, mount, tinyui, transaction, unmount } from "./helpers.ts"; -import { call, Column, createResource, h, HostError, onEmit, pageVisible, query, signal, Text, thunk } from "../src/index.ts"; +import { Column, h, HostError, internal, pageVisible, resource, signal, Text, thunk } from "../src/index.ts" +const { call, query, onEmit } = internal;; import * as timers from "../src/timers.ts"; const g = globalThis as Record; afterEach(() => unmount()); -describe("createResource + K3", () => { +describe("resource + K3", () => { it("fires the host call at creation and writes signals when resolved", async () => { mount(() => { - const [user, { loading }] = createResource(() => call<{ name: string }>("http.get", { url: "/me" })); + const [user, { loading }] = resource(() => call<{ name: string }>("http.get", { url: "/me" })); return h(Column, null, h(Text, { text: thunk(() => loading() ? "loading" : user()!.name) })); }); assert.deepEqual(bridge.calls.map((c) => [c.name, c.args]), [["http.get", { url: "/me" }]]); @@ -26,7 +27,7 @@ describe("createResource + K3", () => { it("rejection lands in error() and is not reported", async () => { mount(() => { - const [, { error }] = createResource(() => call("http.get")); + const [, { error }] = resource(() => call("http.get")); return h(Column, null, h(Text, { text: thunk(() => { const e = error(); return e instanceof HostError ? e.code : "ok"; }) })); }); tinyui().reject(bridge.calls.at(-1)!.cbId, JSON.stringify({ code: "E_NET", message: "offline" })); @@ -40,7 +41,7 @@ describe("createResource + K3", () => { it("a non-JSON host result rejects instead of hanging", async () => { mount(() => { - const [, { error, loading }] = createResource(() => call("x")); + const [, { error, loading }] = resource(() => call("x")); return h(Column, null, h(Text, { text: thunk(() => loading() ? "loading" : String((error() as HostError).code)) })); }); tinyui().resolve(bridge.calls.at(-1)!.cbId, "{not json"); @@ -52,7 +53,7 @@ describe("createResource + K3", () => { it("a result arriving after unmount is dropped", async () => { mount(() => { - const [v] = createResource(() => call("x")); + const [v] = resource(() => call("x")); return h(Column, null, h(Text, { text: thunk(() => String(v())) })); }); const cbId = bridge.calls.at(-1)!.cbId; diff --git a/packages/core/test/observable.test.ts b/packages/core/test/observable.test.ts new file mode 100644 index 0000000..098d87b --- /dev/null +++ b/packages/core/test/observable.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { bridge } from "./host-stub.ts"; +import { mount, transaction, unmount } from "./helpers.ts"; +import { createOwner, effect, runPending, runWithOwner } from "../src/reactive.ts"; +import { observable, unwrap } from "../src/observable.ts"; +import { Column, For, h, Text, thunk } from "../src/index.ts"; + +const render = (fn: () => T) => runWithOwner(createOwner(), fn); + +describe("observable", () => { + it("subscribes per property: writing one field re-runs only its readers", () => { + const store = observable({ a: 1, b: 1 }); + let aRuns = 0, bRuns = 0; + render(() => { + effect(() => { store.a; aRuns++; }); + effect(() => { store.b; bRuns++; }); + }); + store.a = 2; + runPending(); + assert.deepEqual([aRuns, bRuns], [2, 1]); + store.a = 2; + runPending(); + assert.equal(aRuns, 2, "same value is a no-op"); + }); + + it("wraps nested objects lazily with a stable identity", () => { + const store = observable({ user: { name: "a", tags: ["x"] } }); + assert.equal(store.user, store.user); + assert.equal(store.user.tags, store.user.tags); + let runs = 0; + render(() => effect(() => { store.user.name; runs++; })); + store.user.name = "b"; + runPending(); + assert.equal(runs, 2); + store.user = { name: "c", tags: [] }; + runPending(); + assert.equal(runs, 3, "replacing the parent re-reads through the new object"); + store.user.name = "d"; + runPending(); + assert.equal(runs, 4, "the new object is tracked too"); + }); + + it("tracks array elements and length; push, splice and truncation notify", () => { + const store = observable({ list: [1, 2, 3] }); + const seen: number[][] = []; + render(() => effect(() => seen.push(store.list.map((n) => n * 10)))); + store.list.push(4); + runPending(); + store.list.splice(0, 1); + runPending(); + store.list.length = 1; + runPending(); + store.list[0] = 9; + runPending(); + assert.deepEqual(seen, [[10, 20, 30], [10, 20, 30, 40], [20, 30, 40], [20], [90]]); + }); + + it("tracks the key set for iteration and `in`", () => { + const store = observable>({}); + const keys: string[][] = []; + let has = 0; + render(() => { + effect(() => keys.push(Object.keys(store))); + effect(() => { if ("x" in store) has++; }); + }); + store["x"] = 1; + runPending(); + delete store["x"]; + runPending(); + assert.deepEqual(keys, [[], ["x"], []]); + assert.equal(has, 1); + }); + + it("stores class instances and Map by value: only a reference swap triggers", () => { + class Point { x: number; constructor(x = 0) { this.x = x; } } + const store = observable({ p: new Point(), m: new Map() }); + let runs = 0; + render(() => effect(() => { store.p.x; store.m.size; runs++; })); + store.p.x = 5; + store.m.set("k", 1); + runPending(); + assert.equal(runs, 1); + store.p = new Point(6); + runPending(); + assert.equal(runs, 2); + }); + + it("keeps Proxy invariants and stays silent on writes that fail", () => { + const inner = { n: 1 }; + const raw: { locked: { n: number }; open?: { n: number } } = { locked: inner }; + Object.defineProperty(raw, "locked", { value: inner, writable: false, configurable: false, enumerable: true }); + const store = observable(raw); + assert.equal(store.locked, inner, "a non-configurable, non-writable property comes back unwrapped"); + const sealed = observable(Object.seal({ a: 1 }) as { a: number; b?: number }); + let runs = 0; + render(() => effect(() => { sealed.b; runs++; })); + assert.throws(() => { sealed.b = 2; }, TypeError); + assert.throws(() => { delete (sealed as { a?: number }).a; }, TypeError); + runPending(); + assert.equal(runs, 1, "a rejected write queues nothing"); + }); + + it("notifies by what a setter actually stored", () => { + let backing = 1; + const store = observable({ get v() { return backing; }, set v(n: number) { backing = n * 2; } }); + const seen: number[] = []; + render(() => effect(() => seen.push(store.v))); + store.v = 1; + runPending(); + assert.deepEqual(seen, [1, 2]); + }); + + it("unwrap returns plain data and rejects non-objects at creation", () => { + const store = observable({ user: { name: "a" }, list: [{ id: 1 }] }); + const copy = { ...store, extra: store.user }; + assert.equal(unwrap(store), unwrap(store)); + const raw = unwrap(copy); + assert.equal(JSON.stringify(raw), JSON.stringify({ user: { name: "a" }, list: [{ id: 1 }], extra: { name: "a" } })); + assert.notEqual(raw.extra, store.user, "nested proxies are replaced"); + assert.equal(observable(store), store, "a store is returned as is"); + assert.throws(() => observable(1 as unknown as object), /plain object or an array/); + assert.throws(() => observable(new Date() as unknown as object), /plain object or an array/); + }); +}); + +describe("observable with For", () => { + afterEach(() => unmount()); + + it("a field write updates one binding without reconciling the list", () => { + interface Todo { id: number; title: string; done: boolean } + const store = observable({ todos: [{ id: 1, title: "a", done: false }, { id: 2, title: "b", done: false }] as Todo[] }); + let reconciles = 0; + mount(() => h(Column, null, + h(For, { each: thunk(() => { reconciles++; return store.todos; }), key: (t: Todo) => t.id }, (item: () => Todo) => + h(Text, { text: thunk(() => item().title), color: thunk(() => (item().done ? "gray" : "black")) })), + )); + assert.equal(reconciles, 1); + // rows are created before their parent: rows 1 / 2, Column 3 + assert.deepEqual(transaction(() => { store.todos[1]!.done = true; }), [["p", 2, "color", "gray"]]); + assert.equal(reconciles, 1, "the For effect did not re-run"); + assert.deepEqual(transaction(() => { store.todos.push({ id: 3, title: "c", done: false }); }), + [["c", 4, "Text"], ["p", 4, "text", "c"], ["p", 4, "color", "black"], ["i", 3, 4, 2]]); + assert.equal(reconciles, 2); + assert.deepEqual(transaction(() => { store.todos.splice(0, 1); }), [["r", 1]]); + assert.equal(bridge.applied.length, 4); + }); +}); diff --git a/packages/native/src/events.ts b/packages/native/src/events.ts index cf4c154..3d1ee9b 100644 --- a/packages/native/src/events.ts +++ b/packages/native/src/events.ts @@ -1,13 +1,13 @@ // docs/native-api.md §4: one bus for host events and business events. -import { onEmit, send } from "@tiny-ui/core"; +import { internal } from "@tiny-ui/core"; /** J4: delivered to every page subscribed to `topic`, including native listeners. */ export function emit(topic: string, payload: Record = {}): void { - send("events.emit", { topic, payload }); + internal.send("events.emit", { topic, payload }); } /** K5: call during render; the subscription dies with the owner. */ export function on(topic: string, fn: (payload: unknown) => void): void { - send("events.subscribe", { topic }); - onEmit(topic, fn); + internal.send("events.subscribe", { topic }); + internal.onEmit(topic, fn); } diff --git a/packages/native/src/http.ts b/packages/native/src/http.ts index 2600ecb..f7f9289 100644 --- a/packages/native/src/http.ts +++ b/packages/native/src/http.ts @@ -1,5 +1,5 @@ // docs/native-api.md §5: J3 `http.request`, errors are HostError with the E3 codes. -import { call } from "@tiny-ui/core"; +import { internal } from "@tiny-ui/core"; export interface HttpOptions { headers?: Record; @@ -14,7 +14,7 @@ export interface HttpResponse { } export function request(method: string, url: string, body?: unknown, options: HttpOptions = {}): Promise> { - return call>("http.request", { method, url, ...(body !== undefined && { body }), ...options }); + return internal.call>("http.request", { method, url, ...(body !== undefined && { body }), ...options }); } export const get = (url: string, options?: HttpOptions) => request("GET", url, undefined, options); diff --git a/packages/native/src/navigation.ts b/packages/native/src/navigation.ts index ece68e2..6c6df8b 100644 --- a/packages/native/src/navigation.ts +++ b/packages/native/src/navigation.ts @@ -1,19 +1,19 @@ // docs/native-api.md §2; the host implements Navigator (docs/app-model.md). -import { onEmit, send } from "@tiny-ui/core"; +import { internal } from "@tiny-ui/core"; export type Params = Record; /** J4: opens `page` (a module name such as `pages/detail`) on top of this one. */ export function push(page: string, params: Params = {}): void { - send("navigation.push", { page, params }); + internal.send("navigation.push", { page, params }); } /** J4: closes this page; `result` reaches the page below through `onResult`. */ export function pop(result: unknown = null): void { - send("navigation.pop", { result }); + internal.send("navigation.pop", { result }); } /** K5 `navigation.result`: the page that was pushed from here popped with a result. Call during render. */ export function onResult(fn: (result: unknown, from: string) => void): void { - onEmit("navigation.result", (p) => { const r = p as { result: unknown; from: string }; fn(r.result, r.from); }); + internal.onEmit("navigation.result", (p) => { const r = p as { result: unknown; from: string }; fn(r.result, r.from); }); } diff --git a/packages/native/src/store.ts b/packages/native/src/store.ts index 023a700..f511a3a 100644 --- a/packages/native/src/store.ts +++ b/packages/native/src/store.ts @@ -1,21 +1,21 @@ // docs/native-api.md §3: the truth lives in Kotlin; pages read snapshots and subscribe to changes. -import { onEmit, query, send, signal } from "@tiny-ui/core"; +import { internal, signal } from "@tiny-ui/core"; /** J2: the current value, or undefined. */ export function get(key: string): T | undefined { - const v = query("store.get", { key }); + const v = internal.query("store.get", { key }); return v === null ? undefined : v; } /** J4: writes the value; every page subscribed to `key` receives it. */ export function set(key: string, value: unknown): void { - send("store.set", { key, value: JSON.stringify(value) }); + internal.send("store.set", { key, value: JSON.stringify(value) }); } /** An accessor kept current by K5; call during render, read it inside effects and thunks. */ export function watch(key: string): () => T | undefined { const [value, setValue] = signal(get(key)); - send("store.subscribe", { key }); - onEmit(`store:${key}`, (p) => setValue(() => ((p as { value: T | null }).value ?? undefined) as T | undefined)); + internal.send("store.subscribe", { key }); + internal.onEmit(`store:${key}`, (p) => setValue(() => ((p as { value: T | null }).value ?? undefined) as T | undefined)); return value; } diff --git a/sample/js/src/pages/todos.tsx b/sample/js/src/pages/todos.tsx index 28493cd..72b151e 100644 --- a/sample/js/src/pages/todos.tsx +++ b/sample/js/src/pages/todos.tsx @@ -1,47 +1,47 @@ -import { Button, Column, createResource, effect, For, LazyColumn, ref, Row, Show, signal, Text, TextField, untrack, type LazyColumnCommands, type TextFieldCommands } from "@tiny-ui/core"; +import { Button, Column, effect, For, LazyColumn, observable, ref, resource, Row, Show, Text, TextField, untrack, type LazyColumnCommands, type TextFieldCommands } from "@tiny-ui/core"; import { http } from "@tiny-ui/native"; interface Todo { id: number; title: string; done: boolean } interface Page { items: Todo[]; next: number | null } export default function Todos() { - const [todos, setTodos] = signal([]); - const [next, setNext] = signal(1); - const [loading, setLoading] = signal(false); - const [first, firstMeta] = createResource(() => http.get("/todos?page=1")); + const state = observable({ todos: [] as Todo[], next: 1 as number | null, loading: false }); + const [first, firstMeta] = resource(() => http.get("/todos?page=1")); effect(() => { const r = first(); - if (r) untrack(() => { setTodos(r.body.items); setNext(r.body.next); }); + if (r) untrack(() => { state.todos = r.body.items; state.next = r.body.next; }); }); const list = ref(); const input = ref(); async function loadMore() { - const page = next(); - if (page === null || loading()) return; - setLoading(true); + const page = state.next; + if (page === null || state.loading) return; + state.loading = true; try { const { body } = await http.get(`/todos?page=${page}`); - setTodos([...todos(), ...body.items]); - setNext(body.next); + state.todos.push(...body.items); + state.next = body.next; } finally { - setLoading(false); + state.loading = false; } } function add(title: string) { if (!title.trim()) return; - setTodos([...todos(), { id: Date.now(), title: title.trim(), done: false }]); + state.todos.push({ id: Date.now(), title: title.trim(), done: false }); input.cmd("setText", { text: "" }); - list.cmd("scrollTo", { index: todos().length - 1 }); + list.cmd("scrollTo", { index: state.todos.length - 1 }); } - const toggle = (id: number) => setTodos(todos().map((t) => (t.id === id ? { ...t, done: !t.done } : t))); - const remove = (id: number) => setTodos(todos().filter((t) => t.id !== id)); + const remove = (id: number) => { + const at = state.todos.findIndex((t) => t.id === id); + if (at >= 0) state.todos.splice(at, 1); + }; return ( - t.done).length} done`} fontSize={20} fontWeight="bold" /> + t.done).length} done`} fontSize={20} fontWeight="bold" /> }> {() => ( @@ -50,8 +50,8 @@ export default function Todos() {
effect做什么patch
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 里的数组 / 对象整体替换才触发;要按字段触发用 observablelist().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 会成环