From a575a0c940e6bf6c3307207efeea42aacbb6a71a Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Wed, 16 Sep 2026 18:35:53 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20M1=20Counter=20=E7=AB=AF=E5=88=B0?= =?UTF-8?q?=E7=AB=AF=E2=80=94=E2=80=94core=20=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E3=80=81CLI=20JSX=20=E5=8F=98=E6=8D=A2=E3=80=81compose=20?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E8=A1=A8=E4=B8=8E=E9=A1=B5=E9=9D=A2=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @tiny-ui/core:signal / memo / effect / onCleanup / untrack,h / Fragment / thunk / ref,槽位表,For / Show, createResource,宿主定时器,__tinyui 入口与 __host_* 桥;jsx-runtime 类型入口 - @tiny-ui/cli:esbuild onLoad 前置 pass(oxc-parser + magic-string)按 jsx-transform.md 包 thunk、拒绝非法 children; 产物目录先清旧文件 - compose:UiNode / NodeTree(withMutableSnapshot 内应用六种 op,E5 跳过上报)、schema DSL 与 App 级注册表、 Column / Row / Text / Button、PageHost(每个 K 入口 = 入口 + flush)、TinyUIPage;PageEngine 由 PageHost 取代 - sample:Counter 页;Android / iOS 模拟器点击三次显示 Count: 3 Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- compose/api/compose.klib.api | 236 +++++++++++++++++- .../wang/harlon/tinyui/BundleSmokeTest.kt | 27 ++ .../kotlin/wang/harlon/tinyui/PageEngine.kt | 54 ---- .../kotlin/wang/harlon/tinyui/PageHost.kt | 186 ++++++++++++++ .../kotlin/wang/harlon/tinyui/TinyUIPage.kt | 37 +++ .../wang/harlon/tinyui/components/Builtins.kt | 37 +++ .../wang/harlon/tinyui/node/NodeTree.kt | 161 ++++++++++++ .../kotlin/wang/harlon/tinyui/node/UiNode.kt | 20 ++ .../harlon/tinyui/schema/ComponentRegistry.kt | 51 ++++ .../harlon/tinyui/schema/ComponentSchema.kt | 87 +++++++ .../wang/harlon/tinyui/schema/NodeScope.kt | 29 +++ .../wang/harlon/tinyui/PageEngineTest.kt | 56 ----- .../kotlin/wang/harlon/tinyui/PageHostTest.kt | 106 ++++++++ .../wang/harlon/tinyui/node/NodeTreeTest.kt | 63 +++++ docs/jsx-transform.md | 1 + docs/roadmap.md | 5 +- packages/cli/package.json | 4 +- packages/cli/src/build.ts | 23 +- packages/cli/src/index.ts | 2 + packages/cli/src/transform.ts | 145 +++++++++++ packages/cli/test/build.test.ts | 3 +- .../cli/test/fixtures/app/src/pages/home.tsx | 2 +- packages/cli/test/transform.test.ts | 58 +++++ packages/core/package.json | 10 +- packages/core/src/components.ts | 7 + packages/core/src/control.ts | 92 +++++++ packages/core/src/host.ts | 76 ++++++ packages/core/src/index.ts | 12 + packages/core/src/jsx-runtime.ts | 27 ++ packages/core/src/node.ts | 207 +++++++++++++++ packages/core/src/page.ts | 122 +++++++++ packages/core/src/reactive.ts | 190 ++++++++++++++ packages/core/src/resource.ts | 27 ++ packages/core/src/timers.ts | 23 ++ packages/core/test/async.test.ts | 82 ++++++ packages/core/test/control.test.ts | 105 ++++++++ packages/core/test/counter.test.ts | 117 +++++++++ packages/core/test/helpers.ts | 27 ++ packages/core/test/host-stub.ts | 17 ++ packages/core/test/reactive.test.ts | 107 ++++++++ pnpm-lock.yaml | 222 ++++++++++++++++ sample/js/src/lib/greeting.ts | 3 - sample/js/src/pages/counter.tsx | 12 + sample/js/src/pages/home.tsx | 6 - sample/js/tsconfig.json | 5 +- .../kotlin/wang/harlon/tinyui/sample/App.kt | 41 +-- 47 files changed, 2776 insertions(+), 156 deletions(-) create mode 100644 compose/src/androidHostTest/kotlin/wang/harlon/tinyui/BundleSmokeTest.kt delete mode 100644 compose/src/commonMain/kotlin/wang/harlon/tinyui/PageEngine.kt create mode 100644 compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt create mode 100644 compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUIPage.kt create mode 100644 compose/src/commonMain/kotlin/wang/harlon/tinyui/components/Builtins.kt create mode 100644 compose/src/commonMain/kotlin/wang/harlon/tinyui/node/NodeTree.kt create mode 100644 compose/src/commonMain/kotlin/wang/harlon/tinyui/node/UiNode.kt create mode 100644 compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentRegistry.kt create mode 100644 compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentSchema.kt create mode 100644 compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/NodeScope.kt delete mode 100644 compose/src/commonTest/kotlin/wang/harlon/tinyui/PageEngineTest.kt create mode 100644 compose/src/commonTest/kotlin/wang/harlon/tinyui/PageHostTest.kt create mode 100644 compose/src/commonTest/kotlin/wang/harlon/tinyui/node/NodeTreeTest.kt create mode 100644 packages/cli/src/transform.ts create mode 100644 packages/cli/test/transform.test.ts create mode 100644 packages/core/src/components.ts create mode 100644 packages/core/src/control.ts create mode 100644 packages/core/src/host.ts create mode 100644 packages/core/src/jsx-runtime.ts create mode 100644 packages/core/src/node.ts create mode 100644 packages/core/src/page.ts create mode 100644 packages/core/src/reactive.ts create mode 100644 packages/core/src/resource.ts create mode 100644 packages/core/src/timers.ts create mode 100644 packages/core/test/async.test.ts create mode 100644 packages/core/test/control.test.ts create mode 100644 packages/core/test/counter.test.ts create mode 100644 packages/core/test/helpers.ts create mode 100644 packages/core/test/host-stub.ts create mode 100644 packages/core/test/reactive.test.ts delete mode 100644 sample/js/src/lib/greeting.ts create mode 100644 sample/js/src/pages/counter.tsx delete mode 100644 sample/js/src/pages/home.tsx diff --git a/README.md b/README.md index 4432ee5..61bdce2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ JS 写声明式组件、Compose Multiplatform 渲染的 UI 框架,引擎为 QuickJS(经 quickjs-kmp 接入)。Android / iOS 双端。 -> 早期阶段。关键选型已定,构建链已打通(TSX → 模块字节码 → 双端加载);进度见 [docs/roadmap.md](./docs/roadmap.md)。 +> 早期阶段。关键选型已定,M1 Counter 已在双端跑通(TSX → 模块字节码 → 引擎 → patch → Compose);进度见 [docs/roadmap.md](./docs/roadmap.md)。 - 设计文档与决策记录:[docs/](./docs/README.md) - 响应式模型与引擎 benchmark:[bench/](./bench/README.md) diff --git a/compose/api/compose.klib.api b/compose/api/compose.klib.api index 3843e4c..e365530 100644 --- a/compose/api/compose.klib.api +++ b/compose/api/compose.klib.api @@ -6,15 +6,145 @@ // - Show declarations: true // Library unique name: -final class wang.harlon.tinyui/PageEngine : kotlin/AutoCloseable { // wang.harlon.tinyui/PageEngine|null[0] - final fun callDefault(kotlin/Array...): wang.harlon.quickjs/JsValue // wang.harlon.tinyui/PageEngine.callDefault|callDefault(kotlin.Array...){}[0] - final fun close() // wang.harlon.tinyui/PageEngine.close|close(){}[0] +abstract fun interface wang.harlon.tinyui.schema/Component { // wang.harlon.tinyui.schema/Component|null[0] + abstract fun Render(wang.harlon.tinyui.schema/NodeScope, androidx.compose.runtime/Composer?, kotlin/Int) // wang.harlon.tinyui.schema/Component.Render|Render(wang.harlon.tinyui.schema.NodeScope;androidx.compose.runtime.Composer?;kotlin.Int){}[0] +} + +abstract interface wang.harlon.tinyui.schema/NodeScope { // wang.harlon.tinyui.schema/NodeScope|null[0] + abstract val node // wang.harlon.tinyui.schema/NodeScope.node|{}node[0] + abstract fun (): wang.harlon.tinyui.node/UiNode // wang.harlon.tinyui.schema/NodeScope.node.|(){}[0] + + abstract fun <#A1: kotlin/Any?> get(kotlin/String): #A1? // wang.harlon.tinyui.schema/NodeScope.get|get(kotlin.String){0§}[0] + abstract fun Children(androidx.compose.runtime/Composer?, kotlin/Int) // wang.harlon.tinyui.schema/NodeScope.Children|Children(androidx.compose.runtime.Composer?;kotlin.Int){}[0] + abstract fun dispatch(kotlin/String, kotlin/String = ...) // wang.harlon.tinyui.schema/NodeScope.dispatch|dispatch(kotlin.String;kotlin.String){}[0] + abstract fun has(kotlin/String): kotlin/Boolean // wang.harlon.tinyui.schema/NodeScope.has|has(kotlin.String){}[0] + abstract fun modifier(): androidx.compose.ui/Modifier // wang.harlon.tinyui.schema/NodeScope.modifier|modifier(){}[0] + abstract fun takeCommands(): kotlin.collections/List // wang.harlon.tinyui.schema/NodeScope.takeCommands|takeCommands(){}[0] +} + +abstract interface wang.harlon.tinyui/PageSink { // wang.harlon.tinyui/PageSink|null[0] + abstract fun businessError(kotlin/String, kotlin/String, kotlin/String?) // wang.harlon.tinyui/PageSink.businessError|businessError(kotlin.String;kotlin.String;kotlin.String?){}[0] + abstract fun log(kotlin/String) // wang.harlon.tinyui/PageSink.log|log(kotlin.String){}[0] + abstract fun patchProblem(wang.harlon.tinyui.node/PatchProblem) // wang.harlon.tinyui/PageSink.patchProblem|patchProblem(wang.harlon.tinyui.node.PatchProblem){}[0] +} + +final class wang.harlon.tinyui.node/Command { // wang.harlon.tinyui.node/Command|null[0] + constructor (kotlin/String, kotlin.collections/Map) // wang.harlon.tinyui.node/Command.|(kotlin.String;kotlin.collections.Map){}[0] + + final val args // wang.harlon.tinyui.node/Command.args|{}args[0] + final fun (): kotlin.collections/Map // wang.harlon.tinyui.node/Command.args.|(){}[0] + final val name // wang.harlon.tinyui.node/Command.name|{}name[0] + final fun (): kotlin/String // wang.harlon.tinyui.node/Command.name.|(){}[0] +} + +final class wang.harlon.tinyui.node/NodeTree { // wang.harlon.tinyui.node/NodeTree|null[0] + constructor (wang.harlon.tinyui.schema/ComponentRegistry, kotlin/Function1) // wang.harlon.tinyui.node/NodeTree.|(wang.harlon.tinyui.schema.ComponentRegistry;kotlin.Function1){}[0] + + final val root // wang.harlon.tinyui.node/NodeTree.root|{}root[0] + final fun (): wang.harlon.tinyui.node/UiNode // wang.harlon.tinyui.node/NodeTree.root.|(){}[0] + final val size // wang.harlon.tinyui.node/NodeTree.size|{}size[0] + final fun (): kotlin/Int // wang.harlon.tinyui.node/NodeTree.size.|(){}[0] + + final fun apply(kotlin/String) // wang.harlon.tinyui.node/NodeTree.apply|apply(kotlin.String){}[0] + final fun clear() // wang.harlon.tinyui.node/NodeTree.clear|clear(){}[0] + final fun node(kotlin/Int): wang.harlon.tinyui.node/UiNode? // wang.harlon.tinyui.node/NodeTree.node|node(kotlin.Int){}[0] +} + +final class wang.harlon.tinyui.node/PatchProblem { // wang.harlon.tinyui.node/PatchProblem|null[0] + constructor (kotlin/String, kotlin/String) // wang.harlon.tinyui.node/PatchProblem.|(kotlin.String;kotlin.String){}[0] + + final val op // wang.harlon.tinyui.node/PatchProblem.op|{}op[0] + final fun (): kotlin/String // wang.harlon.tinyui.node/PatchProblem.op.|(){}[0] + final val reason // wang.harlon.tinyui.node/PatchProblem.reason|{}reason[0] + final fun (): kotlin/String // wang.harlon.tinyui.node/PatchProblem.reason.|(){}[0] - final object Companion { // wang.harlon.tinyui/PageEngine.Companion|null[0] - final const val MODULE_SCHEME // wang.harlon.tinyui/PageEngine.Companion.MODULE_SCHEME|{}MODULE_SCHEME[0] - final fun (): kotlin/String // wang.harlon.tinyui/PageEngine.Companion.MODULE_SCHEME.|(){}[0] + final fun toString(): kotlin/String // wang.harlon.tinyui.node/PatchProblem.toString|toString(){}[0] +} + +final class wang.harlon.tinyui.node/UiNode { // wang.harlon.tinyui.node/UiNode|null[0] + final val children // wang.harlon.tinyui.node/UiNode.children|{}children[0] + final fun (): androidx.compose.runtime.snapshots/SnapshotStateList // wang.harlon.tinyui.node/UiNode.children.|(){}[0] + final val commands // wang.harlon.tinyui.node/UiNode.commands|{}commands[0] + final fun (): androidx.compose.runtime.snapshots/SnapshotStateList // wang.harlon.tinyui.node/UiNode.commands.|(){}[0] + final val events // wang.harlon.tinyui.node/UiNode.events|{}events[0] + final fun (): androidx.compose.runtime.snapshots/SnapshotStateMap // wang.harlon.tinyui.node/UiNode.events.|(){}[0] + final val id // wang.harlon.tinyui.node/UiNode.id|{}id[0] + final fun (): kotlin/Int // wang.harlon.tinyui.node/UiNode.id.|(){}[0] + final val props // wang.harlon.tinyui.node/UiNode.props|{}props[0] + final fun (): androidx.compose.runtime.snapshots/SnapshotStateMap // wang.harlon.tinyui.node/UiNode.props.|(){}[0] + final val type // wang.harlon.tinyui.node/UiNode.type|{}type[0] + final fun (): kotlin/String // wang.harlon.tinyui.node/UiNode.type.|(){}[0] +} + +final class wang.harlon.tinyui.schema/ComponentRegistry { // wang.harlon.tinyui.schema/ComponentRegistry|null[0] + constructor () // wang.harlon.tinyui.schema/ComponentRegistry.|(){}[0] - final fun load(wang.harlon.tinyui/RuntimeBundle, kotlin/ByteArray, wang.harlon.quickjs/JsEngineConfig = ...): wang.harlon.tinyui/PageEngine // wang.harlon.tinyui/PageEngine.Companion.load|load(wang.harlon.tinyui.RuntimeBundle;kotlin.ByteArray;wang.harlon.quickjs.JsEngineConfig){}[0] + final fun Render(kotlin/String, wang.harlon.tinyui.schema/NodeScope, androidx.compose.runtime/Composer?, kotlin/Int) // wang.harlon.tinyui.schema/ComponentRegistry.Render|Render(kotlin.String;wang.harlon.tinyui.schema.NodeScope;androidx.compose.runtime.Composer?;kotlin.Int){}[0] + final fun manifest(kotlin.collections/List = ...): kotlin/String // wang.harlon.tinyui.schema/ComponentRegistry.manifest|manifest(kotlin.collections.List){}[0] + final fun register(kotlin/String, kotlin/Function1, wang.harlon.tinyui.schema/Component) // wang.harlon.tinyui.schema/ComponentRegistry.register|register(kotlin.String;kotlin.Function1;wang.harlon.tinyui.schema.Component){}[0] + final fun schema(kotlin/String): wang.harlon.tinyui.schema/ComponentSchema? // wang.harlon.tinyui.schema/ComponentRegistry.schema|schema(kotlin.String){}[0] + + final object Companion { // wang.harlon.tinyui.schema/ComponentRegistry.Companion|null[0] + final const val PLACEHOLDER // wang.harlon.tinyui.schema/ComponentRegistry.Companion.PLACEHOLDER|{}PLACEHOLDER[0] + final fun (): kotlin/String // wang.harlon.tinyui.schema/ComponentRegistry.Companion.PLACEHOLDER.|(){}[0] + } +} + +final class wang.harlon.tinyui.schema/ComponentSchema { // wang.harlon.tinyui.schema/ComponentSchema|null[0] + final val commands // wang.harlon.tinyui.schema/ComponentSchema.commands|{}commands[0] + final fun (): kotlin.collections/Set // wang.harlon.tinyui.schema/ComponentSchema.commands.|(){}[0] + final val events // wang.harlon.tinyui.schema/ComponentSchema.events|{}events[0] + final fun (): kotlin.collections/Set // wang.harlon.tinyui.schema/ComponentSchema.events.|(){}[0] + final val props // wang.harlon.tinyui.schema/ComponentSchema.props|{}props[0] + final fun (): kotlin.collections/Map // wang.harlon.tinyui.schema/ComponentSchema.props.|(){}[0] +} + +final class wang.harlon.tinyui.schema/SchemaBuilder { // wang.harlon.tinyui.schema/SchemaBuilder|null[0] + final fun <#A1: kotlin/Enum<#A1>> enum(kotlin/String, #A1?, kotlin.collections/Map) // wang.harlon.tinyui.schema/SchemaBuilder.enum|enum(kotlin.String;0:0?;kotlin.collections.Map){0§>}[0] + final fun boolean(kotlin/String, kotlin/Boolean? = ...) // wang.harlon.tinyui.schema/SchemaBuilder.boolean|boolean(kotlin.String;kotlin.Boolean?){}[0] + final fun color(kotlin/String, androidx.compose.ui.graphics/Color? = ...) // wang.harlon.tinyui.schema/SchemaBuilder.color|color(kotlin.String;androidx.compose.ui.graphics.Color?){}[0] + final fun command(kotlin/String) // wang.harlon.tinyui.schema/SchemaBuilder.command|command(kotlin.String){}[0] + final fun dp(kotlin/String, androidx.compose.ui.unit/Dp? = ...) // wang.harlon.tinyui.schema/SchemaBuilder.dp|dp(kotlin.String;androidx.compose.ui.unit.Dp?){}[0] + final fun event(kotlin/String) // wang.harlon.tinyui.schema/SchemaBuilder.event|event(kotlin.String){}[0] + final fun number(kotlin/String, kotlin/Double? = ...) // wang.harlon.tinyui.schema/SchemaBuilder.number|number(kotlin.String;kotlin.Double?){}[0] + final fun sp(kotlin/String, androidx.compose.ui.unit/TextUnit? = ...) // wang.harlon.tinyui.schema/SchemaBuilder.sp|sp(kotlin.String;androidx.compose.ui.unit.TextUnit?){}[0] + final fun string(kotlin/String, kotlin/String? = ...) // wang.harlon.tinyui.schema/SchemaBuilder.string|string(kotlin.String;kotlin.String?){}[0] + final inline fun <#A1: reified kotlin/Enum<#A1>> enum(kotlin/String, #A1? = ...) // wang.harlon.tinyui.schema/SchemaBuilder.enum|enum(kotlin.String;0:0?){0§>}[0] +} + +final class wang.harlon.tinyui/PageFailure { // wang.harlon.tinyui/PageFailure|null[0] + constructor (kotlin/String, kotlin/String) // wang.harlon.tinyui/PageFailure.|(kotlin.String;kotlin.String){}[0] + + final val kind // wang.harlon.tinyui/PageFailure.kind|{}kind[0] + final fun (): kotlin/String // wang.harlon.tinyui/PageFailure.kind.|(){}[0] + final val message // wang.harlon.tinyui/PageFailure.message|{}message[0] + final fun (): kotlin/String // wang.harlon.tinyui/PageFailure.message.|(){}[0] +} + +final class wang.harlon.tinyui/PageHost { // wang.harlon.tinyui/PageHost|null[0] + constructor (wang.harlon.tinyui/RuntimeBundle, kotlin/ByteArray, wang.harlon.tinyui.schema/ComponentRegistry, wang.harlon.tinyui/PageSink, kotlin/String = ...) // wang.harlon.tinyui/PageHost.|(wang.harlon.tinyui.RuntimeBundle;kotlin.ByteArray;wang.harlon.tinyui.schema.ComponentRegistry;wang.harlon.tinyui.PageSink;kotlin.String){}[0] + + final val registry // wang.harlon.tinyui/PageHost.registry|{}registry[0] + final fun (): wang.harlon.tinyui.schema/ComponentRegistry // wang.harlon.tinyui/PageHost.registry.|(){}[0] + final val tree // wang.harlon.tinyui/PageHost.tree|{}tree[0] + final fun (): wang.harlon.tinyui.node/NodeTree // wang.harlon.tinyui/PageHost.tree.|(){}[0] + + final var failure // wang.harlon.tinyui/PageHost.failure|{}failure[0] + final fun (): wang.harlon.tinyui/PageFailure? // wang.harlon.tinyui/PageHost.failure.|(){}[0] + + final fun close() // wang.harlon.tinyui/PageHost.close|close(){}[0] + final fun dispatch(kotlin/Int, kotlin/String, kotlin/String) // wang.harlon.tinyui/PageHost.dispatch|dispatch(kotlin.Int;kotlin.String;kotlin.String){}[0] + final fun emit(kotlin/String, kotlin/String) // wang.harlon.tinyui/PageHost.emit|emit(kotlin.String;kotlin.String){}[0] + final fun reject(kotlin/Int, kotlin/String) // wang.harlon.tinyui/PageHost.reject|reject(kotlin.Int;kotlin.String){}[0] + final fun resolve(kotlin/Int, kotlin/String) // wang.harlon.tinyui/PageHost.resolve|resolve(kotlin.Int;kotlin.String){}[0] + final fun start() // wang.harlon.tinyui/PageHost.start|start(){}[0] + final fun visible(kotlin/Boolean) // wang.harlon.tinyui/PageHost.visible|visible(kotlin.Boolean){}[0] + + final object Companion { // wang.harlon.tinyui/PageHost.Companion|null[0] + final const val MODULE_SCHEME // wang.harlon.tinyui/PageHost.Companion.MODULE_SCHEME|{}MODULE_SCHEME[0] + final fun (): kotlin/String // wang.harlon.tinyui/PageHost.Companion.MODULE_SCHEME.|(){}[0] + final const val PROTOCOL // wang.harlon.tinyui/PageHost.Companion.PROTOCOL|{}PROTOCOL[0] + final fun (): kotlin/Int // wang.harlon.tinyui/PageHost.Companion.PROTOCOL.|(){}[0] } } @@ -27,15 +157,103 @@ final class wang.harlon.tinyui/RuntimeBundle { // wang.harlon.tinyui/RuntimeBund final fun (): kotlin/ByteArray // wang.harlon.tinyui/RuntimeBundle.native.|(){}[0] } +sealed class wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec|null[0] + final val default // wang.harlon.tinyui.schema/PropSpec.default|{}default[0] + final fun (): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.default.|(){}[0] + final val kind // wang.harlon.tinyui.schema/PropSpec.kind|{}kind[0] + final fun (): kotlin/String // wang.harlon.tinyui.schema/PropSpec.kind.|(){}[0] + + abstract fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] + + final class <#A1: kotlin/Enum<#A1>> Enum : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.Enum|null[0] + constructor (#A1?, kotlin.collections/Map) // wang.harlon.tinyui.schema/PropSpec.Enum.|(1:0?;kotlin.collections.Map){}[0] + + final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.Enum.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] + } + + final class Bool : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.Bool|null[0] + constructor (kotlin/Boolean?) // wang.harlon.tinyui.schema/PropSpec.Bool.|(kotlin.Boolean?){}[0] + + final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.Bool.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] + } + + final class ColorSpec : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.ColorSpec|null[0] + constructor (androidx.compose.ui.graphics/Color?) // wang.harlon.tinyui.schema/PropSpec.ColorSpec.|(androidx.compose.ui.graphics.Color?){}[0] + + final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.ColorSpec.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] + } + + final class Dp : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.Dp|null[0] + constructor (androidx.compose.ui.unit/Dp?) // wang.harlon.tinyui.schema/PropSpec.Dp.|(androidx.compose.ui.unit.Dp?){}[0] + + final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.Dp.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] + } + + final class Num : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.Num|null[0] + constructor (kotlin/Double?) // wang.harlon.tinyui.schema/PropSpec.Num.|(kotlin.Double?){}[0] + + final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.Num.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] + } + + final class Sp : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.Sp|null[0] + constructor (androidx.compose.ui.unit/TextUnit?) // wang.harlon.tinyui.schema/PropSpec.Sp.|(androidx.compose.ui.unit.TextUnit?){}[0] + + final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.Sp.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] + } + + final class Str : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.Str|null[0] + constructor (kotlin/String?) // wang.harlon.tinyui.schema/PropSpec.Str.|(kotlin.String?){}[0] + + final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.Str.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] + } +} + final object wang.harlon.tinyui/TinyUI { // wang.harlon.tinyui/TinyUI|null[0] final val engineVersion // wang.harlon.tinyui/TinyUI.engineVersion|{}engineVersion[0] final fun (): kotlin/String // wang.harlon.tinyui/TinyUI.engineVersion.|(){}[0] } -final val wang.harlon.tinyui/wang_harlon_tinyui_PageEngine$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_PageEngine$stableprop|#static{}wang_harlon_tinyui_PageEngine$stableprop[0] +final const val wang.harlon.tinyui.node/ROOT_ID // wang.harlon.tinyui.node/ROOT_ID|{}ROOT_ID[0] + final fun (): kotlin/Int // wang.harlon.tinyui.node/ROOT_ID.|(){}[0] + +final val wang.harlon.tinyui.node/wang_harlon_tinyui_node_Command$stableprop // wang.harlon.tinyui.node/wang_harlon_tinyui_node_Command$stableprop|#static{}wang_harlon_tinyui_node_Command$stableprop[0] +final val wang.harlon.tinyui.node/wang_harlon_tinyui_node_NodeTree$stableprop // wang.harlon.tinyui.node/wang_harlon_tinyui_node_NodeTree$stableprop|#static{}wang_harlon_tinyui_node_NodeTree$stableprop[0] +final val wang.harlon.tinyui.node/wang_harlon_tinyui_node_PatchProblem$stableprop // wang.harlon.tinyui.node/wang_harlon_tinyui_node_PatchProblem$stableprop|#static{}wang_harlon_tinyui_node_PatchProblem$stableprop[0] +final val wang.harlon.tinyui.node/wang_harlon_tinyui_node_UiNode$stableprop // wang.harlon.tinyui.node/wang_harlon_tinyui_node_UiNode$stableprop|#static{}wang_harlon_tinyui_node_UiNode$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentRegistry$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentRegistry$stableprop|#static{}wang_harlon_tinyui_schema_ComponentRegistry$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentSchema$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentSchema$stableprop|#static{}wang_harlon_tinyui_schema_ComponentSchema$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Bool$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Bool$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec_Bool$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_ColorSpec$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_ColorSpec$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec_ColorSpec$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Dp$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Dp$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec_Dp$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Enum$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Enum$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec_Enum$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Num$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Num$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec_Num$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Sp$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Sp$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec_Sp$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Str$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Str$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec_Str$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SchemaBuilder$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SchemaBuilder$stableprop|#static{}wang_harlon_tinyui_schema_SchemaBuilder$stableprop[0] +final val wang.harlon.tinyui/wang_harlon_tinyui_PageFailure$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_PageFailure$stableprop|#static{}wang_harlon_tinyui_PageFailure$stableprop[0] +final val wang.harlon.tinyui/wang_harlon_tinyui_PageHost$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_PageHost$stableprop|#static{}wang_harlon_tinyui_PageHost$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop|#static{}wang_harlon_tinyui_RuntimeBundle$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_TinyUI$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_TinyUI$stableprop|#static{}wang_harlon_tinyui_TinyUI$stableprop[0] -final fun wang.harlon.tinyui/wang_harlon_tinyui_PageEngine$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_PageEngine$stableprop_getter|wang_harlon_tinyui_PageEngine$stableprop_getter(){}[0] +final fun (wang.harlon.tinyui.schema/ComponentRegistry).wang.harlon.tinyui.components/registerBuiltins(): wang.harlon.tinyui.schema/ComponentRegistry // wang.harlon.tinyui.components/registerBuiltins|registerBuiltins@wang.harlon.tinyui.schema.ComponentRegistry(){}[0] +final fun wang.harlon.tinyui.node/wang_harlon_tinyui_node_Command$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.node/wang_harlon_tinyui_node_Command$stableprop_getter|wang_harlon_tinyui_node_Command$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.node/wang_harlon_tinyui_node_NodeTree$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.node/wang_harlon_tinyui_node_NodeTree$stableprop_getter|wang_harlon_tinyui_node_NodeTree$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.node/wang_harlon_tinyui_node_PatchProblem$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.node/wang_harlon_tinyui_node_PatchProblem$stableprop_getter|wang_harlon_tinyui_node_PatchProblem$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.node/wang_harlon_tinyui_node_UiNode$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.node/wang_harlon_tinyui_node_UiNode$stableprop_getter|wang_harlon_tinyui_node_UiNode$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentRegistry$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentRegistry$stableprop_getter|wang_harlon_tinyui_schema_ComponentRegistry$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentSchema$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentSchema$stableprop_getter|wang_harlon_tinyui_schema_ComponentSchema$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec$stableprop_getter|wang_harlon_tinyui_schema_PropSpec$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Bool$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Bool$stableprop_getter|wang_harlon_tinyui_schema_PropSpec_Bool$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_ColorSpec$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_ColorSpec$stableprop_getter|wang_harlon_tinyui_schema_PropSpec_ColorSpec$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Dp$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Dp$stableprop_getter|wang_harlon_tinyui_schema_PropSpec_Dp$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Enum$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Enum$stableprop_getter|wang_harlon_tinyui_schema_PropSpec_Enum$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Num$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Num$stableprop_getter|wang_harlon_tinyui_schema_PropSpec_Num$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Sp$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Sp$stableprop_getter|wang_harlon_tinyui_schema_PropSpec_Sp$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Str$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Str$stableprop_getter|wang_harlon_tinyui_schema_PropSpec_Str$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SchemaBuilder$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SchemaBuilder$stableprop_getter|wang_harlon_tinyui_schema_SchemaBuilder$stableprop_getter(){}[0] +final fun wang.harlon.tinyui/TinyUIPage(wang.harlon.tinyui/RuntimeBundle, kotlin/ByteArray, wang.harlon.tinyui.schema/ComponentRegistry, wang.harlon.tinyui/PageSink, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function3?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // wang.harlon.tinyui/TinyUIPage|TinyUIPage(wang.harlon.tinyui.RuntimeBundle;kotlin.ByteArray;wang.harlon.tinyui.schema.ComponentRegistry;wang.harlon.tinyui.PageSink;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function3?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_PageFailure$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_PageFailure$stableprop_getter|wang_harlon_tinyui_PageFailure$stableprop_getter(){}[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_PageHost$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_PageHost$stableprop_getter|wang_harlon_tinyui_PageHost$stableprop_getter(){}[0] final fun wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop_getter|wang_harlon_tinyui_RuntimeBundle$stableprop_getter(){}[0] final fun wang.harlon.tinyui/wang_harlon_tinyui_TinyUI$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_TinyUI$stableprop_getter|wang_harlon_tinyui_TinyUI$stableprop_getter(){}[0] diff --git a/compose/src/androidHostTest/kotlin/wang/harlon/tinyui/BundleSmokeTest.kt b/compose/src/androidHostTest/kotlin/wang/harlon/tinyui/BundleSmokeTest.kt new file mode 100644 index 0000000..435565f --- /dev/null +++ b/compose/src/androidHostTest/kotlin/wang/harlon/tinyui/BundleSmokeTest.kt @@ -0,0 +1,27 @@ +package wang.harlon.tinyui + +import wang.harlon.quickjs.JsEngine +import wang.harlon.quickjs.JsEngineConfig +import wang.harlon.quickjs.JsValue +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Loads what `tinyui build` produced for the sample through the real engine; skips when the sample was not built. */ +class BundleSmokeTest { + private val out = File("../sample/shared/build/tinyui-cli") + + @Test + fun builtBundlesLoadAndTheRuntimeComesUp() { + if (!out.isDirectory) return + JsEngine(JsEngineConfig(moduleScheme = PageHost.MODULE_SCHEME)).use { engine -> + for (fn in listOf("__host_apply", "__host_query", "__host_call", "__host_send", "__host_report")) engine.registerFunction(fn) { JsValue.Undefined } + assertEquals("@tiny-ui/core", engine.registerModule(out.resolve("runtime/core.bin").readBytes())) + assertEquals("@tiny-ui/native", engine.registerModule(out.resolve("runtime/native.bin").readBytes())) + engine.evaluateModule("import \"@tiny-ui/core\"; export const p = globalThis.__tinyui.protocol;").use { + assertEquals(JsValue.Num(PageHost.PROTOCOL), it.get("p")) + } + engine.runBytecode(out.resolve("pages/counter.bin").readBytes()).let { if (it is AutoCloseable) it.close() } + } + } +} diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageEngine.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageEngine.kt deleted file mode 100644 index 3c50c2d..0000000 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageEngine.kt +++ /dev/null @@ -1,54 +0,0 @@ -package wang.harlon.tinyui - -import wang.harlon.quickjs.JsEngine -import wang.harlon.quickjs.JsEngineConfig -import wang.harlon.quickjs.JsRef -import wang.harlon.quickjs.JsValue -import wang.harlon.quickjs.ObjectTransport - -/** Bytecode of the runtime modules every page imports, as `tinyui build` writes them under `runtime/`. */ -class RuntimeBundle(val core: ByteArray, val native: ByteArray) - -/** - * One page's engine: the runtime modules registered, the page module evaluated, its namespace held - * (K0 of docs/adr-002-bridge-communication.md). Closing it unloads the page. - */ -class PageEngine private constructor( - private val engine: JsEngine, - private val namespace: JsRef, -) : AutoCloseable { - /** Calls the page module's `default` export with [args]; objects come back as JSON. */ - fun callDefault(vararg args: JsValue): JsValue = - (namespace.get("default", ObjectTransport.REF) as JsRef).use { it.call(*args) } - - override fun close() { - namespace.close() - engine.close() - } - - companion object { - /** `import.meta.url` of a page module is `tinyui:` (docs/adr-005-engine.md). */ - const val MODULE_SCHEME = "tinyui" - - fun load( - runtime: RuntimeBundle, - page: ByteArray, - config: JsEngineConfig = JsEngineConfig(moduleScheme = MODULE_SCHEME), - ): PageEngine { - val engine = JsEngine(config) - try { - engine.registerModule(runtime.core) - engine.registerModule(runtime.native) - val namespace = engine.runBytecode(page, ObjectTransport.REF) as JsRef - if (namespace.isPromise) { - namespace.close() - error("page module is still pending after microtasks were drained; top-level await may only wait on synchronous work") - } - return PageEngine(engine, namespace) - } catch (e: Throwable) { - engine.close() - throw e - } - } - } -} diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt new file mode 100644 index 0000000..4def444 --- /dev/null +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt @@ -0,0 +1,186 @@ +package wang.harlon.tinyui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import wang.harlon.quickjs.JsEngine +import wang.harlon.quickjs.JsEngineConfig +import wang.harlon.quickjs.JsException +import wang.harlon.quickjs.JsRef +import wang.harlon.quickjs.JsRuntime +import wang.harlon.quickjs.JsValue +import wang.harlon.quickjs.ObjectTransport +import wang.harlon.tinyui.node.Command +import wang.harlon.tinyui.node.NodeTree +import wang.harlon.tinyui.node.PatchProblem +import wang.harlon.tinyui.node.UiNode +import wang.harlon.tinyui.schema.ComponentRegistry +import wang.harlon.tinyui.schema.NodeScope + +/** Bytecode of the runtime modules every page imports, as `tinyui build` writes them under `runtime/`. */ +class RuntimeBundle(val core: ByteArray, val native: ByteArray) + +/** Everything the host learns about a page's health, in one place (docs/adr-002 §3.5). */ +interface PageSink { + fun patchProblem(problem: PatchProblem) + fun businessError(entry: String, message: String, stack: String?) + fun log(line: String) +} + +/** The page's failure: engine gone, error page to be rendered by the host. */ +class PageFailure(val kind: String, val message: String) + +/** + * One page: its engine, coroutine scope and node tree (docs/adr-002 §3.4). Every K entry is + * `entry` + `flush` under one [JsRuntime.withEngine] (docs/js-runtime.html §2). + */ +class PageHost( + private val runtimeBundle: RuntimeBundle, + private val page: ByteArray, + val registry: ComponentRegistry, + private val sink: PageSink, + private val propsJson: String = "{}", +) { + val tree = NodeTree(registry, sink::patchProblem) + var failure: PageFailure? by mutableStateOf(null) + private set + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val runtime = JsRuntime(JsEngineConfig(moduleScheme = MODULE_SCHEME, logger = sink::log)) + private var entries: Entries? = null + + private class Entries(val self: JsRef, val fns: Map) { + fun call(name: String, vararg args: JsValue) = fns.getValue(name).invoke(self, args.toList()) + fun close() { fns.values.forEach { it.close() }; self.close() } + } + + fun start() { + scope.launch { + try { + runtime.withEngine { + registerHost(this) + step("registering @tiny-ui/core") { registerModule(runtimeBundle.core) } + step("registering @tiny-ui/native") { registerModule(runtimeBundle.native) } + // a registered module only runs on its first import; the runtime must be up before the page + evaluateModule("import \"@tiny-ui/core\"; import \"@tiny-ui/native\";").close() + val namespace = runBytecode(page, ObjectTransport.REF) as JsRef + if (namespace.isPromise) { namespace.close(); error("page module is still pending after microtasks were drained") } + val self = evaluate("__tinyui", objects = ObjectTransport.REF) as JsRef + val fns = ENTRY_NAMES.associateWith { self.get(it, ObjectTransport.REF) as JsRef } + val e = Entries(self, fns).also { entries = it } + val protocol = (self.get("protocol") as? JsValue.Num)?.value?.toInt() + check(protocol == PROTOCOL) { "protocol $protocol from the runtime module, host implements $PROTOCOL" } + namespace.use { ns -> + (ns.get("default", ObjectTransport.REF) as JsRef).use { default -> + e.call("mount", default, JsValue.Str(propsJson), JsValue.Str(registry.manifest())) + } + } + e.call("flush") + } + } catch (t: Throwable) { + fail("E6", t) + } + } + } + + fun dispatch(nodeId: Int, event: String, payload: String) = entry("dispatch", JsValue.Num(nodeId), JsValue.Str(event), JsValue.Str(payload)) + fun visible(visible: Boolean) = entry("visible", JsValue.Bool(visible)) + fun resolve(cbId: Int, resultJson: String) = entry("resolve", JsValue.Num(cbId), JsValue.Str(resultJson)) + fun reject(cbId: Int, errorJson: String) = entry("reject", JsValue.Num(cbId), JsValue.Str(errorJson)) + fun emit(topic: String, payloadJson: String) = entry("emit", JsValue.Str(topic), JsValue.Str(payloadJson)) + + private fun entry(name: String, vararg args: JsValue) { + if (failure != null) return + scope.launch { + try { + runtime.withEngine { + val e = entries ?: return@withEngine + e.call(name, *args) + e.call("flush") + } + } catch (t: Throwable) { + fail("E2", t) + } + } + } + + fun close() { + scope.launch { + try { + runtime.withEngine { + entries?.let { e -> runCatching { e.call("unmount") }; e.close() } + entries = null + } + } finally { + runtime.close() + } + } + scope.cancel() + tree.clear() + } + + private inline fun step(what: String, block: () -> T): T = + try { block() } catch (t: Throwable) { throw IllegalStateException("$what: ${t.message}", t) } + + private fun fail(kind: String, t: Throwable) { + val message = (t as? JsException)?.let { "${it.message}\n${it.jsStack ?: ""}" } ?: t.message ?: t.toString() + sink.log("page failed ($kind): $message") + failure = PageFailure(kind, message) + } + + private fun registerHost(engine: JsEngine) { + engine.registerFunction("__host_apply") { args -> + tree.apply((args[0] as JsValue.Str).value) + JsValue.Undefined + } + engine.registerFunction("__host_report") { args -> + val detail = (args[1] as JsValue.Str).value + sink.businessError(entry = "js", message = detail, stack = null) + JsValue.Undefined + } + engine.registerFunction("__host_query") { _ -> JsValue.Str("null") } + engine.registerFunction("__host_call") { args -> + val name = (args[0] as JsValue.Str).value + val cbId = (args[1] as JsValue.Num).value.toInt() + scope.launch { reject(cbId, """{"code":"E_UNSUPPORTED","message":"$name is not available"}""") } + JsValue.Undefined + } + engine.registerFunction("__host_send") { _ -> JsValue.Undefined } + } + + /** The scope a component renders through. */ + internal inner class Scope(override val node: UiNode) : NodeScope { + @Suppress("UNCHECKED_CAST") + override fun get(key: String): T? = (node.props[key] ?: registry.schema(node.type)?.props?.get(key)?.default) as T? + override fun has(event: String): Boolean = node.events[event] == true + override fun dispatch(event: String, payload: String) = this@PageHost.dispatch(node.id, event, payload) + override fun takeCommands(): List = node.commands.toList().also { node.commands.clear() } + override fun modifier(): Modifier = Modifier + + @Composable + override fun Children() { + for (child in node.children) key(child.id) { Render(child) } + } + } + + @Composable + internal fun Render(node: UiNode) { + registry.Render(node.type, Scope(node)) + } + + companion object { + /** `import.meta.url` of a page module is `tinyui:` (docs/adr-005-engine.md). */ + const val MODULE_SCHEME = "tinyui" + /** docs/patch-protocol.md §6 */ + const val PROTOCOL = 1 + private val ENTRY_NAMES = listOf("mount", "unmount", "visible", "dispatch", "resolve", "reject", "emit", "flush") + } +} diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUIPage.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUIPage.kt new file mode 100644 index 0000000..3ee768d --- /dev/null +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUIPage.kt @@ -0,0 +1,37 @@ +package wang.harlon.tinyui + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import wang.harlon.tinyui.schema.ComponentRegistry + +/** Renders one TinyUI page; the host owns navigation and passes the page's bytecode (docs/app-model.md). */ +@Composable +fun TinyUIPage( + runtime: RuntimeBundle, + page: ByteArray, + registry: ComponentRegistry, + sink: PageSink, + propsJson: String = "{}", + modifier: Modifier = Modifier, + error: @Composable (PageFailure) -> Unit = { Text("TinyUI page failed (${it.kind}): ${it.message}") }, +) { + val host = remember(page) { PageHost(runtime, page, registry, sink, propsJson) } + DisposableEffect(host) { + host.start() + onDispose { host.close() } + } + Box(modifier) { + val failure = host.failure + if (failure != null) { + error(failure) + } else { + for (child in host.tree.root.children) key(child.id) { host.Render(child) } + } + } +} + diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/Builtins.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/Builtins.kt new file mode 100644 index 0000000..c5029f7 --- /dev/null +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/Builtins.kt @@ -0,0 +1,37 @@ +package wang.harlon.tinyui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import wang.harlon.tinyui.schema.ComponentRegistry + +/** First batch of built-ins (roadmap C); the JSX types in packages/core mirror these schemas by hand for M1. */ +fun ComponentRegistry.registerBuiltins(): ComponentRegistry = apply { + register("Column", {}) { scope -> Column(scope.modifier()) { scope.Children() } } + register("Row", {}) { scope -> Row(scope.modifier()) { scope.Children() } } + register("Text", { + string("text", default = "") + color("color", default = Color.Unspecified) + sp("fontSize", default = TextUnit.Unspecified) + }) { scope -> + Text( + text = scope["text"] ?: "", + color = scope["color"] ?: Color.Unspecified, + fontSize = scope["fontSize"] ?: TextUnit.Unspecified, + modifier = scope.modifier(), + ) + } + register("Button", { + string("text", default = "") + event("onClick") + }) { scope -> + Button(onClick = { if (scope.has("onClick")) scope.dispatch("onClick") }, modifier = scope.modifier()) { + Text(scope["text"] ?: "") + } + } +} + diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/node/NodeTree.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/node/NodeTree.kt new file mode 100644 index 0000000..0c30125 --- /dev/null +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/node/NodeTree.kt @@ -0,0 +1,161 @@ +package wang.harlon.tinyui.node + +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import wang.harlon.tinyui.schema.ComponentRegistry + +const val ROOT_ID = 0 + +/** Reported for every op the tree could not apply (E5). The page keeps running. */ +class PatchProblem(val op: String, val reason: String) { + override fun toString(): String = "$reason: $op" +} + +/** + * The page's node table. Only the JS thread writes it, inside [apply]; the main thread only recomposes + * (docs/adr-003-kotlin-node-tree-and-registry.md §3.2). Op semantics: docs/patch-protocol.md. + */ +class NodeTree(private val registry: ComponentRegistry, private val report: (PatchProblem) -> Unit) { + val root = UiNode(ROOT_ID, "root") + private val nodes = HashMap().apply { put(ROOT_ID, root) } + + val size: Int get() = nodes.size + + fun node(id: Int): UiNode? = nodes[id] + + /** Applies one flush atomically: other threads see the tree before or after, never in between. */ + fun apply(json: String) { + val ops = try { + Json.parseToJsonElement(json).jsonArray + } catch (e: Exception) { + report(PatchProblem(json.take(200), "message is not a JSON array (${e.message})")) + return + } + Snapshot.withMutableSnapshot { + for (op in ops) applyOp(op) + } + } + + fun clear() { + root.children.clear() + nodes.clear() + nodes[ROOT_ID] = root + } + + private fun applyOp(op: JsonElement) { + val fields = op as? JsonArray ?: return report(PatchProblem(op.toString(), "op is not an array")) + val kind = fields.getOrNull(0)?.string() ?: return report(PatchProblem(op.toString(), "op without kind")) + when (kind) { + "c" -> create(fields, op) + "p" -> setProp(fields, op) + "i" -> insert(fields, op) + "m" -> move(fields, op) + "r" -> remove(fields, op) + "x" -> command(fields, op) + else -> report(PatchProblem(op.toString(), "unknown op")) + } + } + + private fun create(f: JsonArray, op: JsonElement) { + val id = f.int(1) ?: return report(PatchProblem(op.toString(), "c without id")) + val type = f.string(2) ?: return report(PatchProblem(op.toString(), "c without type")) + if (nodes.containsKey(id)) return report(PatchProblem(op.toString(), "id already exists")) + val known = registry.schema(type) != null + if (!known) report(PatchProblem(op.toString(), "unknown component type, rendering Placeholder")) + nodes[id] = UiNode(id, if (known) type else ComponentRegistry.PLACEHOLDER) + } + + private fun setProp(f: JsonArray, op: JsonElement) { + val node = target(f, op) ?: return + val key = f.string(2) ?: return report(PatchProblem(op.toString(), "p without key")) + val value = f.getOrNull(3) ?: JsonNull + val schema = registry.schema(node.type) ?: return + if (key.length > 2 && key.startsWith("on") && key[2].isUpperCase()) { + if (key !in schema.events) return report(PatchProblem(op.toString(), "event not in schema of ${node.type}")) + val flag = (value as? JsonPrimitive)?.booleanOrNull ?: return report(PatchProblem(op.toString(), "event flag must be a boolean")) + if (flag) node.events[key] = true else node.events.remove(key) + return + } + val spec = schema.props[key] ?: return report(PatchProblem(op.toString(), "prop not in schema of ${node.type}")) + if (value is JsonNull) { + node.props[key] = spec.default + return + } + val primitive = value as? JsonPrimitive ?: return report(PatchProblem(op.toString(), "prop value must be a scalar")) + val converted = spec.convert(primitive) ?: return report(PatchProblem(op.toString(), "cannot convert ${primitive} to ${spec.kind} for ${node.type}.$key")) + node.props[key] = converted + } + + private fun insert(f: JsonArray, op: JsonElement) { + val parent = f.int(1)?.let { nodes[it] } ?: return report(PatchProblem(op.toString(), "i: unknown parent")) + val node = f.int(2)?.let { nodes[it] } ?: return report(PatchProblem(op.toString(), "i: unknown node")) + if (node.parent != null) return report(PatchProblem(op.toString(), "i: node already has a parent")) + val index = clamp(f.int(3), parent.children.size, op) ?: return + node.parent = parent + parent.children.add(index, node) + } + + private fun move(f: JsonArray, op: JsonElement) { + val parent = f.int(1)?.let { nodes[it] } ?: return report(PatchProblem(op.toString(), "m: unknown parent")) + val node = f.int(2)?.let { nodes[it] } ?: return report(PatchProblem(op.toString(), "m: unknown node")) + if (node.parent !== parent) return report(PatchProblem(op.toString(), "m: node is not a child of parent")) + parent.children.remove(node) + val index = clamp(f.int(3), parent.children.size, op) ?: run { parent.children.add(node); return } + parent.children.add(index, node) + } + + private fun remove(f: JsonArray, op: JsonElement) { + val node = target(f, op) ?: return + node.parent?.children?.remove(node) + node.parent = null + forget(node) + } + + private fun command(f: JsonArray, op: JsonElement) { + val node = target(f, op) ?: return + val name = f.string(2) ?: return report(PatchProblem(op.toString(), "x without name")) + val schema = registry.schema(node.type) ?: return + if (name !in schema.commands) return report(PatchProblem(op.toString(), "command not in schema of ${node.type}")) + val args = (f.getOrNull(3) as? JsonObject)?.mapValues { (_, v) -> (v as? JsonPrimitive)?.scalar() } ?: emptyMap() + node.commands.add(Command(name, args)) + } + + private fun forget(node: UiNode) { + nodes.remove(node.id) + for (child in node.children) forget(child) + } + + private fun target(f: JsonArray, op: JsonElement): UiNode? { + val id = f.int(1) ?: run { report(PatchProblem(op.toString(), "op without id")); return null } + return nodes[id] ?: run { report(PatchProblem(op.toString(), "unknown node $id")); null } + } + + private fun clamp(index: Int?, size: Int, op: JsonElement): Int? { + if (index == null) { report(PatchProblem(op.toString(), "missing index")); return null } + if (index < 0 || index > size) { + report(PatchProblem(op.toString(), "index $index out of [0, $size], clamped")) + return index.coerceIn(0, size) + } + return index + } + + private fun JsonArray.int(i: Int): Int? = (getOrNull(i) as? JsonPrimitive)?.intOrNull + private fun JsonArray.string(i: Int): String? = (getOrNull(i) as? JsonPrimitive)?.takeIf { it.isString }?.content + private fun JsonElement.string(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content + private fun JsonPrimitive.scalar(): Any? = when { + this is JsonNull -> null + isString -> content + booleanOrNull != null -> booleanOrNull + else -> doubleOrNull + } +} diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/node/UiNode.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/node/UiNode.kt new file mode 100644 index 0000000..0b35a3d --- /dev/null +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/node/UiNode.kt @@ -0,0 +1,20 @@ +package wang.harlon.tinyui.node + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.snapshots.SnapshotStateMap + +/** One node of the page tree; the recomposition unit (docs/adr-003-kotlin-node-tree-and-registry.md). */ +class UiNode internal constructor(val id: Int, val type: String) { + /** Typed prop values, converted on write by the component's schema. */ + val props: SnapshotStateMap = mutableStateMapOf() + /** Events the JS side registered a handler for. */ + val events: SnapshotStateMap = mutableStateMapOf() + val children: SnapshotStateList = mutableStateListOf() + /** One-shot commands waiting for the component to consume them (`x` op). */ + val commands: SnapshotStateList = mutableStateListOf() + internal var parent: UiNode? = null +} + +class Command(val name: String, val args: Map) diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentRegistry.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentRegistry.kt new file mode 100644 index 0000000..2f56a4e --- /dev/null +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentRegistry.kt @@ -0,0 +1,51 @@ +package wang.harlon.tinyui.schema + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * App-level, immutable once handed to a page (docs/adr-003 §3.3). Built-in types use bare names, + * host extensions a prefix such as `pp.KycCard`. + */ +class ComponentRegistry { + private class Entry(val schema: ComponentSchema, val component: Component) + + private val entries = LinkedHashMap() + + init { + register(PLACEHOLDER, {}) { Box(it.modifier()) } + } + + fun register(type: String, schema: SchemaBuilder.() -> Unit, component: Component) { + require(type !in entries) { "component $type already registered" } + entries[type] = Entry(SchemaBuilder().apply(schema).build(), component) + } + + fun schema(type: String): ComponentSchema? = entries[type]?.schema + + @Composable + fun Render(type: String, scope: NodeScope) { + (entries[type] ?: entries.getValue(PLACEHOLDER)).component.Render(scope) + } + + /** The manifest handed to JS at mount (`hostJson`, docs/runtime-api.md §9). */ + fun manifest(capabilities: List = emptyList()): String = buildJsonObject { + put("components", JsonObject(entries.filterKeys { it != PLACEHOLDER }.mapValues { (_, e) -> + buildJsonObject { + put("props", JsonArray(e.schema.props.keys.map(::JsonPrimitive))) + put("events", JsonArray(e.schema.events.map(::JsonPrimitive))) + put("commands", JsonArray(e.schema.commands.map(::JsonPrimitive))) + } + })) + put("capabilities", JsonArray(capabilities.map(::JsonPrimitive))) + }.toString() + + companion object { + const val PLACEHOLDER = "Placeholder" + } +} diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentSchema.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentSchema.kt new file mode 100644 index 0000000..6366108 --- /dev/null +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentSchema.kt @@ -0,0 +1,87 @@ +package wang.harlon.tinyui.schema + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull + +/** How one prop crosses the bridge: its JSON scalar, the typed value it becomes, and the default `null` restores. */ +sealed class PropSpec(val kind: String, val default: Any?) { + abstract fun convert(value: JsonPrimitive): Any? + + class Str(default: String?) : PropSpec("string", default) { + override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content + } + + class Num(default: Double?) : PropSpec("number", default) { + override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.doubleOrNull + } + + class Bool(default: Boolean?) : PropSpec("boolean", default) { + override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.booleanOrNull + } + + class Dp(default: androidx.compose.ui.unit.Dp?) : PropSpec("dp", default) { + override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.doubleOrNull?.dp + } + + class Sp(default: TextUnit?) : PropSpec("sp", default) { + override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.doubleOrNull?.sp + } + + /** `#RRGGBB` or `#AARRGGBB`. */ + class ColorSpec(default: Color?) : PropSpec("color", default) { + override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content?.let(::parseColor) + } + + class Enum>(default: T?, private val values: Map) : PropSpec("enum", default) { + override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content?.let { values[it] } + } +} + +internal fun parseColor(text: String): Color? { + if (!text.startsWith("#")) return null + val hex = text.substring(1) + val argb = when (hex.length) { + 6 -> 0xFF000000L or (hex.toLongOrNull(16) ?: return null) + 8 -> hex.toLongOrNull(16) ?: return null + else -> return null + } + return Color(argb.toULong().toLong().toInt()) +} + +class ComponentSchema internal constructor( + val props: Map, + val events: Set, + val commands: Set, +) + +class SchemaBuilder internal constructor() { + private val props = LinkedHashMap() + private val events = LinkedHashSet() + private val commands = LinkedHashSet() + + fun string(name: String, default: String? = null) { props[name] = PropSpec.Str(default) } + fun number(name: String, default: Double? = null) { props[name] = PropSpec.Num(default) } + fun boolean(name: String, default: Boolean? = null) { props[name] = PropSpec.Bool(default) } + fun dp(name: String, default: Dp? = null) { props[name] = PropSpec.Dp(default) } + fun sp(name: String, default: TextUnit? = null) { props[name] = PropSpec.Sp(default) } + fun color(name: String, default: Color? = null) { props[name] = PropSpec.ColorSpec(default) } + inline fun > enum(name: String, default: T? = null) = enum(name, default, enumValues().associateBy { it.name }) + fun > enum(name: String, default: T?, values: Map) { props[name] = PropSpec.Enum(default, values) } + fun event(name: String) { events += name } + fun command(name: String) { commands += name } + + internal fun build() = ComponentSchema(props, events, commands) +} + +/** A registered component renders one node through its [NodeScope]. */ +fun interface Component { + @Composable + fun Render(scope: NodeScope) +} diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/NodeScope.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/NodeScope.kt new file mode 100644 index 0000000..dc21502 --- /dev/null +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/NodeScope.kt @@ -0,0 +1,29 @@ +package wang.harlon.tinyui.schema + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import wang.harlon.tinyui.node.Command +import wang.harlon.tinyui.node.UiNode + +/** What a component sees of its node: typed props, registered events, children (docs/adr-003 §3.3). */ +interface NodeScope { + val node: UiNode + + /** A prop declared in the schema, already converted; `null` when neither JS nor the schema set it. */ + operator fun get(key: String): T? + + /** Whether JS registered a handler for [event]; components attach gestures only when true. */ + fun has(event: String): Boolean + + /** K2: fire and forget, on any thread. */ + fun dispatch(event: String, payload: String = "{}") + + /** Takes the queued commands for this node, oldest first. */ + fun takeCommands(): List + + /** Layout modifier from the common props (empty in M1). */ + fun modifier(): Modifier + + @Composable + fun Children() +} diff --git a/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageEngineTest.kt b/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageEngineTest.kt deleted file mode 100644 index 10f4241..0000000 --- a/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageEngineTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package wang.harlon.tinyui - -import wang.harlon.quickjs.JsBytecode -import wang.harlon.quickjs.JsException -import wang.harlon.quickjs.JsValue -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertTrue - -class PageEngineTest { - private val runtime = RuntimeBundle( - core = module("@tiny-ui/core", "export const VERSION = '0.0.0'; export function h(type, props) { return { type, props }; }"), - native = module("@tiny-ui/native", "import { VERSION } from '@tiny-ui/core'; export const NATIVE = 'native ' + VERSION;"), - ) - - @Test - fun loadsRuntimeModulesThenPage() { - val page = module( - "pages/home", - """ - import { VERSION, h } from "@tiny-ui/core"; - import { NATIVE } from "@tiny-ui/native"; - export default (who) => `${'$'}{who}: core ${'$'}{VERSION}, ${'$'}{NATIVE}, at ${'$'}{import.meta.url}`; - """, - ) - PageEngine.load(runtime, page).use { engine -> - val text = engine.callDefault(JsValue.Str("hello")) as JsValue.Str - assertEquals("hello: core 0.0.0, native 0.0.0, at tinyui:pages/home", text.value) - } - } - - @Test - fun topLevelAwaitOnSynchronousWorkResolvesBeforeLoadReturns() { - val page = module("pages/async", "export const ready = await Promise.resolve(42); export default () => ready;") - PageEngine.load(runtime, page).use { engine -> - assertEquals(JsValue.Num(42.0), engine.callDefault()) - } - } - - @Test - fun pendingPageModuleIsRejected() { - val page = module("pages/stuck", "await new Promise(() => {}); export default () => 0;") - val e = assertFailsWith { PageEngine.load(runtime, page) } - assertTrue("pending" in e.message!!) - } - - @Test - fun unknownImportFailsAtLoad() { - val page = module("pages/bad", "import { x } from 'pages/other'; export default () => x;") - assertFailsWith { PageEngine.load(runtime, page) } - } - - private fun module(name: String, source: String): ByteArray = - JsBytecode.compile(source.trimIndent(), name, module = true, strip = JsBytecode.Strip.SOURCE) -} diff --git a/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageHostTest.kt b/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageHostTest.kt new file mode 100644 index 0000000..a940616 --- /dev/null +++ b/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageHostTest.kt @@ -0,0 +1,106 @@ +package wang.harlon.tinyui + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import wang.harlon.quickjs.JsBytecode +import wang.harlon.tinyui.components.registerBuiltins +import wang.harlon.tinyui.node.PatchProblem +import wang.harlon.tinyui.schema.ComponentRegistry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** Drives PageHost with a hand-written stand-in for `@tiny-ui/core`: the K/J entry shapes, not the real runtime. */ +class PageHostTest { + private val log = mutableListOf() + private val sink = object : PageSink { + override fun patchProblem(problem: PatchProblem) { log += "E5 $problem" } + override fun businessError(entry: String, message: String, stack: String?) { log += "E1 $message" } + override fun log(line: String) { log += line } + } + + private val core = module("@tiny-ui/core", """ + let patches = [], count = 0, handler = null, page = null; + globalThis.__tinyui = { + protocol: 1, + mount(p, props, host) { + page = p; const name = JSON.parse(props).name ?? "world"; + patches.push(["c",1,"Text"],["p",1,"text","hello " + name + " / " + Object.keys(JSON.parse(host).components).length], + ["c",2,"Button"],["p",2,"text","+1"],["p",2,"onClick",true],["c",3,"Column"],["i",3,1,0],["i",3,2,1],["i",0,3,0]); + handler = () => { count++; patches.push(["p",1,"text","count " + count]); }; + }, + unmount() {}, visible() {}, resolve() {}, reject() {}, emit() {}, + dispatch(id, event) { if (id === 2 && event === "onClick") handler(); if (event === "onBoom") throw new Error("boom"); }, + flush() { if (count > 2) throw new Error("render exploded"); if (patches.length) { __host_apply(JSON.stringify(patches)); patches = []; } }, + }; + export const VERSION = "stub"; + """) + private val native = module("@tiny-ui/native", "export const NATIVE = 1;") + private val page = module("pages/counter", "export default function Counter() { return 'page'; }") + + private fun host(props: String = "{}") = PageHost(RuntimeBundle(core, native), page, ComponentRegistry().registerBuiltins(), sink, props) + + // runTest's virtual time never advances the real engine thread: wait on a real dispatcher + private suspend fun PageHost.await(check: () -> Boolean) = + withContext(Dispatchers.Default) { + withTimeout(5_000) { + while (!check()) { + failure?.let { if (!check()) error("page failed: ${it.message}\n${log.joinToString("\n")}") } + delay(10) + } + } + } + + @Test + fun mountsThroughTheEngineAndRendersFromTheTree() = runTest { + val host = host("""{"name":"tinyui"}""") + host.start() + host.await { host.tree.root.children.isNotEmpty() } + assertEquals("hello tinyui / 4", host.tree.node(1)!!.props["text"]) + assertNull(host.failure) + host.close() + } + + @Test + fun dispatchIsATransactionEndingInFlush() = runTest { + val host = host() + host.start() + host.await { host.tree.root.children.isNotEmpty() } + host.dispatch(2, "onClick", "{}") + host.await { host.tree.node(1)!!.props["text"] == "count 1" } + host.dispatch(2, "onClick", "{}") + host.await { host.tree.node(1)!!.props["text"] == "count 2" } + assertNull(host.failure) + host.close() + } + + @Test + fun renderErrorsFailThePage() = runTest { + val host = host() + host.start() + host.await { host.tree.root.children.isNotEmpty() } + repeat(3) { host.dispatch(2, "onClick", "{}") } + host.await { host.failure != null } + val failure = assertNotNull(host.failure) + assertEquals("E2", failure.kind) + assertEquals(true, "render exploded" in failure.message) + host.close() + } + + @Test + fun protocolMismatchIsE6() = runTest { + val badCore = module("@tiny-ui/core", "globalThis.__tinyui = { protocol: 99 }; export const VERSION = 'x';") + val host = PageHost(RuntimeBundle(badCore, native), page, ComponentRegistry().registerBuiltins(), sink) + host.start() + host.await { host.failure != null } + assertEquals("E6", host.failure!!.kind) + host.close() + } + + private fun module(name: String, source: String): ByteArray = + JsBytecode.compile(source.trimIndent(), name, module = true, strip = JsBytecode.Strip.SOURCE) +} diff --git a/compose/src/commonTest/kotlin/wang/harlon/tinyui/node/NodeTreeTest.kt b/compose/src/commonTest/kotlin/wang/harlon/tinyui/node/NodeTreeTest.kt new file mode 100644 index 0000000..47459b8 --- /dev/null +++ b/compose/src/commonTest/kotlin/wang/harlon/tinyui/node/NodeTreeTest.kt @@ -0,0 +1,63 @@ +package wang.harlon.tinyui.node + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.sp +import wang.harlon.tinyui.components.registerBuiltins +import wang.harlon.tinyui.schema.ComponentRegistry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NodeTreeTest { + private val problems = mutableListOf() + private val tree = NodeTree(ComponentRegistry().registerBuiltins()) { problems += it } + + private fun ids(node: UiNode) = node.children.map { it.id } + + @Test + fun mountsTheCounterTree() { + tree.apply("""[["c",1,"Text"],["p",1,"text","Count: 0"],["c",2,"Button"],["p",2,"text","+1"],["p",2,"onClick",true],["c",3,"Column"],["i",3,1,0],["i",3,2,1],["i",0,3,0]]""") + assertEquals(listOf(3), ids(tree.root)) + assertEquals(listOf(1, 2), ids(tree.node(3)!!)) + assertEquals("Count: 0", tree.node(1)!!.props["text"]) + assertEquals(true, tree.node(2)!!.events["onClick"]) + assertTrue(problems.isEmpty(), problems.toString()) + } + + @Test + fun convertsPropsOnWriteAndRestoresDefaultsOnNull() { + tree.apply("""[["c",1,"Text"],["p",1,"color","#FF0000"],["p",1,"fontSize",18],["i",0,1,0]]""") + val node = tree.node(1)!! + assertEquals(Color(0xFFFF0000), node.props["color"]) + assertEquals(18.sp, node.props["fontSize"]) + tree.apply("""[["p",1,"color",null]]""") + assertEquals(Color.Unspecified, node.props["color"]) + assertTrue(problems.isEmpty(), problems.toString()) + } + + @Test + fun moveRemoveAndCommandsFollowTheProtocol() { + tree.apply("""[["c",1,"Column"],["c",2,"Text"],["c",3,"Text"],["c",4,"Text"],["i",1,2,0],["i",1,3,1],["i",1,4,2],["i",0,1,0]]""") + tree.apply("""[["m",1,4,0]]""") + assertEquals(listOf(4, 2, 3), ids(tree.node(1)!!)) + tree.apply("""[["r",2]]""") + assertEquals(listOf(4, 3), ids(tree.node(1)!!)) + assertNull(tree.node(2)) + tree.apply("""[["r",1]]""") + assertEquals(1, tree.size, "removing a subtree forgets every descendant") + assertTrue(problems.isEmpty(), problems.toString()) + } + + @Test + fun badOpsAreSkippedAndReported() { + tree.apply("""[["c",1,"Text"],["p",1,"nope","x"],["p",1,"fontSize","big"],["p",1,"onTap",true],["p",99,"text","x"],["c",2,"pp.Unknown"],["i",0,2,7],["x",1,"focus",{}],["i",0,1,0]]""") + val expected = listOf("prop not in schema", "cannot convert", "event not in schema", "unknown node 99", "unknown component type", "index 7 out of", "command not in schema") + assertEquals(expected.size, problems.size, problems.toString()) + for ((reason, prefix) in problems.map { it.reason }.zip(expected)) assertTrue(reason.startsWith(prefix), "$reason should start with $prefix") + assertEquals(ComponentRegistry.PLACEHOLDER, tree.node(2)!!.type) + assertEquals(listOf(1, 2), ids(tree.root), "the clamped insert (7 → 0 on an empty root) still lands") + tree.apply("not json") + assertEquals("message is not a JSON array", problems.last().reason.substringBefore(" (")) + } +} diff --git a/docs/jsx-transform.md b/docs/jsx-transform.md index 3a13628..9f35916 100644 --- a/docs/jsx-transform.md +++ b/docs/jsx-transform.md @@ -65,6 +65,7 @@ h(Text, { text: thunk(() => "Count: " + count()), color: thunk(() => theme.prima ## 4. 元素类型 - 小写标签 `` 不支持(不是 DOM),首字母大写视为标识符:`` 变成 `h(Text, …)`,`Text` 由 `@tiny-ui/core` 导出,是值为 `"Text"` 的字符串常量(TS 允许字符串字面量类型的标识符做元素名,prop 类型从 `JSX.IntrinsicElements["Text"]` 查,由 schema 生成,roadmap C 组第 4 项);宿主扩展 `` 变成 `h(pp.KycCard, …)`,`pp` 由宿主 App 自己的类型包导出 +- 类型检查用 `"jsx": "react-jsx"` + `"jsxImportSource": "@tiny-ui/core"`:TS 从 `@tiny-ui/core/jsx-runtime` 取 `JSX` 命名空间,业务文件不用 import `h`;产物仍由 esbuild 按 `jsxFactory: "h"` 生成(TS 只管类型,不参与产出) - `<>…` 变成 `h(Fragment, null, …)` - 组件 `` 与内置元素规则相同:属性照 §2 包裹,运行时把 thunk 转成 getter 交给组件 diff --git a/docs/roadmap.md b/docs/roadmap.md index dd3b8c4..865f0c6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -18,7 +18,8 @@ |---|---|---|---| | 真机比值:Signal bench 在 Android / iOS 跑一遍(QuickJS) | ADR-001 / 005 | 所有性能判断都基于 macOS 数字,真机慢几倍要知道 | 待开 | | Runtime 创建 + 运行时模块 + 页面模块加载耗时;Runtime 基线内存 | ADR-002 / 005 | 决定"每页一 Runtime"的进页延迟与返回栈深度 | 待开 | -| JS 线程写快照 vs 主线程重组的锁争用 | ADR-003 | 有问题要退回 op 列表投主线程,越早知道改动越小 | 待开 | +| JS 线程写快照 vs 主线程重组的锁争用 | ADR-003 | 有问题要退回 op 列表投主线程,越早知道改动越小 | 待开(M1 的 `NodeTree.apply` 已按 `withMutableSnapshot` 写,可直接测) | +| Kotlin 侧流式 patch 解析器 | ADR-003 §3.2 | M1 用 kotlinx `parseToJsonElement` 建树,ADR 定的是逐 token 直写节点表;B 组真机数据出来再决定值不值得 | 待开 | | "一行一个 effect"内存优化 | ADR-001 | 非阻塞,可最后做 | 待开 | ## C. 实现期定稿(要写文档,不需要 ADR,实现前必须定) @@ -58,5 +59,5 @@ 1. **A 立项骨架**——先 quickjs-kmp(M1~M3),再 TinyUI 构建链,否则 B、C 都没有落点 2. **B 前三项并行**——半天到一天的实验,结果决定 C 里几个数值和 ADR-003 的退路 3. **C 前三项定稿**(运行时 API、patch 协议、schema DSL)——两侧代码的契约,定了才能分头写 -4. **M1:Counter 端到端**——一个引擎、一页、一个 `Text` + 一个 `Button`,J1 / K1 / K2 全链路跑通,验证 ADR-001~004 主干 +4. ~~**M1:Counter 端到端**——一个引擎、一页、一个 `Text` + 一个 `Button`,J1 / K1 / K2 全链路跑通,验证 ADR-001~004 主干~~ 已完成(PR #3,2026-09-16):`@tiny-ui/core` 运行时(30 测试)、CLI JSX 变换(19 测试)、compose 节点表 / schema DSL / 注册表 / `PageHost` / `TinyUIPage`(9 测试),Counter 在 Android 与 iOS 模拟器上跑通。顺带修了 quickjs-kmp 的字节码注册入口不刷新栈顶的 bug(quickjs-kmp PR #12),**真机需要 quickjs-kmp ≥ 0.1.1**,发版后 bump catalog 5. **C 剩余项 + 内置组件集,M2:列表页**——`For` / `LazyColumn` / `TextField` / J3 网络,覆盖所有权、命令、流式输入 diff --git a/packages/cli/package.json b/packages/cli/package.json index ca8cc97..f827bbe 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -27,7 +27,9 @@ "tinyui": "./dist/bin.js" }, "dependencies": { - "esbuild": "0.28.2" + "esbuild": "0.28.2", + "magic-string": "1.4.1", + "oxc-parser": "0.150.0" }, "devDependencies": { "@tiny-ui/core": "workspace:*", diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index fb961d7..b41f64a 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -1,7 +1,8 @@ import { build as esbuild, type Plugin } from "esbuild"; -import { mkdir, readdir, writeFile } from "node:fs/promises"; +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join, relative, resolve, sep } from "node:path"; import { compileModule, findQjsc } from "./qjsc.ts"; +import { TransformError, transformJsx } from "./transform.ts"; export const RUNTIME_MODULES = ["@tiny-ui/core", "@tiny-ui/native"] as const; @@ -47,6 +48,9 @@ export async function build(options: BuildOptions): Promise { throw new Error("qjsc-kmp not found: pass --qjsc, set TINYUI_QJSC, or put it on PATH (or use --js-only)"); } + // stale outputs would otherwise be packaged along with the current pages + await rm(join(out, "pages"), { recursive: true, force: true }); + await rm(join(out, "runtime"), { recursive: true, force: true }); const runtime = await bundleRuntime(root, out); const pages = await bundlePages(root, out, pageNames); if (qjsc) { @@ -117,6 +121,7 @@ async function bundlePages(root: string, out: string, pages: Map } const JSX_SHIM = "tinyui:jsx-shim"; +const TSX = /\.tsx$/; /** `inject` wants a module; this serves one in memory so pages get `h` / `Fragment` from the runtime module. */ const pagePlugin: Plugin = { @@ -126,9 +131,23 @@ const pagePlugin: Plugin = { // Runtime modules stay bare specifiers and are pure, so a page that never uses JSX keeps no import of h api.onResolve({ filter: /^@tiny-ui\// }, (args) => ({ path: args.path, external: true, sideEffects: false })); api.onLoad({ filter: /.*/, namespace: "tinyui" }, () => ({ - contents: 'export { h, Fragment } from "@tiny-ui/core";', + contents: 'export { h, Fragment, thunk } from "@tiny-ui/core";', loader: "js", })); + // docs/jsx-transform.md: wrap dynamic attributes before esbuild turns JSX into h() + api.onLoad({ filter: TSX }, async (args) => { + const source = await readFile(args.path, "utf8"); + try { + const { code, map } = transformJsx(relative(api.initialOptions.absWorkingDir ?? "", args.path), source); + const inline = Buffer.from(map).toString("base64"); + return { contents: `${code}\n//# sourceMappingURL=data:application/json;base64,${inline}`, loader: "tsx" }; + } catch (e) { + if (e instanceof TransformError) { + return { errors: [{ text: e.message.slice(e.message.indexOf(": ") + 2), location: { file: args.path, line: e.line, column: e.column - 1 } }] }; + } + throw e; + } + }); }, }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 770f091..6f335a8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,3 +2,5 @@ export { build, RUNTIME_MODULES } from "./build.ts"; export type { BuildOptions, BuildResult, BuiltModule, Manifest } from "./build.ts"; export { compileModule, findQjsc } from "./qjsc.ts"; export type { CompileOptions } from "./qjsc.ts"; +export { transformJsx, TransformError } from "./transform.ts"; +export type { TransformResult } from "./transform.ts"; diff --git a/packages/cli/src/transform.ts b/packages/cli/src/transform.ts new file mode 100644 index 0000000..b8dbc20 --- /dev/null +++ b/packages/cli/src/transform.ts @@ -0,0 +1,145 @@ +import MagicString from "magic-string"; +import { parseSync } from "oxc-parser"; + +/** Attributes the compiler never wraps: docs/jsx-transform.md §2. */ +const NEVER_WRAP = /^(ref|key|children|on[A-Z].*)$/; + +export interface TransformResult { + code: string; + /** JSON source map, v3; `sources` holds the input path. */ + map: string; +} + +export class TransformError extends Error { + readonly file: string; + readonly line: number; + readonly column: number; + constructor(file: string, line: number, column: number, message: string) { + super(`${file}:${line}:${column}: ${message}`); + this.file = file; + this.line = line; + this.column = column; + } +} + +/** + * Wraps dynamic JSX attribute expressions in `thunk(() => …)` and rejects children shapes the + * runtime cannot handle (docs/jsx-transform.md). JSX → `h()` and type erasure stay with esbuild. + */ +export function transformJsx(file: string, source: string): TransformResult { + const { program, errors } = parseSync(file, source, { lang: "tsx" }); + if (errors.length > 0) { + const e = errors[0]!; + const at = e.labels?.[0]?.start ?? 0; + const { line, column } = position(source, at); + throw new TransformError(file, line, column, e.message); + } + const ms = new MagicString(source); + const ctx = { file, source, ms }; + walk(program as unknown as AnyNode, (node) => { + if (node.type === "JSXElement") { + for (const attr of (node["openingElement"] as AnyNode)["attributes"] as AnyNode[]) visitAttribute(ctx, attr); + visitChildren(ctx, node["children"] as AnyNode[]); + } else if (node.type === "JSXFragment") { + visitChildren(ctx, node["children"] as AnyNode[]); + } + }); + return { + code: ms.toString(), + map: ms.generateMap({ source: file, includeContent: true, hires: "boundary" }).toString(), + }; +} + +interface Ctx { file: string; source: string; ms: MagicString } +type AnyNode = { type: string; start: number; end: number; [k: string]: unknown }; + +function visitAttribute(ctx: Ctx, attr: AnyNode) { + if (attr.type === "JSXSpreadAttribute") { + fail(ctx, attr, "spread attributes are not supported: spreading evaluates getters into a snapshot; list the props explicitly"); + } + const name = attributeName(attr); + const value = attr["value"] as AnyNode | null; + if (!value || value.type !== "JSXExpressionContainer") return; + const expr = value["expression"] as AnyNode; + if (expr.type === "JSXEmptyExpression") return; + if (NEVER_WRAP.test(name)) return; + if (!isDynamic(expr)) return; + ctx.ms.prependLeft(expr.start, "thunk(() => "); + ctx.ms.appendRight(expr.end, ")"); +} + +function visitChildren(ctx: Ctx, children: AnyNode[]) { + for (const child of children) { + if (child.type === "JSXText") { + if ((ctx.source.slice(child.start, child.end)).trim() !== "") { + fail(ctx, child, "text children are not supported; pass text through a prop such as text=\"…\""); + } + } else if (child.type === "JSXExpressionContainer") { + const expr = child["expression"] as AnyNode; + if (expr.type === "JSXEmptyExpression") continue; + if (expr.type !== "ArrowFunctionExpression" && expr.type !== "FunctionExpression") { + fail(ctx, child, "dynamic children are not supported; use for conditions, for lists, and a text prop for text"); + } + } + } +} + +function attributeName(attr: AnyNode): string { + const n = attr["name"] as AnyNode; + if (n.type === "JSXNamespacedName") return `${(n["namespace"] as AnyNode)["name"]}:${(n["name"] as AnyNode)["name"]}`; + return n["name"] as string; +} + +/** True when the expression can read a signal: it contains a call or a property access outside any function body. */ +function isDynamic(node: AnyNode): boolean { + switch (node.type) { + case "CallExpression": + case "MemberExpression": + case "ChainExpression": + case "NewExpression": + case "TaggedTemplateExpression": + return true; + case "ArrowFunctionExpression": + case "FunctionExpression": + case "JSXElement": + case "JSXFragment": + case "Literal": + case "Identifier": + case "ThisExpression": + return false; + } + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const item of value) if (isNode(item) && isDynamic(item)) return true; + } else if (isNode(value) && isDynamic(value)) { + return true; + } + } + return false; +} + +function walk(node: AnyNode, visit: (n: AnyNode) => void) { + visit(node); + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const item of value) if (isNode(item)) walk(item, visit); + } else if (isNode(value)) { + walk(value, visit); + } + } +} + +function isNode(v: unknown): v is AnyNode { + return typeof v === "object" && v !== null && typeof (v as AnyNode).type === "string" && typeof (v as AnyNode).start === "number"; +} + +function fail(ctx: Ctx, node: AnyNode, message: string): never { + const { line, column } = position(ctx.source, node.start); + throw new TransformError(ctx.file, line, column, message); +} + +function position(source: string, offset: number): { line: number; column: number } { + let line = 1, last = 0; + for (let i = 0; i < offset; i++) if (source.charCodeAt(i) === 10) { line++; last = i + 1; } + return { line, column: offset - last + 1 }; +} diff --git a/packages/cli/test/build.test.ts b/packages/cli/test/build.test.ts index 77f155e..e128bdd 100644 --- a/packages/cli/test/build.test.ts +++ b/packages/cli/test/build.test.ts @@ -27,8 +27,9 @@ describe("tinyui build", () => { it("turns JSX into h() calls with the factory imported from @tiny-ui/core", async () => { const js = await readFile(join(out, "pages", "home.js"), "utf8"); - assert.match(js, /import \{ h, Fragment \} from "@tiny-ui\/core"/); + assert.match(js, /import \{ h, Fragment, thunk \} from "@tiny-ui\/core"/); assert.match(js, /h\(Column, null, .*h\(Text, \{ text: label \}\)/s); + assert.match(js, /h\(Text, \{ text: thunk\(\(\) => title\(name\)\) \}\)/, "call expressions are wrapped"); assert.doesNotMatch(js, /interface Props|: Props/, "types are erased"); }); diff --git a/packages/cli/test/fixtures/app/src/pages/home.tsx b/packages/cli/test/fixtures/app/src/pages/home.tsx index 292ef66..a4a7f40 100644 --- a/packages/cli/test/fixtures/app/src/pages/home.tsx +++ b/packages/cli/test/fixtures/app/src/pages/home.tsx @@ -5,5 +5,5 @@ interface Props { name: string } export default function Home({ name }: Props) { const label = title(name); - return ; + return ; } diff --git a/packages/cli/test/transform.test.ts b/packages/cli/test/transform.test.ts new file mode 100644 index 0000000..6ff04b4 --- /dev/null +++ b/packages/cli/test/transform.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { transformJsx, TransformError } from "../src/transform.ts"; + +const t = (jsx: string) => transformJsx("page.tsx", `export default () => ${jsx};`).code; + +describe("thunk wrapping", () => { + it("wraps calls and member access, leaves identifiers and literals", () => { + assert.match(t(``), /text=\{thunk\(\(\) => "n: " \+ count\(\)\)\}/); + assert.match(t(``), /thunk\(\(\) => props\.title\)/); + assert.match(t(``), /thunk\(\(\) => items\(\)!\)/); + assert.doesNotMatch(t(``), /thunk/); + assert.doesNotMatch(t(""), /thunk/); + }); + it("descends into conditionals, templates and object literals but not function bodies", () => { + assert.match(t(``), /thunk/); + assert.match(t(""), /thunk/); + assert.match(t(``), /style=\{thunk\(\(\) => \{ padding: gap\(\) \}\)\}/); + assert.doesNotMatch(t(``), /thunk/); + assert.doesNotMatch(t(` fmt(n)} />`), /thunk/); + }); + it("never wraps ref, key, children and on* attributes", () => { + const code = t(` it.id} ref={r()} onClick={handlers.get()} />`); + assert.match(code, /each=\{thunk/); + assert.doesNotMatch(code, /key=\{thunk|ref=\{thunk|onClick=\{thunk/); + }); + it("applies to components the same way", () => { + assert.match(t(``), /todo=\{thunk\(\(\) => todo\(\)\)\}/); + }); +}); + +describe("rejected shapes", () => { + const rejects = (jsx: string, re: RegExp) => + assert.throws(() => t(jsx), (e: unknown) => e instanceof TransformError && re.test(e.message)); + it("spread attributes", () => rejects(``, /spread/)); + it("expression children", () => { + rejects(`{cond && }`, /dynamic children/); + rejects(`{list.map((x) => )}`, /dynamic children/); + rejects(`{value}`, /dynamic children/); + }); + it("text children", () => rejects(`hi`, /text children/)); + it("allows a render function child and whitespace", () => { + assert.doesNotThrow(() => t(`\n {() => }\n`)); + }); + it("reports the location", () => { + try { transformJsx("p.tsx", "const a = 1;\nconst b = hi;"); assert.fail(); } + catch (e) { assert.equal((e as TransformError).message.slice(0, 9), "p.tsx:2:1"); } + }); +}); + +describe("source map", () => { + it("maps back to the tsx file with content", () => { + const { map } = transformJsx("pages/home.tsx", `export default () => ;`); + const parsed = JSON.parse(map); + assert.deepEqual(parsed.sources, ["pages/home.tsx"]); + assert.ok(parsed.sourcesContent[0].includes("count()")); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 93723b0..6ce3e72 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,6 +8,14 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./jsx-runtime": { + "types": "./dist/jsx-runtime.d.ts", + "default": "./dist/jsx-runtime.js" + }, + "./jsx-dev-runtime": { + "types": "./dist/jsx-runtime.d.ts", + "default": "./dist/jsx-runtime.js" } }, "files": [ @@ -16,7 +24,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --test --experimental-strip-types 'test/**/*.test.ts'" + "test": "node --test 'test/**/*.test.ts'" }, "repository": { "type": "git", diff --git a/packages/core/src/components.ts b/packages/core/src/components.ts new file mode 100644 index 0000000..60db75e --- /dev/null +++ b/packages/core/src/components.ts @@ -0,0 +1,7 @@ +// Built-in component names for JSX; the prop types live in jsx-runtime.ts, hand-written for M1 and generated from schema/ later (roadmap C). +export const Column = "Column"; +export const Row = "Row"; +export const Text = "Text"; +export const Button = "Button"; + +export interface ClickPayload {} diff --git a/packages/core/src/control.ts b/packages/core/src/control.ts new file mode 100644 index 0000000..883a249 --- /dev/null +++ b/packages/core/src/control.ts @@ -0,0 +1,92 @@ +// For / Show: docs/runtime-api.md §4; slot mechanics in node.ts. +import { disposeOwner, effect, onCleanup, signal, untrack, type Owner } from "./reactive.ts"; +import { CONTROL, renderInOwner, Slot, type Component, type Node } from "./node.ts"; + +export interface ShowProps { + when: T; + children: () => Node; + fallback?: () => Node; +} + +export const Show: Component> = Object.assign(function Show(props: ShowProps): Node { + const slot = new Slot(); + let current: { owner: Owner; node: number } | null = null; + let shown: boolean | undefined; + effect(() => { + const truthy = !!props.when; + if (truthy === shown) return; + shown = truthy; + untrack(() => { + if (current) { + disposeOwner(current.owner); + slot.remove(current.node); + current = null; + } + const render = truthy ? props.children : props.fallback; + if (render) { + current = renderInOwner(render, truthy ? " children" : " fallback"); + slot.insert(current.node, 0); + } + }); + }); + onCleanup(() => { if (current) disposeOwner(current.owner); }); + return slot as unknown as Node; +}, { [CONTROL]: true as const }); + +export interface ForProps { + each: T[]; + key: (item: T, index: number) => string | number; + children: (item: () => T, index: () => number) => Node; +} + +interface Row { + owner: Owner; + node: number; + setItem: (v: T) => void; + setIndex: (i: number) => void; +} + +export const For: Component> = Object.assign(function For(props: ForProps): Node { + const slot = new Slot(); + const rows = new Map>(); + let order: (string | number)[] = []; + effect(() => { + const list = props.each ?? []; + const keys: (string | number)[] = []; + const seen = new Set(); + for (let i = 0; i < list.length; i++) { + const k = props.key(list[i]!, i); + if (seen.has(k)) throw new Error(` duplicate key ${String(k)}`); + seen.add(k); + keys.push(k); + } + untrack(() => { + for (const k of order) { + if (!seen.has(k)) { + const row = rows.get(k)!; + disposeOwner(row.owner); + slot.remove(row.node); + rows.delete(k); + } + } + for (let i = 0; i < keys.length; i++) { + const k = keys[i]!, item = list[i]!; + const row = rows.get(k); + if (row) { + row.setItem(item); + row.setIndex(i); + if (slot.nodes[i] !== row.node) slot.move(row.node, i); + } else { + const [getItem, setItem] = signal(item); + const [getIndex, setIndex] = signal(i); + const { owner, node } = renderInOwner(() => props.children(getItem, getIndex), " row"); + rows.set(k, { owner, node, setItem, setIndex }); + slot.insert(node, i); + } + } + order = keys; + }); + }); + onCleanup(() => { for (const row of rows.values()) disposeOwner(row.owner); }); + return slot as unknown as Node; +}, { [CONTROL]: true as const }); diff --git a/packages/core/src/host.ts b/packages/core/src/host.ts new file mode 100644 index 0000000..709abac --- /dev/null +++ b/packages/core/src/host.ts @@ -0,0 +1,76 @@ +// JS → Kotlin entries and the pending table: docs/runtime-api.md §9. +declare const __host_apply: (patchJson: string) => void; +declare const __host_query: (name: string, argsJson: string) => string; +declare const __host_call: (name: string, cbId: number, argsJson: string) => void; +declare const __host_send: (name: string, argsJson: string) => void; +declare const __host_report: (kind: string, detailJson: string) => void; + +interface Pending { + resolve: (value: unknown) => void; + reject: (error: HostError) => void; +} + +export class HostError extends Error { + readonly code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + } +} + +const pending = new Map(); +let nextCbId = 1; + +export function apply(patchJson: string): void { + __host_apply(patchJson); +} + +/** J2: synchronous whitelisted query; the host answers with JSON text. */ +export function query(name: string, args: Record = {}): T { + return JSON.parse(__host_query(name, JSON.stringify(args))) as T; +} + +/** J3: asynchronous capability; settles when the host calls K3. */ +export function call(name: string, args: Record = {}): Promise { + const cbId = nextCbId++; + return new Promise((resolve, reject) => { + pending.set(cbId, { resolve: resolve as (v: unknown) => void, reject }); + __host_call(name, cbId, JSON.stringify(args)); + }); +} + +/** J4: fire and forget. */ +export function send(name: string, args: Record = {}): void { + __host_send(name, JSON.stringify(args)); +} + +export function report(kind: "E1", detail: { entry: string; message: string; stack?: string }): void { + __host_report(kind, JSON.stringify(detail)); +} + +export function resolvePending(cbId: number, resultJson: string): void { + const p = pending.get(cbId); + if (!p) return; + pending.delete(cbId); + p.resolve(resultJson === "" ? undefined : JSON.parse(resultJson)); +} + +export function rejectPending(cbId: number, errorJson: string): void { + const p = pending.get(cbId); + if (!p) return; + pending.delete(cbId); + const e = JSON.parse(errorJson) as { code?: string; message?: string }; + p.reject(new HostError(e.code ?? "unknown", e.message ?? "host call failed")); +} + +export function cancelPending(cbId: number): void { + pending.delete(cbId); +} + +export function allocateCbId(): number { + return nextCbId++; +} + +export function registerPending(cbId: number, p: Pending): void { + pending.set(cbId, p); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e487cda..6ad173a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,16 @@ export const VERSION = "0.0.0"; +export { PROTOCOL, pageVisible, host, onEmit } from "./page.ts"; +export type { HostManifest } from "./page.ts"; +export { signal, memo, effect, onCleanup, untrack } from "./reactive.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 type { ResourceActions } from "./resource.ts"; +export { call, query, send, HostError } from "./host.ts"; +export { Column, Row, Text, Button } from "./components.ts"; +export type { ClickPayload } from "./components.ts"; // Globals the engine provides to page code; there is no DOM lib to declare them (docs/adr-005-engine.md). declare global { diff --git a/packages/core/src/jsx-runtime.ts b/packages/core/src/jsx-runtime.ts new file mode 100644 index 0000000..0ea2a29 --- /dev/null +++ b/packages/core/src/jsx-runtime.ts @@ -0,0 +1,27 @@ +// Type-check entry for `jsx: "react-jsx"` + `jsxImportSource: "@tiny-ui/core"`; the CLI still emits h() calls +// (docs/jsx-transform.md). The runtime functions exist so a react-jsx transform would also work. +import { Fragment as FragmentImpl, h, type Node, type Props } from "./node.ts"; +import type { ClickPayload } from "./components.ts"; + +export function jsx(type: string | ((props: Props) => Node), props: Props & { children?: unknown }): Node { + const { children, ...rest } = props; + return h(type, rest, ...(Array.isArray(children) ? (children as Node[]) : children === undefined ? [] : [children as Node])); +} + +export { jsx as jsxs, jsx as jsxDEV }; +export const Fragment = FragmentImpl; + +interface Container { + children?: Node | Node[]; +} + +export declare namespace JSX { + type Element = Node; + interface ElementChildrenAttribute { children: {} } + interface IntrinsicElements { + Column: Container; + Row: Container; + Text: { text: string; color?: string; fontSize?: number }; + Button: { text: string; onClick?: (e: ClickPayload) => void }; + } +} diff --git a/packages/core/src/node.ts b/packages/core/src/node.ts new file mode 100644 index 0000000..2220674 --- /dev/null +++ b/packages/core/src/node.ts @@ -0,0 +1,207 @@ +// Node ids, slots, handlers and the patch buffer: docs/patch-protocol.md; docs/runtime-api.md §3. +import { createOwner, disposeOwner, effect, insideRender, onCleanup, runWithOwner, type Owner } from "./reactive.ts"; + +/** A node handle. At runtime it is the node id. */ +export type Node = number & { readonly __tinyuiNode: unique symbol }; + +export type Scalar = string | number | boolean | null | undefined; +export type Props = Record; +export type Child = Node | Slot | Node[] | Slot[] | null | undefined | false; +export type Component

= ((props: P) => Node) & { [CONTROL]?: true }; + +export const CONTROL: unique symbol = Symbol("tinyui.control"); + +export class Thunk { + readonly fn: () => T; + constructor(fn: () => T) { + this.fn = fn; + } +} + +/** Marks a dynamic prop; the CLI inserts it (docs/jsx-transform.md), business code rarely writes it. */ +export function thunk(fn: () => T): T { + return new Thunk(fn) as unknown as T; +} + +export const patches: unknown[][] = []; +export const handlers = new Map void>(); +let nextId = 1; + +export function resetIds(): void { + nextId = 1; +} + +interface SlotEntry { + length: number; +} + +/** A variable-length range in a parent's children, owned by For / Show. */ +export class Slot implements SlotEntry { + parent = 0; + position = -1; + length = 0; + readonly nodes: number[] = []; + private entries: SlotEntry[] | null = null; + + mount(parent: number, entries: SlotEntry[], position: number): void { + this.parent = parent; + this.entries = entries; + this.position = position; + for (let i = 0; i < this.nodes.length; i++) patches.push(["i", parent, this.nodes[i], this.offset() + i]); + } + + insert(node: number, at: number): void { + this.nodes.splice(at, 0, node); + this.length = this.nodes.length; + if (this.entries) patches.push(["i", this.parent, node, this.offset() + at]); + } + + move(node: number, to: number): void { + const from = this.nodes.indexOf(node); + if (from < 0) throw new Error("move: node not in slot"); + this.nodes.splice(from, 1); + this.nodes.splice(to, 0, node); + if (this.entries) patches.push(["m", this.parent, node, this.offset() + to]); + } + + remove(node: number): void { + const at = this.nodes.indexOf(node); + if (at < 0) throw new Error("remove: node not in slot"); + this.nodes.splice(at, 1); + this.length = this.nodes.length; + if (this.entries) patches.push(["r", node]); + } + + private offset(): number { + let n = 0; + for (let i = 0; i < this.position; i++) n += this.entries![i]!.length; + return n; + } +} + +export interface Ref { + cmd(name: string, args?: Record): void; +} + +export function ref(): Ref { + const r = { id: 0, cmd(name: string, args: Record = {}) { + if (r.id === 0) throw new Error("ref.cmd() before the ref was attached to a node"); + patches.push(["x", r.id, name, args]); + } }; + return r; +} + +export function Fragment(props: { children?: Child | Child[] }): Node { + return flatten(props.children) as unknown as Node; +} + +export function h(type: string | Component, props: Props | null, ...children: Child[]): Node { + if (!insideRender()) throw new Error("h() called outside a synchronous render period"); + if (typeof type === "function") return callComponent(type, props, children); + return createElement(type, props, children); +} + +function callComponent(type: Component, props: Props | null, children: Child[]): Node { + const p: Props = {}; + if (props) { + for (const key of Object.keys(props)) { + const v = props[key]; + if (v instanceof Thunk) Object.defineProperty(p, key, { get: v.fn, enumerable: true }); + else p[key] = v; + } + } + if (children.length === 1 && typeof children[0] === "function") p["children"] = children[0]; + else if (children.length > 0) p["children"] = flatten(children); + 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)" + : Array.isArray(result) ? "several nodes (wrap them in one container)" + : result instanceof Slot ? "a For / Show (wrap it in one container)" + : String(result); + throw new Error(`component ${type.name || ""} must return exactly one node, got ${what}`); + } + return result as Node; +} + +function createElement(type: string, props: Props | null, children: Child[]): Node { + const id = nextId++; + patches.push(["c", id, type]); + if (props) { + for (const key of Object.keys(props)) { + const v = props[key]; + if (key === "ref") { + (v as { id: number }).id = id; + } else if (/^on[A-Z]/.test(key)) { + if (typeof v !== "function") throw new Error(`<${type} ${key}> must be a function`); + const k = `${id}:${key}`; + handlers.set(k, v as (payload: unknown) => void); + onCleanup(() => handlers.delete(k)); + patches.push(["p", id, key, true]); + } else if (v instanceof Thunk) { + bindProp(id, type, key, v.fn as () => unknown); + } else { + patches.push(["p", id, key, scalar(type, key, v)]); + } + } + } + const entries: SlotEntry[] = []; + for (const child of flatten(children)) { + if (child instanceof Slot) { + entries.push(child); + child.mount(id, entries, entries.length - 1); + } else { + entries.push({ length: 1 }); + patches.push(["i", id, child, entries.length - 1]); + } + } + return id as Node; +} + +function bindProp(id: number, type: string, key: string, fn: () => unknown): void { + let last: unknown = undefined, first = true; + effect(() => { + const v = scalar(type, key, fn()); + if (first || v !== last) { + first = false; + last = v; + patches.push(["p", id, key, v]); + } + }); +} + +function scalar(type: string, key: string, v: unknown): string | number | boolean | null { + if (v === undefined || v === null) return null; + const t = typeof v; + if (t === "string" || t === "number" || t === "boolean") return v as string | number | boolean; + if (t === "function") throw new Error(`<${type} ${key}> is a function; did you forget to call it?`); + throw new Error(`<${type} ${key}> must be a string, number, boolean or null; props do not cross the bridge as objects`); +} + +function flatten(children: Child | Child[] | undefined): (number | Slot)[] { + const out: (number | Slot)[] = []; + const push = (c: Child | Child[]) => { + if (c === null || c === undefined || c === false) return; + if (Array.isArray(c)) for (const x of c) push(x); + else out.push(c as number | Slot); + }; + push(children); + return out; +} + +/** Runs [render] in a fresh owner and checks it produced exactly one node. */ +export function renderInOwner(render: () => Node, what: string): { owner: Owner; node: number } { + const owner = createOwner(); + let node: unknown; + try { + node = runWithOwner(owner, render); + } catch (e) { + disposeOwner(owner); + throw e; + } + if (typeof node !== "number") { + disposeOwner(owner); + throw new Error(`${what} must return exactly one node`); + } + return { owner, node }; +} diff --git a/packages/core/src/page.ts b/packages/core/src/page.ts new file mode 100644 index 0000000..a604343 --- /dev/null +++ b/packages/core/src/page.ts @@ -0,0 +1,122 @@ +// Kotlin → JS entries (`globalThis.__tinyui`): docs/runtime-api.md §9. +import { createOwner, disposeOwner, insideRender, onCleanup, runPending, runWithOwner, signal, type Owner } from "./reactive.ts"; +import { handlers, patches, resetIds, type Node, type Props } from "./node.ts"; +import { apply, rejectPending, report, resolvePending } from "./host.ts"; +import { installGlobals } from "./timers.ts"; + +export const PROTOCOL = 1; + +export interface HostManifest { + components: Record; + capabilities: string[]; +} + +let root: Owner | null = null; +let manifest: HostManifest = { components: {}, capabilities: [] }; +const [visible, setVisible] = signal(true); +const topics = new Map void>>(); + +export function pageVisible(): boolean { + return visible(); +} + +export function host(): HostManifest { + return manifest; +} + +/** K5 subscription; the subscription dies with the current owner. */ +export function onEmit(topic: string, fn: (payload: unknown) => void): void { + if (!insideRender()) throw new Error("onEmit() called outside a synchronous render period"); + let set = topics.get(topic); + if (!set) topics.set(topic, (set = new Set())); + set.add(fn); + onCleanup(() => { set.delete(fn); }); +} + +function guarded(entry: string, fn: () => void): void { + try { + fn(); + } catch (e) { + const err = e as Error; + report("E1", { entry, message: err?.message ?? String(e), ...(err?.stack !== undefined && { stack: err.stack }) }); + } +} + +export const entries = { + protocol: PROTOCOL, + + mount(page: (props: Props) => Node, propsJson: string, hostJson: string): void { + if (root) throw new Error("mount() called twice"); + manifest = JSON.parse(hostJson) as HostManifest; + const props = JSON.parse(propsJson) as Props; + resetIds(); + root = createOwner(); + let node: unknown; + try { + node = runWithOwner(root, () => page(props)); + if (typeof node !== "number") { + throw new Error(`page must return exactly one node, got ${node instanceof Promise ? "a Promise" : String(node)}`); + } + } catch (e) { + disposeOwner(root); + root = null; + patches.length = 0; + throw e; + } + patches.push(["i", 0, node, 0]); + }, + + unmount(): void { + if (root) disposeOwner(root); + root = null; + patches.length = 0; + handlers.clear(); + topics.clear(); + }, + + visible(v: boolean): void { + setVisible(v); + }, + + dispatch(nodeId: number, event: string, payloadJson: string): void { + const handler = handlers.get(`${nodeId}:${event}`); + if (!handler) return; + guarded("dispatch", () => handler(JSON.parse(payloadJson))); + }, + + resolve(cbId: number, resultJson: string): void { + guarded("resolve", () => resolvePending(cbId, resultJson)); + }, + + reject(cbId: number, errorJson: string): void { + guarded("reject", () => rejectPending(cbId, errorJson)); + }, + + emit(topic: string, payloadJson: string): void { + const set = topics.get(topic); + if (!set) return; + const payload = JSON.parse(payloadJson); + for (const fn of [...set]) guarded("emit", () => fn(payload)); + }, + + /** Second call of every K entry: re-runs effects, ships the patches (J1). Throws E2 with the buffer cleared. */ + flush(): void { + try { + runPending(); + } catch (e) { + patches.length = 0; + throw e; + } + if (patches.length === 0) return; + const json = JSON.stringify(patches); + patches.length = 0; + apply(json); + }, +}; + +export function install(g: Record): void { + g["__tinyui"] = entries; + installGlobals(g); +} + +install(globalThis as unknown as Record); diff --git a/packages/core/src/reactive.ts b/packages/core/src/reactive.ts new file mode 100644 index 0000000..e6cdffe --- /dev/null +++ b/packages/core/src/reactive.ts @@ -0,0 +1,190 @@ +// Runtime signals: docs/js-runtime.html §1 / §3 for the model, docs/runtime-api.md §2 for the contract. + +export interface Owner { + effects: Effect[]; + cleanups: (() => void)[]; + disposed: boolean; +} + +interface Effect { + fn: () => void; + deps: Set[]; + owner: Owner | null; + cleanups: (() => void)[]; + queued: boolean; + disposed: boolean; + runs: number; + memo: Memo | null; +} + +interface Memo { + effect: Effect; + subs: Set; + value: T; + dirty: boolean; +} + +let currentEffect: Effect | null = null; +let currentOwner: Owner | null = null; +const pending: Effect[] = []; +const ran: Effect[] = []; + +const MAX_RUNS_PER_FLUSH = 100; + +export function createOwner(): Owner { + return { effects: [], cleanups: [], disposed: false }; +} + +/** Runs [fn] as a synchronous render period under [owner]: effects it creates belong to the owner. */ +export function runWithOwner(owner: Owner, fn: () => T): T { + const prevOwner = currentOwner, prevEffect = currentEffect; + currentOwner = owner; + currentEffect = null; + try { + return fn(); + } finally { + currentOwner = prevOwner; + currentEffect = prevEffect; + } +} + +export function disposeOwner(owner: Owner): void { + if (owner.disposed) return; + owner.disposed = true; + for (const e of owner.effects) disposeEffect(e); + owner.effects.length = 0; + const cleanups = owner.cleanups.splice(0).reverse(); + for (const c of cleanups) c(); +} + +export function insideRender(): boolean { + return currentOwner !== null; +} + +export function signal(init: T): [get: () => T, set: (next: T | ((prev: T) => T)) => void] { + let value = init; + const subs = new Set(); + const get = () => { + track(subs); + return value; + }; + const set = (next: T | ((prev: T) => T)) => { + const v = typeof next === "function" ? (next as (prev: T) => T)(value) : next; + if (v === value) return; + value = v; + notify(subs); + }; + return [get, set]; +} + +export function memo(fn: () => T): () => T { + const effect = createEffectRecord(() => { + const v = fn(); + m.dirty = false; + m.value = v; + }); + const m: Memo = { effect, subs: new Set(), value: undefined as T, dirty: true }; + effect.memo = m as Memo; + return () => { + track(m.subs); + if (m.dirty && !effect.disposed) runEffect(effect); + return m.value; + }; +} + +export function effect(fn: () => void): void { + runEffect(createEffectRecord(fn)); +} + +export function onCleanup(fn: () => void): void { + if (currentEffect) currentEffect.cleanups.push(fn); + else if (currentOwner) currentOwner.cleanups.push(fn); + else throw new Error("onCleanup() called outside a synchronous render period"); +} + +export function untrack(fn: () => T): T { + const prev = currentEffect; + currentEffect = null; + try { + return fn(); + } finally { + currentEffect = prev; + } +} + +/** Re-runs every queued effect until the queue is empty. Throws (E2) on an update loop or a render error. */ +export function runPending(): void { + try { + for (let i = 0; i < pending.length; i++) { + const e = pending[i]!; + e.queued = false; + if (e.disposed) continue; + if (++e.runs > MAX_RUNS_PER_FLUSH) throw new Error("update loop: an effect re-ran more than 100 times in one flush"); + runEffect(e); + } + } finally { + pending.length = 0; + for (const e of ran) e.runs = 0; + ran.length = 0; + } +} + +function createEffectRecord(fn: () => void): Effect { + if (!currentOwner) throw new Error("effect() called outside a synchronous render period"); + const e: Effect = { fn, deps: [], owner: currentOwner, cleanups: [], queued: false, disposed: false, runs: 0, memo: null }; + currentOwner.effects.push(e); + return e; +} + +function runEffect(e: Effect): void { + if (e.disposed) return; + unsubscribe(e); + runCleanups(e); + const prevEffect = currentEffect, prevOwner = currentOwner; + currentEffect = e; + currentOwner = e.owner; + try { + e.fn(); + } finally { + currentEffect = prevEffect; + currentOwner = prevOwner; + } +} + +function disposeEffect(e: Effect): void { + e.disposed = true; + unsubscribe(e); + runCleanups(e); +} + +function unsubscribe(e: Effect): void { + for (const subs of e.deps) subs.delete(e); + e.deps.length = 0; +} + +function runCleanups(e: Effect): void { + const cleanups = e.cleanups.splice(0).reverse(); + for (const c of cleanups) c(); +} + +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 { + for (const e of subs) { + if (e.memo) { + if (!e.memo.dirty) { + e.memo.dirty = true; + notify(e.memo.subs); + } + } else if (!e.queued) { + e.queued = true; + pending.push(e); + if (e.runs === 0) ran.push(e); + } + } +} diff --git a/packages/core/src/resource.ts b/packages/core/src/resource.ts new file mode 100644 index 0000000..31138c8 --- /dev/null +++ b/packages/core/src/resource.ts @@ -0,0 +1,27 @@ +import { insideRender, onCleanup, signal } from "./reactive.ts"; + +export interface ResourceActions { + loading: () => boolean; + error: () => unknown; + refetch: () => void; +} + +/** 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"); + const [data, setData] = signal(undefined); + const [loading, setLoading] = signal(false); + const [error, setError] = signal(undefined); + let disposed = false; + onCleanup(() => { disposed = true; }); + const run = () => { + setLoading(true); + setError(undefined); + fetcher().then( + (v) => { if (disposed) return; setData(() => v); setLoading(false); }, + (e: unknown) => { if (disposed) return; setError(e); setLoading(false); }, + ); + }; + run(); + return [data, { loading, error, refetch: run }]; +} diff --git a/packages/core/src/timers.ts b/packages/core/src/timers.ts new file mode 100644 index 0000000..2f708ed --- /dev/null +++ b/packages/core/src/timers.ts @@ -0,0 +1,23 @@ +import { allocateCbId, cancelPending, registerPending, send } from "./host.ts"; + +declare const __host_call: (name: string, cbId: number, argsJson: string) => void; + +/** `setTimeout` on top of the host timer (J3 `timer.schedule`); ids share the cbId space. */ +export function setTimeout(fn: () => void, ms = 0): number { + const cbId = allocateCbId(); + registerPending(cbId, { resolve: () => fn(), reject: () => {} }); + __host_call("timer.schedule", cbId, JSON.stringify({ ms })); + return cbId; +} + +export function clearTimeout(id: number): void { + cancelPending(id); + send("timer.cancel", { cbId: id }); +} + +/** The engine has no timers (quickjs-libc is not linked); a host that already has them (Node tests) keeps its own. */ +export function installGlobals(g: Record): void { + if ("setTimeout" in g) return; + g["setTimeout"] = setTimeout; + g["clearTimeout"] = clearTimeout; +} diff --git a/packages/core/test/async.test.ts b/packages/core/test/async.test.ts new file mode 100644 index 0000000..8d4c8a2 --- /dev/null +++ b/packages/core/test/async.test.ts @@ -0,0 +1,82 @@ +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 * as timers from "../src/timers.ts"; + +const g = globalThis as Record; +afterEach(() => unmount()); + +describe("createResource + 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" })); + return h(Column, null, h(Text, { text: thunk(() => loading() ? "loading" : user()!.name) })); + }); + assert.deepEqual(bridge.calls, [{ name: "http.get", cbId: 1, args: { url: "/me" } }]); + assert.deepEqual(bridge.ops().filter((o) => o[0] === "p"), [["p", 1, "text", "loading"]]); + tinyui().resolve(1, JSON.stringify({ name: "harlon" })); + await drain(); + const before = bridge.applied.length; + tinyui().flush(); + assert.deepEqual(bridge.applied.slice(before).flat(), [["p", 1, "text", "harlon"]]); + unmount(); + }); + + it("rejection lands in error() and is not reported", async () => { + mount(() => { + const [, { error }] = createResource(() => call("http.get")); + return h(Column, null, h(Text, { text: thunk(() => { const e = error(); return e instanceof HostError ? e.code : "ok"; }) })); + }); + tinyui().reject(2, JSON.stringify({ code: "E_NET", message: "offline" })); + await drain(); + const before = bridge.applied.length; + tinyui().flush(); + assert.deepEqual(bridge.applied.slice(before).flat(), [["p", 1, "text", "E_NET"]]); + assert.equal(bridge.reports.length, 0); + unmount(); + }); + + it("a result arriving after unmount is dropped", async () => { + mount(() => { + const [v] = createResource(() => call("x")); + return h(Column, null, h(Text, { text: thunk(() => String(v())) })); + }); + const cbId = bridge.calls.at(-1)!.cbId; + unmount(); + tinyui().resolve(cbId, "1"); + await drain(); + const before = bridge.applied.length; + tinyui().flush(); + assert.equal(bridge.applied.length, before); + }); +}); + +describe("other entries", () => { + it("query returns parsed JSON; timers use the cbId space", () => { + bridge.queries["device.info"] = { os: "android" }; + assert.deepEqual(query("device.info"), { os: "android" }); + const id = timers.setTimeout(() => {}, 10); + assert.equal(bridge.calls.at(-1)!.name, "timer.schedule"); + timers.clearTimeout(id); + assert.deepEqual(bridge.sent.at(-1), { name: "timer.cancel", args: { cbId: id } }); + }); + + it("visible() and emit() reach subscribed effects in one transaction", () => { + mount(() => { + const [net, setNet] = signal("?"); + onEmit("network", (p) => setNet((p as { online: boolean }).online ? "on" : "off")); + return h(Column, null, h(Text, { text: thunk(() => `${pageVisible() ? "v" : "h"}:${net()}`) })); + }); + assert.deepEqual(transaction(() => tinyui().visible(false)), [["p", 1, "text", "h:?"]]); + assert.deepEqual(transaction(() => tinyui().emit("network", JSON.stringify({ online: true }))), [["p", 1, "text", "h:on"]]); + unmount(); + tinyui().visible(true); + }); + + it("exposes the protocol version", () => { + assert.equal(tinyui().protocol, 1); + g; + }); +}); diff --git a/packages/core/test/control.test.ts b/packages/core/test/control.test.ts new file mode 100644 index 0000000..946aac4 --- /dev/null +++ b/packages/core/test/control.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { bridge } from "./host-stub.ts"; +import { mount, tinyui, transaction, unmount } from "./helpers.ts"; +import { Button, Column, For, h, onCleanup, Show, signal, Text, thunk } from "../src/index.ts"; + +const g = globalThis as Record; +afterEach(() => unmount()); + +describe("Show", () => { + it("is a slot between siblings: switching keeps sibling indices right", () => { + mount(() => { + const [on, setOn] = signal(false); + g["__setOn"] = setOn; + return h(Column, null, + h(Text, { text: "head" }), + h(Show, { when: thunk(() => on()), fallback: () => h(Text, { text: "off" }) }, () => h(Text, { text: "on" })), + h(Text, { text: "tail" }), + ); + }); + assert.deepEqual(bridge.ops().filter((o) => o[0] === "i"), [["i", 4, 1, 0], ["i", 4, 2, 1], ["i", 4, 3, 2], ["i", 0, 4, 0]]); + const ops = transaction(() => (g["__setOn"] as (v: boolean) => void)(true)); + assert.deepEqual(ops, [["r", 2], ["c", 5, "Text"], ["p", 5, "text", "on"], ["i", 4, 5, 1]]); + assert.deepEqual(transaction(() => (g["__setOn"] as (v: boolean) => void)(false)), [["r", 5], ["c", 6, "Text"], ["p", 6, "text", "off"], ["i", 4, 6, 1]]); + unmount(); + }); + + it("switches only on truthiness and disposes the old branch", () => { + const log: string[] = []; + mount(() => { + const [user, setUser] = signal<{ name: string } | null>({ name: "a" }); + g["__setUser"] = setUser; + return h(Column, null, h(Show, { when: thunk(() => user()) }, () => { + onCleanup(() => log.push("branch disposed")); + return h(Text, { text: thunk(() => user()!.name) }); + })); + }); + assert.deepEqual(transaction(() => (g["__setUser"] as (v: unknown) => void)({ name: "b" })), [["p", 1, "text", "b"]], "same truthiness: no rebuild"); + assert.deepEqual(transaction(() => (g["__setUser"] as (v: unknown) => void)(null)), [["r", 1]]); + assert.deepEqual(log, ["branch disposed"]); + unmount(); + }); +}); + +interface Todo { id: number; title: string } + +function TodoList(props: { todos: Todo[] }) { + return h(Column, null, + h(Text, { text: "head" }), + h(For, { each: thunk(() => props.todos), key: (t: Todo) => t.id }, (item: () => Todo, index: () => number) => + h(Button, { text: thunk(() => `${index()}:${item().title}`), onClick: () => {} })), + ); +} + +function mountList(initial: Todo[]) { + return mount(() => { + const [todos, setTodos] = signal(initial); + g["__setTodos"] = setTodos; + return h(TodoList, { todos: thunk(() => todos()) }); + }); +} +const setTodos = (v: Todo[]) => transaction(() => (g["__setTodos"] as (v: Todo[]) => void)(v)); + +describe("For", () => { + it("creates rows after the static sibling and keeps them keyed", () => { + const ops = mountList([{ id: 1, title: "a" }, { id: 2, title: "b" }]); + assert.deepEqual(ops.filter((o) => o[0] === "i"), [["i", 4, 1, 0], ["i", 4, 2, 1], ["i", 4, 3, 2], ["i", 0, 4, 0]]); + assert.deepEqual(ops.filter((o) => o[0] === "p" && o[2] === "text"), [["p", 1, "text", "head"], ["p", 2, "text", "0:a"], ["p", 3, "text", "1:b"]]); + unmount(); + }); + + it("updates a kept row through the item accessor instead of rebuilding it", () => { + mountList([{ id: 1, title: "a" }, { id: 2, title: "b" }]); + assert.deepEqual(setTodos([{ id: 1, title: "a2" }, { id: 2, title: "b" }]), [["p", 2, "text", "0:a2"]]); + unmount(); + }); + + it("removes, inserts and moves with indices offset by the static sibling", () => { + mountList([{ id: 1, title: "a" }, { id: 2, title: "b" }, { id: 3, title: "c" }]); + assert.deepEqual(setTodos([{ id: 3, title: "c" }, { id: 1, title: "a" }, { id: 4, title: "d" }]), [ + ["r", 3], + ["m", 5, 4, 1], + ["c", 6, "Button"], ["p", 6, "text", "2:d"], ["p", 6, "onClick", true], ["i", 5, 6, 3], + ["p", 4, "text", "0:c"], + ["p", 2, "text", "1:a"], + ]); + unmount(); + }); + + it("disposes removed rows: their handlers are gone", () => { + mountList([{ id: 1, title: "a" }]); + setTodos([]); + assert.deepEqual(transaction(() => tinyui().dispatch(2, "onClick", "{}")), []); + unmount(); + }); + + it("rejects duplicate keys and misplaced control flow", () => { + assert.throws(() => mountList([{ id: 1, title: "a" }, { id: 1, title: "b" }]), /duplicate key/); + unmount(); + assert.throws(() => mount(() => h(For, { each: [], key: (t: Todo) => t.id }, () => h(Text, { text: "x" })) as never), /page must return exactly one node/); + unmount(); + assert.throws(() => mount(() => h(function Wrap() { return h(Show, { when: true }, () => h(Text, { text: "x" })); }, null)), /got a For \/ Show/); + unmount(); + }); +}); diff --git a/packages/core/test/counter.test.ts b/packages/core/test/counter.test.ts new file mode 100644 index 0000000..641b0cd --- /dev/null +++ b/packages/core/test/counter.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { bridge } from "./host-stub.ts"; +import { mount, tinyui, transaction, unmount } from "./helpers.ts"; +import { Button, Column, h, onCleanup, ref, signal, Text, thunk } from "../src/index.ts"; + +function Counter() { + const [count, setCount] = signal(0); + return h(Column, null, + h(Text, { text: thunk(() => "Count: " + count()) }), + h(Button, { text: "+1", onClick: () => setCount(count() + 1) }), + ); +} + +afterEach(() => unmount()); + +describe("Counter end to end", () => { + it("mounts with the tree bottom-up and the root attached last", () => { + const ops = mount(Counter); + assert.deepEqual(ops, [ + ["c", 1, "Text"], ["p", 1, "text", "Count: 0"], + ["c", 2, "Button"], ["p", 2, "text", "+1"], ["p", 2, "onClick", true], + ["c", 3, "Column"], ["i", 3, 1, 0], ["i", 3, 2, 1], + ["i", 0, 3, 0], + ]); + unmount(); + }); + + it("a click transaction ships exactly one prop op", () => { + mount(Counter); + const ops = transaction(() => tinyui().dispatch(2, "onClick", "{}")); + assert.deepEqual(ops, [["p", 1, "text", "Count: 1"]]); + assert.deepEqual(transaction(() => tinyui().dispatch(2, "onClick", "{}")), [["p", 1, "text", "Count: 2"]]); + unmount(); + }); + + it("an unknown handler is ignored and an empty flush ships nothing", () => { + mount(Counter); + const before = bridge.applied.length; + transaction(() => tinyui().dispatch(99, "onClick", "{}")); + assert.equal(bridge.applied.length, before); + unmount(); + }); + + it("a throwing handler is reported as E1 and the transaction still flushes", () => { + mount(() => { + const [n, setN] = signal(0); + return h(Column, null, + h(Text, { text: thunk(() => String(n())) }), + h(Button, { text: "x", onClick: () => { setN(1); throw new Error("boom"); } })); + }); + const ops = transaction(() => tinyui().dispatch(2, "onClick", "{}")); + assert.deepEqual(ops, [["p", 1, "text", "1"]]); + assert.equal(bridge.reports.length, 1); + assert.match((bridge.reports[0]!.detail as { message: string }).message, /boom/); + unmount(); + }); + + it("a render error in flush throws E2 with the buffer cleared", () => { + mount(() => { + const [n, setN] = signal(0); + (globalThis as Record)["__bump"] = () => setN(1); + return h(Column, null, h(Text, { text: thunk(() => { if (n() > 0) throw new Error("render"); return "ok"; }) })); + }); + ((globalThis as Record)["__bump"] as () => void)(); + const before = bridge.applied.length; + assert.throws(() => tinyui().flush(), /render/); + assert.equal(bridge.applied.length, before); + unmount(); + }); + + it("unmount runs cleanups and clears handlers without patches", () => { + let cleaned = false; + mount(() => { + onCleanup(() => { cleaned = true; }); + return h(Button, { text: "x", onClick: () => {} }); + }); + const before = bridge.applied.length; + unmount(); + assert.equal(cleaned, true); + assert.equal(bridge.applied.length, before); + assert.deepEqual(transaction(() => tinyui().dispatch(1, "onClick", "{}")), []); + }); + + it("rejects bad shapes at mount", () => { + assert.throws(() => mount(() => h(Text, { text: () => "x" } as never)), /did you forget to call it/); + unmount(); + assert.throws(() => mount(() => h(Text, { text: { a: 1 } } as never)), /do not cross the bridge/); + unmount(); + assert.throws(() => mount((() => Promise.resolve()) as never), /page must return exactly one node/); + unmount(); + assert.throws(() => mount(() => h(function Bad() { return [h(Text, { text: "a" })]; } as never, null)), /must return exactly one node, got several/); + unmount(); + }); + + it("ref + cmd emits an x op in the current transaction", () => { + const list = ref(); + mount(() => h(Column, { ref: list }, h(Text, { text: "a" }))); + const ops = transaction(() => list.cmd("scrollTo", { index: 0 })); + assert.deepEqual(ops, [["x", 2, "scrollTo", { index: 0 }]]); + unmount(); + }); + + it("components get getters for thunk props and plain values otherwise", () => { + function Row(props: { title: string; format: (s: string) => string }) { + return h(Text, { text: thunk(() => props.format(props.title)) }); + } + mount(() => { + const [t, setT] = signal("a"); + (globalThis as Record)["__setT"] = setT; + return h(Column, null, h(Row, { title: thunk(() => t()), format: (s: string) => s.toUpperCase() })); + }); + assert.deepEqual(bridge.ops().filter((o) => o[0] === "p"), [["p", 1, "text", "A"]]); + assert.deepEqual(transaction(() => ((globalThis as Record)["__setT"] as (v: string) => void)("b")), [["p", 1, "text", "B"]]); + unmount(); + }); +}); diff --git a/packages/core/test/helpers.ts b/packages/core/test/helpers.ts new file mode 100644 index 0000000..1bd9ead --- /dev/null +++ b/packages/core/test/helpers.ts @@ -0,0 +1,27 @@ +import { bridge } from "./host-stub.ts"; +import type { Node, Props } from "../src/node.ts"; + +type Entries = typeof import("../src/page.ts")["entries"]; +export const tinyui = (): Entries => (globalThis as Record)["__tinyui"] as Entries; + +const HOST = JSON.stringify({ components: {}, capabilities: [] }); + +/** One K entry as Kotlin does it: the entry, then flush(). */ +export function transaction(fn: () => void): unknown[][] { + const before = bridge.applied.length; + fn(); + tinyui().flush(); + return bridge.applied.slice(before).flat(); +} + +export function mount(page: (props: Props) => Node, props: Props = {}): unknown[][] { + bridge.reset(); + return transaction(() => tinyui().mount(page, JSON.stringify(props), HOST)); +} + +export function unmount(): void { + tinyui().unmount(); +} + +/** Drains microtasks the way quickjs-kmp does before an entry returns. */ +export const drain = (): Promise => new Promise((r) => setTimeout(r, 0)); diff --git a/packages/core/test/host-stub.ts b/packages/core/test/host-stub.ts new file mode 100644 index 0000000..929df79 --- /dev/null +++ b/packages/core/test/host-stub.ts @@ -0,0 +1,17 @@ +// Installs fake `__host_*` globals before the runtime is imported; tests read what crossed the bridge. +export const bridge = { + applied: [] as unknown[][][], + calls: [] as { name: string; cbId: number; args: unknown }[], + sent: [] as { name: string; args: unknown }[], + reports: [] as { kind: string; detail: unknown }[], + queries: {} as Record, + reset() { this.applied.length = 0; this.calls.length = 0; this.sent.length = 0; this.reports.length = 0; }, + /** All ops of all flushes since the last reset, in order. */ + ops(): unknown[][] { return this.applied.flat(); }, +}; +const g = globalThis as Record; +g["__host_apply"] = (json: string) => bridge.applied.push(JSON.parse(json)); +g["__host_call"] = (name: string, cbId: number, args: string) => bridge.calls.push({ name, cbId, args: JSON.parse(args) }); +g["__host_send"] = (name: string, args: string) => bridge.sent.push({ name, args: JSON.parse(args) }); +g["__host_query"] = (name: string) => JSON.stringify(bridge.queries[name] ?? null); +g["__host_report"] = (kind: string, detail: string) => bridge.reports.push({ kind, detail: JSON.parse(detail) }); diff --git a/packages/core/test/reactive.test.ts b/packages/core/test/reactive.test.ts new file mode 100644 index 0000000..d3e3048 --- /dev/null +++ b/packages/core/test/reactive.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import "./host-stub.ts"; +import { createOwner, disposeOwner, effect, memo, onCleanup, runPending, runWithOwner, signal, untrack } from "../src/reactive.ts"; + +const render = (fn: () => T) => { const owner = createOwner(); return { owner, result: runWithOwner(owner, fn) }; }; + +describe("signal + effect", () => { + it("runs the effect once on creation and again at flush after a write", () => { + const [count, setCount] = signal(0); + const seen: number[] = []; + const { owner } = render(() => effect(() => seen.push(count()))); + assert.deepEqual(seen, [0]); + setCount(1); + assert.equal(count(), 1, "the write is visible immediately"); + assert.deepEqual(seen, [0], "the effect waits for flush"); + runPending(); + assert.deepEqual(seen, [0, 1]); + disposeOwner(owner); + setCount(2); + runPending(); + assert.deepEqual(seen, [0, 1], "disposed effects never run again"); + }); + + it("skips equal writes and batches several writes into one run", () => { + const [a, setA] = signal(1); + const [b, setB] = signal(1); + let runs = 0; + render(() => effect(() => { a(); b(); runs++; })); + setA(1); + runPending(); + assert.equal(runs, 1); + setA(2); setB(2); setA(3); + runPending(); + assert.equal(runs, 2); + }); + + it("resubscribes on every run so a switched branch drops the old dependency", () => { + const [flag, setFlag] = signal(true); + const [a, setA] = signal("a"); + const [b, setB] = signal("b"); + const seen: string[] = []; + render(() => effect(() => seen.push(flag() ? a() : b()))); + setFlag(false); runPending(); + setA("a2"); runPending(); + assert.deepEqual(seen, ["a", "b"], "a() is no longer a dependency"); + setB("b2"); runPending(); + assert.deepEqual(seen, ["a", "b", "b2"]); + }); + + it("updater form and reads in handlers do not subscribe", () => { + const [n, setN] = signal(1); + let runs = 0; + render(() => effect(() => { untrack(() => n()); runs++; })); + setN((p) => p + 1); + runPending(); + assert.equal(n(), 2); + assert.equal(runs, 1); + }); + + it("throws outside a render period and on an update loop", () => { + assert.throws(() => effect(() => {}), /outside a synchronous render period/); + assert.throws(() => onCleanup(() => {}), /outside a synchronous render period/); + const [n, setN] = signal(0); + render(() => effect(() => { setN(n() + 1); })); + assert.throws(() => runPending(), /update loop/); + }); +}); + +describe("memo", () => { + it("is lazy, cached, and current right after a write", () => { + const [n, setN] = signal(2); + let computed = 0; + const { result: double } = render(() => memo(() => { computed++; return n() * 2; })); + assert.equal(computed, 0); + assert.equal(double(), 4); + assert.equal(double(), 4); + assert.equal(computed, 1); + setN(3); + assert.equal(double(), 6, "pull-based: no flush needed"); + assert.equal(computed, 2); + }); + + it("schedules effects that read it", () => { + const [n, setN] = signal(1); + const seen: number[] = []; + render(() => { const m = memo(() => n() * 10); effect(() => seen.push(m())); }); + setN(2); runPending(); + assert.deepEqual(seen, [10, 20]); + }); +}); + +describe("onCleanup", () => { + it("inside an effect runs before the next run and on dispose; on an owner runs at dispose in reverse order", () => { + const [n, setN] = signal(0); + const log: string[] = []; + const { owner } = render(() => { + onCleanup(() => log.push("owner-1")); + onCleanup(() => log.push("owner-2")); + effect(() => { n(); onCleanup(() => log.push("effect")); }); + }); + setN(1); runPending(); + assert.deepEqual(log, ["effect"]); + disposeOwner(owner); + assert.deepEqual(log, ["effect", "effect", "owner-2", "owner-1"]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a6ac72..b6814cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: esbuild: specifier: 0.28.2 version: 0.28.2 + magic-string: + specifier: 1.4.1 + version: 1.4.1 + oxc-parser: + specifier: 0.150.0 + version: 0.150.0 devDependencies: '@tiny-ui/core': specifier: workspace:* @@ -203,6 +209,126 @@ packages: cpu: [x64] os: [win32] + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@oxc-parser/binding-android-arm-eabi@0.150.0': + resolution: {integrity: sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.150.0': + resolution: {integrity: sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.150.0': + resolution: {integrity: sha512-J+9IHKzx/bSz1JetOfD4zKXSK9sOm4/a7+0qomJODcTL6JsRXGeV/ZdkAPSIDydfFmWzYCraMlQEosSZEyBYkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.150.0': + resolution: {integrity: sha512-v6IPfcAcSYrBWXV5Tce1DmxDMXLhEYpIIWiRFPJopgCVscZdLaV4MRQOUxvL3usQlw+yrwYOjBFB9IwBx+jUiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.150.0': + resolution: {integrity: sha512-AoR/4jD02HET0KO0yNT4nbTE+XJUxiO9jB1X/ycEjUE3WrIJ3IjV/Vf+gskhWLcqqeXfvNnkDtBR7epllAm88A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.150.0': + resolution: {integrity: sha512-A/hycCFjLUrCmLoL5O/vBp40ohlaXO1Ta3v5hqYZxicYFs129wZmgZJggcW4mYGYbZebQLhup+N8JjeQl8qwyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.150.0': + resolution: {integrity: sha512-pbqahg1Pkz7J4RKmXm30s/iQu99hv64ayXCu8P4p75HcEH5azOdR4eGqiB6lFGkFR3mMpzaYsSKLkS8oJbjmeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.150.0': + resolution: {integrity: sha512-HV11aRbBQGwqv8bEo+K/6qr88uB4fEe1mhXncMhlVCrj+WpBSraxrUzHaPVmeQRU6x6x8v/3DuCbbo93rpp+fw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.150.0': + resolution: {integrity: sha512-k6pVkJqALwtEuP4zukVhGRhdIy4+ofChUIUUsAHHyqDZlNsknmel3JjbggrJiWGaes6h/dIcWmEXsKW4SmK9oQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-ppc64-gnu@0.150.0': + resolution: {integrity: sha512-tEFg39mw/rHO5n8GK2DR4ZMFfsPQZtnQIPbsIpjoQRomJc/2tRfsCSmCT6gNit+NZGoeJG5aH9ATDH5bf0WnoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.150.0': + resolution: {integrity: sha512-0JO7IFkoek6HqV589Menx3hJySNXi6quRhO4tq2P7kpEHKEXoDATnoPVUpn5B7gDH2KLkdo751N0uIrGJFCTCw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-musl@0.150.0': + resolution: {integrity: sha512-7XPzREnyAS5wHU9aB+49Uaqgoo1gVCklHc3DRlc3fJy2tXD/eAJFN1QFz/crj+Ulup5P1+axNREF+/RGaB/IRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.150.0': + resolution: {integrity: sha512-7n7ZxcbRWDFaTdyj8M8p0O9OfzoMKm8O6syBAIgcSutbOALq0Bb9G52Me+XH0anMLZV+NgXc726gqgG2q39vcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.150.0': + resolution: {integrity: sha512-Vx0GSA9ZCRTUiczoEQnIIyXkMvtEY8uc6IiwLQtMe5ci2sXPqjrry0Ek5fsPP1k2kCzkML3ow9UOOEgnEDi7Bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.150.0': + resolution: {integrity: sha512-ii4/9m3viDLssMnfXU7/Pni/3nYplApuGayceX4qsVGaqqQJCx3tHIH9M/HWIeSty5i8LjS21zPYT6lVIkwLxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-openharmony-arm64@0.150.0': + resolution: {integrity: sha512-ZtoxX5rez36ZWkwtiEhjkCPnlTPoQ2I7lIL0IYZ1yjxMYohbvoOjBmUIZzrf9W/HouONq0omIfTeCWEY+R380w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.150.0': + resolution: {integrity: sha512-VqeRb5JX/bKYBrff7TUDvJyKY0914UzuCkuXIGxJS4FUmvMNfqkCbc7Sq90iMz9DlmAtlggwaBYQ9eg3h3ltiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.150.0': + resolution: {integrity: sha512-EPqJfeZ4Pgg2BJSsCV8GozxV0FDltRzpVtGVa1r2noJu1iu+opOw3gtBWXwIo9odCT/MdFfyHxdy0/CRqs3OqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.150.0': + resolution: {integrity: sha512-n5YMzbqwPQqozbkrexi0lNZrUz5cnPHalYpFWe6Dau7AmKIWNo+y24IwzDuZEQtEdUHuKhXmMMih/MPjOI+CMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.150.0': + resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} + '@types/node@22.20.3': resolution: {integrity: sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==} @@ -331,6 +457,13 @@ packages: engines: {node: '>=18'} hasBin: true + magic-string@1.4.1: + resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==} + + oxc-parser@0.150.0: + resolution: {integrity: sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg==} + engines: {node: ^20.19.0 || >=22.12.0} + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -419,6 +552,67 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@oxc-parser/binding-android-arm-eabi@0.150.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.150.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.150.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.150.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.150.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.150.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.150.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.150.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.150.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.150.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.150.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.150.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.150.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.150.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.150.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.150.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.150.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.150.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.150.0': + optional: true + + '@oxc-project/types@0.150.0': {} + '@types/node@22.20.3': dependencies: undici-types: 6.21.0 @@ -512,6 +706,34 @@ snapshots: '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 + magic-string@1.4.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + oxc-parser@0.150.0: + dependencies: + '@oxc-project/types': 0.150.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.150.0 + '@oxc-parser/binding-android-arm64': 0.150.0 + '@oxc-parser/binding-darwin-arm64': 0.150.0 + '@oxc-parser/binding-darwin-x64': 0.150.0 + '@oxc-parser/binding-freebsd-x64': 0.150.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.150.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.150.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.150.0 + '@oxc-parser/binding-linux-arm64-musl': 0.150.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.150.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.150.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.150.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.150.0 + '@oxc-parser/binding-linux-x64-gnu': 0.150.0 + '@oxc-parser/binding-linux-x64-musl': 0.150.0 + '@oxc-parser/binding-openharmony-arm64': 0.150.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.150.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.150.0 + '@oxc-parser/binding-win32-x64-msvc': 0.150.0 + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 diff --git a/sample/js/src/lib/greeting.ts b/sample/js/src/lib/greeting.ts deleted file mode 100644 index e73e365..0000000 --- a/sample/js/src/lib/greeting.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function greeting(runtime: string, from: string): string { - return `TinyUI page loaded: ${runtime} (${from})`; -} diff --git a/sample/js/src/pages/counter.tsx b/sample/js/src/pages/counter.tsx new file mode 100644 index 0000000..6a5a577 --- /dev/null +++ b/sample/js/src/pages/counter.tsx @@ -0,0 +1,12 @@ +import { Button, Column, signal, Text } from "@tiny-ui/core"; + +export default function Counter() { + const [count, setCount] = signal(0); + return ( + + +