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
14 changes: 7 additions & 7 deletions docs/js-runtime.html
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ <h2 id="structure">1. 结构:一个页面在内存里有什么</h2>
<tbody>
<tr><td>owner</td><td><code>mount</code> 建根;<code>Show</code> 每次切分支建一个;<code>For</code> 每建一行建一个</td><td>根 owner 被运行时持有;分支 owner 被 <code>Show</code> 的 cleanup 持有;行 owner 被 <code>For</code> 持有</td><td>dispose:跑名下 effect 的解绑与 cleanups。分支切换、行删除、卸载</td></tr>
<tr><td>effect</td><td><code>h()</code> 遇到 <code>thunk</code> prop;<code>effect()</code>;<code>For</code> / <code>Show</code> 自己</td><td>创建时的当前 owner</td><td>owner dispose 时;或它所属的 effect 重跑前(effect 内 <code>onCleanup</code>)</td></tr>
<tr><td>signal</td><td><code>signal()</code>、<code>memo()</code>、<code>createResource()</code>、<code>For</code> 的 item accessor</td><td>不挂任何名下,靠引用存活</td><td>没人引用时被 GC。订阅它的 effect 被解绑后,signal 若无其他引用即回收</td></tr>
<tr><td>signal</td><td><code>signal()</code>、<code>memo()</code>、<code>resource()</code>、<code>For</code> 的 item accessor</td><td>不挂任何名下,靠引用存活</td><td>没人引用时被 GC。订阅它的 effect 被解绑后,signal 若无其他引用即回收</td></tr>
<tr><td>节点 id</td><td><code>h()</code> 对内置类型分配</td><td>不是对象,只是整数;Kotlin 侧才有节点</td><td>不回收。<code>r</code> 之后不再出现</td></tr>
<tr><td>槽位表</td><td><code>h()</code> 处理 children 时</td><td>内置节点</td><td>随节点 <code>r</code></td></tr>
<tr><td>handler</td><td><code>h()</code> 遇到 <code>onXxx</code></td><td>通过 <code>onCleanup</code> 登记到当前 owner</td><td>owner dispose</td></tr>
Expand Down Expand Up @@ -241,13 +241,13 @@ <h2 id="loop">3. 响应式闭环</h2>
<h2 id="life">4. 一个页面的一生</h2>
<p>页面:拉一个待办列表,加载中显示占位,每行点击切换完成态。</p>
<pre><code>// 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(() =&gt; http.get&lt;Todo[]&gt;("/todos"));
const [todos] = resource(() =&gt; http.get&lt;Todo[]&gt;("/todos"));
return (
&lt;Column&gt;
&lt;Text text={`${(todos() ?? []).length} todos`} /&gt;
Expand Down Expand Up @@ -276,7 +276,7 @@ <h2 id="life">4. 一个页面的一生</h2>
<h3>4.1 编译</h3>
<p>CLI 只做两件事:含调用 / 属性访问的属性表达式包成 <code>thunk</code>,JSX 变成 <code>h()</code>。事件、<code>key</code>、<code>fallback</code>、渲染函数是函数,原样保留。</p>
<pre><code>function Todos() {
const [todos] = createResource(() =&gt; http.get("/todos"));
const [todos] = resource(() =&gt; http.get("/todos"));
return h(Column, null,
h(Text, { text: thunk(() =&gt; `${(todos() ?? []).length} todos`) }),
h(Show, { when: thunk(() =&gt; todos()), fallback: () =&gt; h(Text, { text: "loading…" }) },
Expand Down Expand Up @@ -336,14 +336,14 @@ <h3>4.2 挂载(事务 1)与数据回来(事务 2)</h3>
<div class="tbl"><table>
<thead><tr><th>步骤</th><th>运行时里发生的</th><th>patch</th></tr></thead>
<tbody>
<tr><td><code>createResource</code></td><td>建 <code>data</code> / <code>loading</code> / <code>error</code> 三个 signal;分配 cbId 1 存进 pending 表;<code>__host_call("http.get", 1, …)</code></td><td></td></tr>
<tr><td><code>resource</code></td><td>建 <code>data</code> / <code>loading</code> / <code>error</code> 三个 signal;分配 cbId 1 存进 pending 表;<code>__host_call("http.get", 1, …)</code></td><td></td></tr>
<tr><td><code>h(Text, {text: thunk})</code></td><td>分配 id 1;建 effect E1,首跑读 <code>data</code>(undefined)→ 订阅</td><td class="ops">["c",1,"Text"] ["p",1,"text","0 todos"]</td></tr>
<tr><td><code>h(Show, …)</code></td><td>建 effect E2,首跑读 <code>data</code> → 假 → 建分支 owner O1,在其中调 fallback:id 2</td><td class="ops">["c",2,"Text"] ["p",2,"text","loading…"]</td></tr>
<tr><td><code>h(Column, null, 节点1, Show槽位)</code></td><td>分配 id 3;槽位表 <code>[节点1, Show(O1: [2])]</code></td><td class="ops">["i",3,1,0] ["i",3,2,1]</td></tr>
<tr><td><code>mount</code> 收尾</td><td>根节点 3 挂到根容器</td><td class="ops">["i",0,3,0]</td></tr>
</tbody></table></div>
<p><code>mount()</code> 返回,Kotlin 调 <code>flush()</code>:pending 队列为空,缓冲里 8 条 op 一次交给 J1。</p>
<p>Kotlin 拿到响应,<code>resolve(1, "[{id:1,title:'Buy milk',done:false},{id:2,title:'Call mom',done:true}]")</code>。运行时从 pending 表取出 resolver 调用,返回;quickjs-kmp 排空微任务:<code>createResource</code> 的 <code>.then</code> 写 <code>data</code>,E1、E2 入队。Kotlin 调 <code>flush()</code>:</p>
<p>Kotlin 拿到响应,<code>resolve(1, "[{id:1,title:'Buy milk',done:false},{id:2,title:'Call mom',done:true}]")</code>。运行时从 pending 表取出 resolver 调用,返回;quickjs-kmp 排空微任务:<code>resource</code> 的 <code>.then</code> 写 <code>data</code>,E1、E2 入队。Kotlin 调 <code>flush()</code>:</p>
<div class="tbl"><table>
<thead><tr><th>effect</th><th>做什么</th><th>patch</th></tr></thead>
<tbody>
Expand Down Expand Up @@ -384,7 +384,7 @@ <h2 id="rules">5. 规则表</h2>
<tr><td>children 创建后不可变;结构变化只经 <code>For</code> / <code>Show</code></td><td>编译期报错</td><td>§1:只有 <code>For</code> / <code>Show</code> 开 owner,别处建的节点没人负责删</td></tr>
<tr><td><code>For</code> / <code>Show</code> 只能直接写在内置节点的 children 里</td><td><code>h()</code> 抛 E2</td><td>§1:槽位表挂在内置节点上,它们需要一个父节点来算 index</td></tr>
<tr><td>内置 / 宿主节点的 prop 不能是函数(<code>on*</code> 除外);业务组件的 prop 不限</td><td><code>h()</code> / <code>bindProp</code> 抛 E2,抓的是 <code>text={fmt}</code> 忘了调用</td><td>ADR-002:函数不过桥,边界就是 patch;业务组件之间是纯 JS 调用</td></tr>
<tr><td>数组 / 对象整体替换才触发</td><td><code>list().push(x)</code> 静默无效</td><td>§3:<code>===</code> 判等;v1 没有 Proxy 追踪</td></tr>
<tr><td><code>signal</code> 里的数组 / 对象整体替换才触发;要按字段触发用 <code>observable</code></td><td><code>list().push(x)</code> 静默无效</td><td>§3:<code>===</code> 判等;Proxy 追踪只在 store 里(runtime-api.md §2.6)</td></tr>
<tr><td><code>For</code> 的 item 是 accessor</td><td>写 <code>todo.title</code> 类型错</td><td>§4.4:同 key 元素换引用时更新行而不重建行</td></tr>
<tr><td><code>Show</code> 只在真值性变化时切分支</td><td></td><td>§4.2:切分支 = 重建整棵分支,值变化不该付这个代价</td></tr>
<tr><td>同一 flush 内一个 effect 重跑超过 100 次</td><td>抛 E2</td><td>§3:effect 写自己读的 signal 会成环</td></tr>
Expand Down
2 changes: 1 addition & 1 deletion docs/jsx-transform.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ h(Text, { text: thunk(() => "Count: " + count()), color: thunk(() => theme.prima
| `{...props}` 展开属性 | 展开会立刻求值 getter,丢失响应式 | 显式列出属性 |
| children 里的表达式:`{cond && <A/>}`、`{cond ? <A/> : <B/>}`、`{list.map(...)}`、`{value}` | children 创建后不可变(runtime-api.md §3.1) | `<Show>` / `<For>`;文本走 `text` prop |
| 非空白的 JSXText:`<Text>hi</Text>` | 同上 | `<Text text="hi" />` |
| `async` 组件函数上的 JSX(`export default async function`) | 组件必须同步 | `createResource` |
| `async` 组件函数上的 JSX(`export default async function`) | 组件必须同步 | `resource` |

例外:children 里恰好一个箭头 / 函数表达式(`For` / `Show` 的渲染函数)合法,原样传为 `children`。

Expand Down
4 changes: 2 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand All @@ -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 | 页面间重复代码让安装包体积成问题 |
Expand Down
38 changes: 30 additions & 8 deletions docs/runtime-api.md
Original file line number Diff line number Diff line change
@@ -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. 一览
Expand All @@ -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. 响应式原语

Expand Down Expand Up @@ -79,6 +85,22 @@ function untrack<T>(fn: () => T): T

执行 `fn` 期间不建立任何订阅。用于 effect 里"读一下但不想因它重跑"。

### 2.6 `observable`

```ts
function observable<T extends object>(init: T): T
function unwrap<T>(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<T[]>` 整体替换仍合法。不做 `reconcile` / `produce` / 只读视图(roadmap D 组)

## 3. 节点与组件

### 3.1 `h`
Expand Down Expand Up @@ -144,7 +166,7 @@ interface ForProps<T> {
- 在一个 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`
Expand Down Expand Up @@ -177,10 +199,10 @@ interface Ref { cmd(name: string, args?: Record<string, string | number | boolea

`<LazyColumn ref={list} />` 后 `list.cmd("scrollTo", { index: 0 })` 往本事务的 patch 推 `["x", id, "scrollTo", {"index":0}]`。语义见 ADR-004 §3.2:一次性、晚于组合、无回执。未绑定节点时调用抛错。

## 6. `createResource`
## 6. `resource`

```ts
function createResource<T>(fetcher: () => Promise<T>): [
function resource<T>(fetcher: () => Promise<T>): [
data: () => T | undefined,
{ loading: () => boolean; error: () => unknown; refetch: () => void },
]
Expand Down Expand Up @@ -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):

Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/internal.ts
Original file line number Diff line number Diff line change
@@ -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";
2 changes: 1 addition & 1 deletion packages/core/src/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ function callComponent(type: Component<any>, 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);
Expand Down
Loading
Loading