diff --git a/compose/api/compose.klib.api b/compose/api/compose.klib.api index 3fe5178..0c24bc3 100644 --- a/compose/api/compose.klib.api +++ b/compose/api/compose.klib.api @@ -72,9 +72,8 @@ abstract interface wang.harlon.tinyui/Navigator { // wang.harlon.tinyui/Navigato } 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 error(wang.harlon.tinyui/PageError) // wang.harlon.tinyui/PageSink.error|error(wang.harlon.tinyui.PageError){}[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] } abstract interface wang.harlon.tinyui/Store { // wang.harlon.tinyui/Store|null[0] @@ -191,6 +190,23 @@ final class wang.harlon.tinyui.schema/ComponentSchema { // wang.harlon.tinyui.sc final fun prop(kotlin/String): wang.harlon.tinyui.schema/PropSpec? // wang.harlon.tinyui.schema/ComponentSchema.prop|prop(kotlin.String){}[0] } +final class wang.harlon.tinyui/BuildManifest { // wang.harlon.tinyui/BuildManifest|null[0] + constructor (kotlin.collections/List, kotlin.collections/List, kotlin.collections/Map) // wang.harlon.tinyui/BuildManifest.|(kotlin.collections.List;kotlin.collections.List;kotlin.collections.Map){}[0] + + final val buildIds // wang.harlon.tinyui/BuildManifest.buildIds|{}buildIds[0] + final fun (): kotlin.collections/Map // wang.harlon.tinyui/BuildManifest.buildIds.|(){}[0] + final val pages // wang.harlon.tinyui/BuildManifest.pages|{}pages[0] + final fun (): kotlin.collections/List // wang.harlon.tinyui/BuildManifest.pages.|(){}[0] + final val runtime // wang.harlon.tinyui/BuildManifest.runtime|{}runtime[0] + final fun (): kotlin.collections/List // wang.harlon.tinyui/BuildManifest.runtime.|(){}[0] + + final fun buildId(kotlin/String): kotlin/String // wang.harlon.tinyui/BuildManifest.buildId|buildId(kotlin.String){}[0] + + final object Companion { // wang.harlon.tinyui/BuildManifest.Companion|null[0] + final fun parse(kotlin/String): wang.harlon.tinyui/BuildManifest // wang.harlon.tinyui/BuildManifest.Companion.parse|parse(kotlin.String){}[0] + } +} + final class wang.harlon.tinyui/EventBus { // wang.harlon.tinyui/EventBus|null[0] constructor () // wang.harlon.tinyui/EventBus.|(){}[0] @@ -261,9 +277,34 @@ final class wang.harlon.tinyui/InMemoryStore : wang.harlon.tinyui/Store { // wan final fun set(kotlin/String, kotlin/String) // wang.harlon.tinyui/InMemoryStore.set|set(kotlin.String;kotlin.String){}[0] } +final class wang.harlon.tinyui/PageError { // wang.harlon.tinyui/PageError|null[0] + constructor (kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin/String? = ..., kotlin/String? = ..., kotlin.collections/List = ..., kotlin/String? = ...) // wang.harlon.tinyui/PageError.|(kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String?;kotlin.String?;kotlin.collections.List;kotlin.String?){}[0] + + final val buildId // wang.harlon.tinyui/PageError.buildId|{}buildId[0] + final fun (): kotlin/String // wang.harlon.tinyui/PageError.buildId.|(){}[0] + final val entry // wang.harlon.tinyui/PageError.entry|{}entry[0] + final fun (): kotlin/String? // wang.harlon.tinyui/PageError.entry.|(){}[0] + final val frames // wang.harlon.tinyui/PageError.frames|{}frames[0] + final fun (): kotlin.collections/List // wang.harlon.tinyui/PageError.frames.|(){}[0] + final val jsStack // wang.harlon.tinyui/PageError.jsStack|{}jsStack[0] + final fun (): kotlin/String? // wang.harlon.tinyui/PageError.jsStack.|(){}[0] + final val kind // wang.harlon.tinyui/PageError.kind|{}kind[0] + final fun (): kotlin/String // wang.harlon.tinyui/PageError.kind.|(){}[0] + final val message // wang.harlon.tinyui/PageError.message|{}message[0] + final fun (): kotlin/String // wang.harlon.tinyui/PageError.message.|(){}[0] + final val op // wang.harlon.tinyui/PageError.op|{}op[0] + final fun (): kotlin/String? // wang.harlon.tinyui/PageError.op.|(){}[0] + final val page // wang.harlon.tinyui/PageError.page|{}page[0] + final fun (): kotlin/String // wang.harlon.tinyui/PageError.page.|(){}[0] + + final fun toString(): kotlin/String // wang.harlon.tinyui/PageError.toString|toString(){}[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] + constructor (wang.harlon.tinyui/PageError) // wang.harlon.tinyui/PageFailure.|(wang.harlon.tinyui.PageError){}[0] + final val error // wang.harlon.tinyui/PageFailure.error|{}error[0] + final fun (): wang.harlon.tinyui/PageError // wang.harlon.tinyui/PageFailure.error.|(){}[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] @@ -271,7 +312,7 @@ final class wang.harlon.tinyui/PageFailure { // wang.harlon.tinyui/PageFailure|n } 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, wang.harlon.tinyui/HostServices = ..., kotlin/String = ..., kotlin/Long = ...) // wang.harlon.tinyui/PageHost.|(wang.harlon.tinyui.RuntimeBundle;kotlin.ByteArray;wang.harlon.tinyui.schema.ComponentRegistry;wang.harlon.tinyui.PageSink;wang.harlon.tinyui.HostServices;kotlin.String;kotlin.Long){}[0] + constructor (wang.harlon.tinyui/RuntimeBundle, wang.harlon.tinyui/PageModule, wang.harlon.tinyui.schema/ComponentRegistry, wang.harlon.tinyui/PageSink, wang.harlon.tinyui/HostServices = ..., kotlin/String = ..., kotlin/Long = ..., wang.harlon.tinyui/SourceMaps = ...) // wang.harlon.tinyui/PageHost.|(wang.harlon.tinyui.RuntimeBundle;wang.harlon.tinyui.PageModule;wang.harlon.tinyui.schema.ComponentRegistry;wang.harlon.tinyui.PageSink;wang.harlon.tinyui.HostServices;kotlin.String;kotlin.Long;wang.harlon.tinyui.SourceMaps){}[0] final val registry // wang.harlon.tinyui/PageHost.registry|{}registry[0] final fun (): wang.harlon.tinyui.schema/ComponentRegistry // wang.harlon.tinyui/PageHost.registry.|(){}[0] @@ -297,6 +338,17 @@ final class wang.harlon.tinyui/PageHost { // wang.harlon.tinyui/PageHost|null[0] } } +final class wang.harlon.tinyui/PageModule { // wang.harlon.tinyui/PageModule|null[0] + constructor (kotlin/String, kotlin/ByteArray, kotlin/String = ...) // wang.harlon.tinyui/PageModule.|(kotlin.String;kotlin.ByteArray;kotlin.String){}[0] + + final val buildId // wang.harlon.tinyui/PageModule.buildId|{}buildId[0] + final fun (): kotlin/String // wang.harlon.tinyui/PageModule.buildId.|(){}[0] + final val bytecode // wang.harlon.tinyui/PageModule.bytecode|{}bytecode[0] + final fun (): kotlin/ByteArray // wang.harlon.tinyui/PageModule.bytecode.|(){}[0] + final val name // wang.harlon.tinyui/PageModule.name|{}name[0] + final fun (): kotlin/String // wang.harlon.tinyui/PageModule.name.|(){}[0] +} + final class wang.harlon.tinyui/RuntimeBundle { // wang.harlon.tinyui/RuntimeBundle|null[0] constructor (kotlin/ByteArray, kotlin/ByteArray) // wang.harlon.tinyui/RuntimeBundle.|(kotlin.ByteArray;kotlin.ByteArray){}[0] @@ -306,6 +358,34 @@ final class wang.harlon.tinyui/RuntimeBundle { // wang.harlon.tinyui/RuntimeBund final fun (): kotlin/ByteArray // wang.harlon.tinyui/RuntimeBundle.native.|(){}[0] } +final class wang.harlon.tinyui/SourceMaps { // wang.harlon.tinyui/SourceMaps|null[0] + constructor (kotlin.collections/Map) // wang.harlon.tinyui/SourceMaps.|(kotlin.collections.Map){}[0] + + final fun frames(kotlin/String?): kotlin.collections/List // wang.harlon.tinyui/SourceMaps.frames|frames(kotlin.String?){}[0] + + final object Companion { // wang.harlon.tinyui/SourceMaps.Companion|null[0] + final val EMPTY // wang.harlon.tinyui/SourceMaps.Companion.EMPTY|{}EMPTY[0] + final fun (): wang.harlon.tinyui/SourceMaps // wang.harlon.tinyui/SourceMaps.Companion.EMPTY.|(){}[0] + } +} + +final class wang.harlon.tinyui/StackFrame { // wang.harlon.tinyui/StackFrame|null[0] + constructor (kotlin/String, kotlin/String, kotlin/Int, kotlin/Int?, kotlin/Boolean) // wang.harlon.tinyui/StackFrame.|(kotlin.String;kotlin.String;kotlin.Int;kotlin.Int?;kotlin.Boolean){}[0] + + final val column // wang.harlon.tinyui/StackFrame.column|{}column[0] + final fun (): kotlin/Int? // wang.harlon.tinyui/StackFrame.column.|(){}[0] + final val file // wang.harlon.tinyui/StackFrame.file|{}file[0] + final fun (): kotlin/String // wang.harlon.tinyui/StackFrame.file.|(){}[0] + final val function // wang.harlon.tinyui/StackFrame.function|{}function[0] + final fun (): kotlin/String // wang.harlon.tinyui/StackFrame.function.|(){}[0] + final val line // wang.harlon.tinyui/StackFrame.line|{}line[0] + final fun (): kotlin/Int // wang.harlon.tinyui/StackFrame.line.|(){}[0] + final val mapped // wang.harlon.tinyui/StackFrame.mapped|{}mapped[0] + final fun (): kotlin/Boolean // wang.harlon.tinyui/StackFrame.mapped.|(){}[0] + + final fun toString(): kotlin/String // wang.harlon.tinyui/StackFrame.toString|toString(){}[0] +} + final class wang.harlon.tinyui/StoreValue { // wang.harlon.tinyui/StoreValue|null[0] constructor (kotlin/String, kotlin/Long) // wang.harlon.tinyui/StoreValue.|(kotlin.String;kotlin.Long){}[0] @@ -431,6 +511,10 @@ final object wang.harlon.tinyui.schema/LayoutProps { // wang.harlon.tinyui.schem 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 var debug // wang.harlon.tinyui/TinyUI.debug|{}debug[0] + final fun (): kotlin/Boolean // wang.harlon.tinyui/TinyUI.debug.|(){}[0] + final fun (kotlin/Boolean) // wang.harlon.tinyui/TinyUI.debug.|(kotlin.Boolean){}[0] } final const val wang.harlon.tinyui.node/ROOT_ID // wang.harlon.tinyui.node/ROOT_ID|{}ROOT_ID[0] @@ -456,6 +540,7 @@ final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Str$stabl final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fill$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fill$stableprop|#static{}wang_harlon_tinyui_schema_SizeValue_Fill$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop|#static{}wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop|#static{}wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop[0] +final val wang.harlon.tinyui/wang_harlon_tinyui_BuildManifest$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_BuildManifest$stableprop|#static{}wang_harlon_tinyui_BuildManifest$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_EventBus$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_EventBus$stableprop|#static{}wang_harlon_tinyui_EventBus$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_HostException$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_HostException$stableprop|#static{}wang_harlon_tinyui_HostException$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_HostServices$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_HostServices$stableprop|#static{}wang_harlon_tinyui_HostServices$stableprop[0] @@ -464,9 +549,14 @@ final val wang.harlon.tinyui/wang_harlon_tinyui_HttpRequest$stableprop // wang.h final val wang.harlon.tinyui/wang_harlon_tinyui_HttpResponse$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_HttpResponse$stableprop|#static{}wang_harlon_tinyui_HttpResponse$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_InMemoryStore$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_InMemoryStore$stableprop|#static{}wang_harlon_tinyui_InMemoryStore$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_Navigator_None$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_Navigator_None$stableprop|#static{}wang_harlon_tinyui_Navigator_None$stableprop[0] +final val wang.harlon.tinyui/wang_harlon_tinyui_PageError$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_PageError$stableprop|#static{}wang_harlon_tinyui_PageError$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_PageModule$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_PageModule$stableprop|#static{}wang_harlon_tinyui_PageModule$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_SourceMap_Position$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_SourceMap_Position$stableprop|#static{}wang_harlon_tinyui_SourceMap_Position$stableprop[0] +final val wang.harlon.tinyui/wang_harlon_tinyui_SourceMaps$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_SourceMaps$stableprop|#static{}wang_harlon_tinyui_SourceMaps$stableprop[0] +final val wang.harlon.tinyui/wang_harlon_tinyui_StackFrame$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_StackFrame$stableprop|#static{}wang_harlon_tinyui_StackFrame$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_StoreValue$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_StoreValue$stableprop|#static{}wang_harlon_tinyui_StoreValue$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] @@ -492,7 +582,9 @@ final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Str$stabl final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fill$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fill$stableprop_getter|wang_harlon_tinyui_schema_SizeValue_Fill$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop_getter|wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop_getter|wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop_getter(){}[0] -final fun wang.harlon.tinyui/TinyUIPage(wang.harlon.tinyui/RuntimeBundle, kotlin/ByteArray, wang.harlon.tinyui.schema/ComponentRegistry, wang.harlon.tinyui/PageSink, wang.harlon.tinyui/HostServices?, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function3?, kotlin/Function1?, 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;wang.harlon.tinyui.HostServices?;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function3?;kotlin.Function1?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun wang.harlon.tinyui/PageFailureScreen(wang.harlon.tinyui/PageFailure, androidx.compose.runtime/Composer?, kotlin/Int) // wang.harlon.tinyui/PageFailureScreen|PageFailureScreen(wang.harlon.tinyui.PageFailure;androidx.compose.runtime.Composer?;kotlin.Int){}[0] +final fun wang.harlon.tinyui/TinyUIPage(wang.harlon.tinyui/RuntimeBundle, wang.harlon.tinyui/PageModule, wang.harlon.tinyui.schema/ComponentRegistry, wang.harlon.tinyui/PageSink, wang.harlon.tinyui/HostServices?, kotlin/String?, androidx.compose.ui/Modifier?, wang.harlon.tinyui/SourceMaps?, kotlin/Function3?, kotlin/Function1?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // wang.harlon.tinyui/TinyUIPage|TinyUIPage(wang.harlon.tinyui.RuntimeBundle;wang.harlon.tinyui.PageModule;wang.harlon.tinyui.schema.ComponentRegistry;wang.harlon.tinyui.PageSink;wang.harlon.tinyui.HostServices?;kotlin.String?;androidx.compose.ui.Modifier?;wang.harlon.tinyui.SourceMaps?;kotlin.Function3?;kotlin.Function1?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_BuildManifest$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_BuildManifest$stableprop_getter|wang_harlon_tinyui_BuildManifest$stableprop_getter(){}[0] final fun wang.harlon.tinyui/wang_harlon_tinyui_EventBus$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_EventBus$stableprop_getter|wang_harlon_tinyui_EventBus$stableprop_getter(){}[0] final fun wang.harlon.tinyui/wang_harlon_tinyui_HostException$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_HostException$stableprop_getter|wang_harlon_tinyui_HostException$stableprop_getter(){}[0] final fun wang.harlon.tinyui/wang_harlon_tinyui_HostServices$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_HostServices$stableprop_getter|wang_harlon_tinyui_HostServices$stableprop_getter(){}[0] @@ -501,8 +593,13 @@ final fun wang.harlon.tinyui/wang_harlon_tinyui_HttpRequest$stableprop_getter(): final fun wang.harlon.tinyui/wang_harlon_tinyui_HttpResponse$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_HttpResponse$stableprop_getter|wang_harlon_tinyui_HttpResponse$stableprop_getter(){}[0] final fun wang.harlon.tinyui/wang_harlon_tinyui_InMemoryStore$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_InMemoryStore$stableprop_getter|wang_harlon_tinyui_InMemoryStore$stableprop_getter(){}[0] final fun wang.harlon.tinyui/wang_harlon_tinyui_Navigator_None$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_Navigator_None$stableprop_getter|wang_harlon_tinyui_Navigator_None$stableprop_getter(){}[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_PageError$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_PageError$stableprop_getter|wang_harlon_tinyui_PageError$stableprop_getter(){}[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_PageModule$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_PageModule$stableprop_getter|wang_harlon_tinyui_PageModule$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_SourceMap_Position$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_SourceMap_Position$stableprop_getter|wang_harlon_tinyui_SourceMap_Position$stableprop_getter(){}[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_SourceMaps$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_SourceMaps$stableprop_getter|wang_harlon_tinyui_SourceMaps$stableprop_getter(){}[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_StackFrame$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_StackFrame$stableprop_getter|wang_harlon_tinyui_StackFrame$stableprop_getter(){}[0] final fun wang.harlon.tinyui/wang_harlon_tinyui_StoreValue$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_StoreValue$stableprop_getter|wang_harlon_tinyui_StoreValue$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 index 435565f..e400045 100644 --- a/compose/src/androidHostTest/kotlin/wang/harlon/tinyui/BundleSmokeTest.kt +++ b/compose/src/androidHostTest/kotlin/wang/harlon/tinyui/BundleSmokeTest.kt @@ -1,11 +1,19 @@ package wang.harlon.tinyui +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import wang.harlon.quickjs.JsEngine import wang.harlon.quickjs.JsEngineConfig import wang.harlon.quickjs.JsValue +import wang.harlon.tinyui.components.registerBuiltins +import wang.harlon.tinyui.schema.ComponentRegistry import java.io.File import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue /** Loads what `tinyui build` produced for the sample through the real engine; skips when the sample was not built. */ class BundleSmokeTest { @@ -24,4 +32,42 @@ class BundleSmokeTest { engine.runBytecode(out.resolve("pages/counter.bin").readBytes()).let { if (it is AutoCloseable) it.close() } } } + + /** A rejected `await` in the todos page's `loadMore` is E7; its frames map back to the `.tsx` through the built map. */ + @Test + fun stacksFromABuiltPageMapBackToTheSource() = runBlocking { + if (!out.isDirectory) return@runBlocking + val manifest = BuildManifest.parse(out.resolve("manifest.json").readText()) + val maps = SourceMaps(mapOf("pages/todos" to out.resolve("pages/todos.js.map").readText(), "@tiny-ui/core" to out.resolve("runtime/core.js.map").readText())) + val errors = mutableListOf() + val sink = object : PageSink { + override fun error(error: PageError) { errors += error } + override fun log(line: String) {} + } + val http = object : HttpClient { + override suspend fun request(request: HttpRequest): HttpResponse = + if (request.url.endsWith("page=1")) HttpResponse(200, """{"items":[{"id":1,"title":"a","done":false}],"next":2}""") + else throw HostException("E_HTTP", "500") + } + val runtime = RuntimeBundle(out.resolve("runtime/core.bin").readBytes(), out.resolve("runtime/native.bin").readBytes()) + val page = PageModule("pages/todos", out.resolve("pages/todos.bin").readBytes(), manifest.buildId("pages/todos")) + val host = PageHost(runtime, page, ComponentRegistry().registerBuiltins(), sink, HostServices(http = http), sourceMaps = maps) + host.start() + try { + // the list only exists once the first page has loaded; node ids are dense, so scan them + fun lazyColumn(): Int? = (1..host.tree.size * 2).firstOrNull { host.tree.node(it)?.type == "LazyColumn" } + withContext(Dispatchers.Default) { withTimeout(10_000) { + while (lazyColumn() == null) delay(20) + host.dispatch(lazyColumn()!!, "onReachEnd", "{}") + while (errors.none { it.kind == "E7" }) delay(20) + } } + val e = errors.single { it.kind == "E7" } + assertEquals(manifest.buildId("pages/todos"), e.buildId) + val own = e.frames.firstOrNull { it.mapped && it.file == "src/pages/todos.tsx" } ?: error("no mapped frame in ${e.frames}; stack=${e.jsStack}") + val source = File("../sample/js/src/pages/todos.tsx").readLines() + assertTrue("await http.get" in source[own.line - 1], "frame $own points at the await; stack=${e.jsStack}") + } finally { + host.close() + } + } } diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/BuildManifest.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/BuildManifest.kt new file mode 100644 index 0000000..19fb7c5 --- /dev/null +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/BuildManifest.kt @@ -0,0 +1,20 @@ +package wang.harlon.tinyui + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** `manifest.json` as `tinyui build` writes it: module names and the build id of each. */ +class BuildManifest(val runtime: List, val pages: List, val buildIds: Map) { + fun buildId(module: String): String = buildIds[module] ?: "" + + companion object { + fun parse(json: String): BuildManifest { + val root = Json.parseToJsonElement(json).jsonObject + val names = { key: String -> root[key]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList() } + val ids = root["buildIds"]?.jsonObject?.mapValues { it.value.jsonPrimitive.content } ?: emptyMap() + return BuildManifest(names("runtime"), names("pages"), ids) + } + } +} diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageError.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageError.kt new file mode 100644 index 0000000..763f2da --- /dev/null +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageError.kt @@ -0,0 +1,38 @@ +package wang.harlon.tinyui + +/** One line of a JS stack. With a source map the location is the `.tsx` one and [mapped] is true; otherwise it is what the engine printed. */ +class StackFrame(val function: String, val file: String, val line: Int, val column: Int?, val mapped: Boolean) { + override fun toString(): String = "at $function ($file:$line${column?.let { ":$it" } ?: ""})" +} + +/** + * Every failure of a page in one shape (docs/adr-002 §3.5): [kind] is E1–E7. [frames] is [jsStack] parsed and, + * where a source map was available, mapped back to the source; [buildId] pairs [jsStack] with the map offline. + */ +class PageError( + val kind: String, + val page: String, + val buildId: String, + val message: String, + /** E1: the K entry that was running (`dispatch`, `resolve`, `emit`…). */ + val entry: String? = null, + val jsStack: String? = null, + val frames: List = emptyList(), + /** E5: the op that was skipped. */ + val op: String? = null, +) { + override fun toString(): String = buildString { + append(kind).append(' ').append(page) + if (buildId.isNotEmpty()) append('@').append(buildId) + entry?.let { append(" [").append(it).append(']') } + append(": ").append(message) + op?.let { append(" op=").append(it) } + for (f in frames) append("\n ").append(f) + } +} + +interface PageSink { + fun error(error: PageError) + /** `console.*` and engine log lines. */ + fun log(line: String) +} diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt index 8c8ed6e..3c0ef2d 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt @@ -50,7 +50,6 @@ import wang.harlon.quickjs.JsRuntime import wang.harlon.quickjs.JsValue import wang.harlon.quickjs.ObjectTransport 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 @@ -59,16 +58,16 @@ import wang.harlon.tinyui.schema.SizeValue /** 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 bytecode; [name] is the module name (`pages/todos`) and [buildId] comes from `manifest.json`. */ +class PageModule(val name: String, val bytecode: ByteArray, val buildId: String = "") + /** 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 (E2 / E6): engine gone, error page to be rendered by the host. */ +class PageFailure(val error: PageError) { + val kind: String get() = error.kind + val message: String get() = error.message } -/** 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). JS runs on a thread @@ -77,15 +76,16 @@ class PageFailure(val kind: String, val message: String) @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) class PageHost( private val runtimeBundle: RuntimeBundle, - private val page: ByteArray, + private val page: PageModule, val registry: ComponentRegistry, private val sink: PageSink, private val services: HostServices = HostServices.Default, private val propsJson: String = "{}", /** K entries longer than this are interrupted and fail the page (docs/native-api.md §6). */ private val entryTimeoutMs: Long = 5_000, + private val sourceMaps: SourceMaps = SourceMaps.EMPTY, ) { - val tree = NodeTree(registry, sink::patchProblem) + val tree = NodeTree(registry) { report("E5", it.reason, op = it.op) } var failure: PageFailure? by mutableStateOf(null) private set @@ -94,7 +94,10 @@ class PageHost( private val subscriptions = HashMap() private val storeVersions = HashMap() private val jsThread = newSingleThreadContext("tinyui-page") - private val runtime = JsRuntime(JsEngineConfig(moduleScheme = MODULE_SCHEME, logger = sink::log), jsThread) + private val runtime = JsRuntime( + JsEngineConfig(moduleScheme = MODULE_SCHEME, logger = sink::log, onUnhandledRejection = { report("E7", it.message ?: "unhandled rejection", stack = it.jsStack) }), + jsThread, + ) private var entries: Entries? = null private class Entries(val self: JsRef, val fns: Map) { @@ -112,7 +115,7 @@ class PageHost( 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 + val namespace = runBytecode(page.bytecode, 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 } @@ -186,9 +189,14 @@ class PageHost( 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) + val error = report(kind, t.message ?: t.toString(), stack = (t as? JsException)?.jsStack) + failure = PageFailure(error) + } + + private fun report(kind: String, message: String, entry: String? = null, stack: String? = null, op: String? = null): PageError { + val error = PageError(kind, page.name, page.buildId, message, entry, stack, sourceMaps.frames(stack), op) + sink.error(error) + return error } private fun registerHost(engine: JsEngine) { @@ -201,13 +209,14 @@ class PageHost( val detail = (args[1] as? JsValue.Str)?.value ?: "" val fields = runCatching { Json.parseToJsonElement(detail).jsonObject }.getOrNull() if (kind == "E1" && fields != null) { - sink.businessError( + report( + "E1", + fields["message"]?.jsonPrimitive?.contentOrNull ?: "", entry = fields["entry"]?.jsonPrimitive?.contentOrNull ?: "?", - message = fields["message"]?.jsonPrimitive?.contentOrNull ?: "", stack = fields["stack"]?.jsonPrimitive?.contentOrNull, ) } else { - sink.businessError(entry = "?", message = "unparseable $kind report: $detail", stack = null) + report("E1", "unparseable $kind report: $detail") } JsValue.Undefined } diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/SourceMaps.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/SourceMaps.kt new file mode 100644 index 0000000..e381711 --- /dev/null +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/SourceMaps.kt @@ -0,0 +1,107 @@ +package wang.harlon.tinyui + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * The `.js.map` files `tinyui build` wrote, keyed by module name (`pages/todos`, `@tiny-ui/core`), parsed on first use. + * Ship them in debug builds only (docs/build-chain.md); release stacks are mapped offline with the build id. + */ +class SourceMaps(maps: Map) { + private val entries: Map> = maps.mapValues { (_, json) -> lazy { SourceMap(json) } } + + /** Parses an engine stack (` at fn (module:line:col)` per line), mapping the frames whose module has a map. */ + fun frames(stack: String?): List { + if (stack.isNullOrBlank()) return emptyList() + return stack.lineSequence().mapNotNull(::frame).toList() + } + + private fun frame(line: String): StackFrame? { + val m = FRAME.matchEntire(line.trim()) ?: return null + val function = m.groups[1]?.value ?: "" + val location = m.groups[2]?.value ?: m.groups[3]!!.value + val loc = LOCATION.matchEntire(location) + ?: return StackFrame(function, location, 0, null, mapped = false) + val file = loc.groupValues[1] + val lineNo = loc.groupValues[2].toInt() + val column = loc.groups[3]?.value?.toInt() + // a broken map must not cost the report itself: fall back to the engine's frame + val mapped = runCatching { entries[file.removePrefix("${PageHost.MODULE_SCHEME}:")]?.value?.lookup(lineNo, column) }.getOrNull() + return if (mapped != null) StackFrame(function, mapped.file, mapped.line, mapped.column, mapped = true) + else StackFrame(function, file, lineNo, column, mapped = false) + } + + companion object { + val EMPTY = SourceMaps(emptyMap()) + private val FRAME = Regex("""at (?:(.+?) \((.+)\)|(.+))""") + private val LOCATION = Regex("""(.+?):(\d+)(?::(\d+))?""") + } +} + +/** Source map v3 (`mappings` VLQ), enough to answer "which source line is generated line L, column C". */ +internal class SourceMap(json: String) { + class Position(val file: String, val line: Int, val column: Int) + + private val sources: List + /** Per generated line: segments as (generatedColumn, sourceIndex, sourceLine, sourceColumn), all 0-based. */ + private val lines: List + + init { + val root = Json.parseToJsonElement(json).jsonObject + sources = root["sources"]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList() + lines = decode(root["mappings"]?.jsonPrimitive?.content ?: "") + } + + /** [line] and [column] as the engine prints them (1-based); without a column the first segment of the line answers. */ + fun lookup(line: Int, column: Int?): Position? { + val segments = lines.getOrNull(line - 1) ?: return null + if (segments.isEmpty()) return null + var best = -1 + val target = (column ?: 1) - 1 + for (i in 0 until segments.size / 4) { + if (segments[i * 4] <= target) best = i else break + } + if (best < 0) best = 0 + val source = sources.getOrNull(segments[best * 4 + 1]) ?: return null + return Position(source, segments[best * 4 + 2] + 1, segments[best * 4 + 3] + 1) + } + + private fun decode(mappings: String): List { + val out = ArrayList() + var source = 0; var sourceLine = 0; var sourceColumn = 0 + for (group in mappings.split(';')) { + var generated = 0 + val segments = ArrayList() + if (group.isNotEmpty()) for (segment in group.split(',')) { + val fields = vlq(segment) + generated += fields[0] + if (fields.size >= 4) { + source += fields[1]; sourceLine += fields[2]; sourceColumn += fields[3] + segments += generated; segments += source; segments += sourceLine; segments += sourceColumn + } + } + out += segments.toIntArray() + } + return out + } + + private fun vlq(segment: String): IntArray { + val values = ArrayList() + var value = 0; var shift = 0 + for (c in segment) { + val digit = BASE64.indexOf(c) + require(digit >= 0) { "bad VLQ character '$c'" } + value = value or ((digit and 31) shl shift) + if (digit and 32 != 0) { shift += 5; continue } + values += if (value and 1 != 0) -(value shr 1) else value shr 1 + value = 0; shift = 0 + } + return values.toIntArray() + } + + private companion object { + const val BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + } +} diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUI.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUI.kt index 0ea9d7a..82c61a2 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUI.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUI.kt @@ -5,4 +5,7 @@ import wang.harlon.quickjs.QuickJs object TinyUI { /** Version of the quickjs-kmp SDK this build links against. */ val engineVersion: String get() = QuickJs.sdkVersion + + /** Debug builds show the (mapped) JS stack on the page-failure screen; keep false in release. */ + var debug: Boolean = false } diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUIPage.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUIPage.kt index bd7ec87..099bb9b 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUIPage.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUIPage.kt @@ -1,12 +1,19 @@ package wang.harlon.tinyui import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme 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 androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp import wang.harlon.tinyui.schema.ComponentRegistry /** @@ -16,16 +23,19 @@ import wang.harlon.tinyui.schema.ComponentRegistry @Composable fun TinyUIPage( runtime: RuntimeBundle, - page: ByteArray, + page: PageModule, registry: ComponentRegistry, sink: PageSink, services: HostServices = HostServices.Default, propsJson: String = "{}", modifier: Modifier = Modifier, - error: @Composable (PageFailure) -> Unit = { Text("TinyUI page failed (${it.kind}): ${it.message}") }, + sourceMaps: SourceMaps = SourceMaps.EMPTY, + error: @Composable (PageFailure) -> Unit = { PageFailureScreen(it) }, onHost: (PageHost) -> Unit = {}, ) { - val host = remember(runtime, page, registry, sink, services, propsJson) { PageHost(runtime, page, registry, sink, services, propsJson) } + val host = remember(runtime, page, registry, sink, services, propsJson, sourceMaps) { + PageHost(runtime, page, registry, sink, services, propsJson, sourceMaps = sourceMaps) + } DisposableEffect(host) { onHost(host) host.start() @@ -41,3 +51,15 @@ fun TinyUIPage( } } + +/** The default failure screen: kind and message, plus the stack when [TinyUI.debug] is on. */ +@Composable +fun PageFailureScreen(failure: PageFailure) { + Column(Modifier.padding(16.dp).verticalScroll(rememberScrollState())) { + Text("TinyUI page failed (${failure.kind})", style = MaterialTheme.typography.titleMedium) + Text(failure.message, style = MaterialTheme.typography.bodyMedium) + if (TinyUI.debug) { + for (frame in failure.error.frames) Text(frame.toString(), style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + } + } +} diff --git a/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageHostTest.kt b/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageHostTest.kt index 4e45545..17aa661 100644 --- a/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageHostTest.kt +++ b/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageHostTest.kt @@ -8,7 +8,6 @@ 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 @@ -18,9 +17,9 @@ 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 errors = 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 @$entry stack=${stack != null}" } + override fun error(error: PageError) { errors += error; log += error.toString() } override fun log(line: String) { log += line } } @@ -41,6 +40,7 @@ class PageHostTest { if (id === 2 && event === "onClick") handler(); if (event === "onTimer") __host_call("timer.schedule", 7, JSON.stringify({ ms: 10 })); if (event === "onBoom") { try { throw new Error("boom"); } catch (e) { __host_report("E1", JSON.stringify({ entry: "dispatch", message: e.message, stack: e.stack })); } } + if (event === "onReject") Promise.reject(new Error("nobody catches")); if (event === "onQuery") patches.push(["p",1,"text", __host_query("store.get", JSON.stringify({ key: "cart" })) + "|" + __host_query("i18n.t", JSON.stringify({ key: "hi" })) + "|" + JSON.parse(__host_query("device.info", "{}")).os]); if (event === "onHttp") __host_call("http.request", 9, JSON.stringify({ method: "GET", url: "/todos" })); if (event === "onSubscribe") { __host_send("store.subscribe", JSON.stringify({ key: "cart" })); __host_send("store.subscribe", JSON.stringify({ key: "cart" })); __host_send("events.subscribe", JSON.stringify({ topic: "net" })); __host_send("events.subscribe", JSON.stringify({ topic: "net" })); patches.push(["p",1,"text","subscribed"]); } @@ -52,7 +52,7 @@ class PageHostTest { 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 val page = PageModule("pages/counter", module("pages/counter", "export default function Counter() { return 'page'; }"), "abcd1234") private val store = InMemoryStore().apply { set("cart", """{"n":2}""") } private val services = HostServices( @@ -65,7 +65,8 @@ class PageHostTest { }, ) - private fun host(props: String = "{}") = PageHost(RuntimeBundle(core, native), page, ComponentRegistry().registerBuiltins(), sink, services, props) + private fun host(props: String = "{}", maps: SourceMaps = SourceMaps.EMPTY) = + PageHost(RuntimeBundle(core, native), page, ComponentRegistry().registerBuiltins(), sink, services, props, sourceMaps = maps) // runTest's virtual time never advances the real engine thread: wait on a real dispatcher private suspend fun PageHost.await(check: () -> Boolean) = @@ -113,7 +114,48 @@ class PageHostTest { host.dispatch(2, "onTimer", "{}") host.await { host.tree.node(1)!!.props["text"] == "timer 7" } host.dispatch(2, "onBoom", "{}") - host.await { log.any { it.startsWith("E1 boom") } } + host.await { errors.any { it.kind == "E1" } } + val e = errors.single { it.kind == "E1" } + assertEquals("boom", e.message) + assertEquals("dispatch", e.entry) + assertEquals("pages/counter", e.page) + assertEquals("abcd1234", e.buildId) + val frame = e.frames.first() + assertEquals("dispatch", frame.function) + assertEquals("@tiny-ui/core", frame.file, "engine frames name the module; stack=${e.jsStack}") + assertEquals(16, frame.line) + assertNotNull(frame.column, "bytecode compiled with --strip-source keeps columns; stack=${e.jsStack}") + host.close() + } + + @Test + fun framesAreMappedThroughTheModuleSourceMap() = runTest { + // generated line 16 → src/core.ts line 3 column 7: fifteen empty groups, then one segment [0, 0, 2, 6] + val map = SourceMaps(mapOf("@tiny-ui/core" to """{"version":3,"sources":["src/core.ts"],"mappings":";;;;;;;;;;;;;;;AAEM"}""")) + val host = host(maps = map) + host.start() + host.await { host.tree.root.children.isNotEmpty() } + host.dispatch(2, "onBoom", "{}") + host.await { errors.any { it.kind == "E1" } } + val frame = errors.single { it.kind == "E1" }.frames.first() + assertEquals(true, frame.mapped) + assertEquals("src/core.ts", frame.file) + assertEquals(3, frame.line) + assertEquals(7, frame.column) + host.close() + } + + @Test + fun unhandledRejectionsAreE7() = runTest { + val host = host() + host.start() + host.await { host.tree.root.children.isNotEmpty() } + host.dispatch(2, "onReject", "{}") + host.await { errors.any { it.kind == "E7" } } + val e = errors.single { it.kind == "E7" } + assertEquals(true, "nobody catches" in e.message, e.message) + assertEquals(true, e.frames.isNotEmpty(), "stack=${e.jsStack}") + assertNull(host.failure) host.close() } @@ -149,6 +191,8 @@ class PageHostTest { val failure = assertNotNull(host.failure) assertEquals("E2", failure.kind) assertEquals(true, "render exploded" in failure.message) + assertEquals(true, failure.error.frames.any { it.function == "flush" && it.file == "@tiny-ui/core" }, "stack=${failure.error.jsStack}") + assertEquals(1, errors.count { it.kind == "E2" }, "E2 goes through the sink once") host.close() } @@ -159,6 +203,7 @@ class PageHostTest { host.start() host.await { host.failure != null } assertEquals("E6", host.failure!!.kind) + assertEquals("E6", errors.single().kind) host.close() } diff --git a/docs/README.md b/docs/README.md index 8c7b010..cdb92cb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,7 +40,7 @@ JS 桥 Kotlin | 编号 | 主题 | 状态 | 结论 | |---|---|---|---| | [ADR-001](./adr-001-reactivity-model.md) | 状态管理与更新模型 | 已定(2026-09-14,所有权 / 清理 2026-09-15 补定) | 运行时 Signal(细粒度绑定),不做 vdom diff,不走编译期静态依赖;作用域只开在结构边界,同步渲染期外创建 effect 抛错 | -| [ADR-002](./adr-002-bridge-communication.md) | JS 与 Kotlin 通信机制(含序列化) | 已定(2026-09-15) | 双向入口 5 + 5;JS 专用线程、K 入口不等待;一次 K 入口一个事务;每页一个引擎;错误六类一个 sink;JSON 文本载荷 | +| [ADR-002](./adr-002-bridge-communication.md) | JS 与 Kotlin 通信机制(含序列化) | 已定(2026-09-15) | 双向入口 5 + 5;JS 专用线程、K 入口不等待;一次 K 入口一个事务;每页一个引擎;错误七类一个 sink;JSON 文本载荷 | | [ADR-003](./adr-003-kotlin-node-tree-and-registry.md) | Kotlin 侧节点表与组件注册 | 已定(2026-09-15) | 节点即重组单元;JS 线程直接写快照状态、主线程只重组;App 级注册表 + 清单下发;prop / event schema 写入时转换 | | [ADR-004](./adr-004-events-and-input-ownership.md) | 事件与输入状态归属 | 已定(2026-09-15) | 事件三分(离散 / 流式输入 / 连续),60 fps 状态留 Kotlin;新增 `x` 命令 op(ref + cmd);文本框 initial prop + 命令 + 事件,不受控回写 | | [ADR-005](./adr-005-engine.md) | JS 引擎选型 | 已定(2026-09-15) | MicroQuickJS → QuickJS(ES2025,经 quickjs-kmp);原生 ESM 必选;不降级 ES5;热下发不在本期 | @@ -99,7 +99,7 @@ tinyui/ | 文件 | 主题 | 状态 | |---|---|---| -| [build-chain.md](./build-chain.md) | 构建链:TSX → ESM 模块字节码,esbuild 选型,字节码由 CLI 经 `qjsc-kmp` 生成 | 已完成(2026-09-16) | +| [build-chain.md](./build-chain.md) | 构建链:TSX → ESM 模块字节码,esbuild 选型,字节码由 CLI 经 `qjsc-kmp` 生成;§7 错误上报与 source map 回映射 | 已完成(2026-09-16;§7 2026-09-17) | | [app-model.md](./app-model.md) | 应用模型:路由、生命周期、跨页状态在 Kotlin;页面之间经 Kotlin 中转的四种通信 | 已对齐(2026-09-16),API 面归 C 组 | | [js-runtime.html](./js-runtime.html) | **JS 层总览**:七样运行时对象、四个时期、响应式闭环、一个页面的一生、规则表。先读这份(浏览器打开) | 已定(2026-09-16) | | [runtime-api.md](./runtime-api.md) | `@tiny-ui/core` v1 的 API 定义、页面模块契约、桥入口 | 已定(2026-09-16) | diff --git a/docs/adr-002-bridge-communication.md b/docs/adr-002-bridge-communication.md index a790603..196706f 100644 --- a/docs/adr-002-bridge-communication.md +++ b/docs/adr-002-bridge-communication.md @@ -11,6 +11,7 @@ > - **每页一 Runtime 的论证重写**:§3.4 表中"后续页面只能走源码 evaluate"是 MicroQuickJS 特有问题,作废。QuickJS 下真正的替代是"一 Runtime 多 Context"(共享堆、原子表、shape),但限额与 interrupt 是 Runtime 级、卸载 = 关 Runtime 仍是最干净的回收,结论不变。2026-09-16 在 quickjs-kmp 侧实测后不翻:Runtime + Context 基线仅 155 KB,多 Context 每页只省约 100 KB;模块缓存挂在 JSContext 上,运行时模块每页仍要各编译一遍,共享堆换不来共享 `@tiny-ui/core`;微任务队列也是 Runtime 级,多页共享会打破"一次 K 入口 = 一个事务"的页级边界。quickjs-kmp 已定为不暴露多 Context(其 docs/decisions.md「Runtime / Context」条) > - K0 改为"注册运行时模块(`@tiny-ui/core` / `@tiny-ui/native`)→ 求值页面模块(Promise,排空后取 namespace)→ 调 `default` 导出挂载";"每引擎一个程序"的硬限制消失 > - Promise 原生,J3 / K3 的对接不再需要 polyfill +> - **sink 的形态(2026-09-17)**:七类错误统一为一个 `PageError`(kind / 页面 / buildId / message / 入口 / 原始栈 / 映射后的帧 / op)经 `PageSink.error()` 交出,`log()` 只收 `console.*`;栈经 source map 回映射到 `.tsx`,dev 进包实时映射、release 离线,见 build-chain.md §7 > - **新增 E7:未处理的 Promise rejection**——业务 `async` 里抛出且无人 catch,既不经入口 catch(非 E1)也非宿主失败(非 E3);由 quickjs-kmp 接 `JS_SetHostPromiseRejectionTracker` 上报,页面继续。**E6 增加栈溢出**:`JS_SetMaxStackSize` 触发归入引擎级失败 diff --git a/docs/build-chain.md b/docs/build-chain.md index 2a6a4f5..133c55c 100644 --- a/docs/build-chain.md +++ b/docs/build-chain.md @@ -1,8 +1,8 @@ # 构建链:TSX → ESM 模块字节码 -- 状态:已完成(2026-09-16,PR #2);sample 在 Android 模拟器与 iOS 模拟器上显示 `tinyui:pages/home` +- 状态:已完成(2026-09-16,PR #2;2026-09-17 加 §7 错误上报与 source map);sample 在 Android 模拟器与 iOS 模拟器上显示 `tinyui:pages/home` - 来源:[ADR-005](./adr-005-engine.md) §4 "语言目标 / 模块" 两行的展开;roadmap A 组"构建链打通" -- 范围:`@tiny-ui/cli` 的 `tinyui build`、`compose/` 的 K0 加载序列、sample 的接线与 CI。自动 thunk(roadmap C 组)、字节码行号回映射、`qjsc-kmp` 二进制分发、页面级配置文件不在本期 +- 范围:`@tiny-ui/cli` 的 `tinyui build`、`compose/` 的 K0 加载序列、sample 的接线与 CI。自动 thunk(roadmap C 组)、`qjsc-kmp` 二进制分发、页面级配置文件不在本期;字节码行号回映射见 §7 ## 1. 目标与验收 @@ -98,4 +98,16 @@ rolldown 是真正的备选(Rollup 同形 API、oxc 转译)。CLI 只包一 | `settings.gradle.kts` | 有 `quickjs-kmp.dir` 时把 `qjsc-kmp` 路径传给 Exec 任务 | | CI | gradle job 加 setup-node + pnpm,clone quickjs-kmp 的 catalog 版本 tag 编 `buildHostTools`(qjsc-kmp)与 `buildNativeHostJni`(Android host 测试要加载宿主 JNI 库,Maven 包里没有),经 `TINYUI_QJSC` / `TINYUI_QUICKJS_HOST_JNI` 传入;ios.yml 同样 | -source map 随 `.js` 输出到 `.map`;行列号回映射到 TSX 归 E1 / E2 错误上报那一期。 +## 7. 错误上报与 source map + +定于 2026-09-17。七类错误(ADR-002 §3.5,E1~E7)统一成一个 `PageError`(kind、页面模块名、buildId、message、E1 的入口名、原始 JS 栈、解析 / 映射后的帧、E5 的 op 原文)经 `PageSink.error()` 交给宿主;`console.*` 与引擎日志仍走 `PageSink.log()`。E2 / E6 同时置 `PageHost.failure`,错误页由 Kotlin 渲染,`TinyUI.debug` 为 true 时显示映射后的栈;E1 / E7 只上报不叠层,页面继续。 + +**栈的形态**:QuickJS 打印 `at fn (模块名:行:列)`,`--strip-source` 保留行与列(宿主测试断言)。J3 的 `HostError` 在 K3 `reject` 处创建,它自己的栈只会指向 core 的入口,所以 `call()` 时先记下调用点的栈(约 1 µs),rejection 带着它——`await http.get` 无人 catch 的 E7 因此指向业务的 `await` 那一行。 + +**回映射在哪做**:dev 构建把 `.js.map` 一起打进 App,Kotlin 侧 `SourceMaps` 按模块名惰性解析(source map v3 的 VLQ,约百行,无依赖),只解析栈里出现的行;release 不进包,sink 拿到原始栈 + `buildId`,离线对 map(Sentry / Crashlytics 的做法)。否掉 JS 侧运行时映射:字节码与内存都涨,出错时还要跑 JS。 + +**buildId**:CLI 对每个模块的 `.js` 取 sha256 前 8 位写进 `manifest.json` 的 `buildIds`,`BuildManifest.parse` 读出,`PageModule(name, bytecode, buildId)` 交给 `PageHost`。宿主发布时把 `out/` 目录整体归档即可对回。 + +**map 的路径**:thunk pass 的内联 map 以绝对路径作 `source`(相对路径会被 esbuild 再按文件目录解析一次,出现 `src/pages/src/pages/` 的重复,2026-09-17 修),esbuild 输出后 CLI 把 `sources` 改写为相对项目根(`src/pages/todos.tsx`、`../../packages/core/src/host.ts`),运行时与离线符号化都以此显示。 + +**sample 的接线**:`collectTinyUIResources` 默认连 `.js.map` 一起进资源,`-Ptinyui.maps=false` 不带;`App.kt` 读 manifest,能读到的 map 交给 `SourceMaps`,读不到就按原始栈上报。真实 App 按构建变体决定要不要带 map,库不替宿主决定。 diff --git a/docs/jsx-transform.md b/docs/jsx-transform.md index 995a8b2..cd516b9 100644 --- a/docs/jsx-transform.md +++ b/docs/jsx-transform.md @@ -71,7 +71,7 @@ h(Text, { text: thunk(() => "Count: " + count()), color: thunk(() => theme.prima ## 5. source map -本 pass 用字符串局部替换生成 map,以内联 `sourceMappingURL` 交给 esbuild;esbuild 与自己的 JSX / 合并 map 组合后输出 `pages/.js.map`,`sources` 指向原 `.tsx`。字节码保留行号(`--strip-source`),E1 / E2 的栈里是 `.js` 的行列,经 `.js.map` 回到 `.tsx`,这一步在错误上报那一期做。 +本 pass 用字符串局部替换生成 map,以内联 `sourceMappingURL` 交给 esbuild;esbuild 与自己的 JSX / 合并 map 组合后输出 `pages/.js.map`,`sources` 指向原 `.tsx`。字节码保留行列(`--strip-source`),E1 / E2 的栈里是 `.js` 的行列,经 `.js.map` 回到 `.tsx`([build-chain.md](./build-chain.md) §7)。 ## 6. 实现(非契约) diff --git a/docs/roadmap.md b/docs/roadmap.md index a2c4b62..56708f3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -38,6 +38,7 @@ | `@tiny-ui/native` 的 `navigation` / `store` / `events` API 面与 `Navigator` 接口([app-model.md](./app-model.md));`push` 的 Promise 糖做不做 | app-model.md | 已定(native-api.md §2–§5;Promise 糖不做) | | E3 错误 code 表、K 入口超时阈值、连续事件节流阈值 | ADR-002 / 004 | 已定(native-api.md §6) | | `Placeholder` 在 release 的表现 | ADR-003 | 已定(components.md §5:零尺寸空 Box) | +| 错误上报:`PageError` 单 sink、E7 接入、栈经 source map 回映射到 `.tsx`、buildId | ADR-002 §3.5 | 已完成(2026-09-17,[build-chain.md](./build-chain.md) §7) | ## D. 明确推迟(记录不做,触发条件写清) diff --git a/docs/runtime-api.md b/docs/runtime-api.md index aa99458..a8c36b9 100644 --- a/docs/runtime-api.md +++ b/docs/runtime-api.md @@ -272,8 +272,11 @@ export default function Home(props: HomeProps): Node { … } |---|---| | E1 handler / 回调抛错 | `dispatch` / `resolve` / `reject` / `emit` 内 catch → `__host_report("E1")` → 入口正常返回,Kotlin 照常调 `flush()` | | E2 渲染期抛错 | `mount()` 或 `flush()` **直接抛出**(`JsException` 带 JS 栈),运行时先清空 patch 缓冲;Kotlin 不再调 `flush()`,判页面失败 | -| E3 `reject` 到达 | 对应 Promise reject;无人 catch 走 E7(引擎的 unhandled rejection 回调,Kotlin 侧接) | +| E3 `reject` 到达 | 对应 Promise reject 为 `HostError`,其 `stack` 是 J3 调用点(`call()` 时记下),不是 `reject` 到达处;无人 catch 走 E7(引擎的 unhandled rejection 回调,Kotlin 侧接) | | E4 `__host_query` 抛错 | 在 JS 里是普通异常,落入 E1 | +| E7 未处理 rejection | quickjs-kmp `onUnhandledRejection` → Kotlin `PageError("E7")`,页面继续 | + +Kotlin 侧的统一上报对象 `PageError` 与栈回映射见 [build-chain.md](./build-chain.md) §7。 ## 10. 与 ADR 的差异说明 diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index f3b1867..b7ed76e 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -1,6 +1,7 @@ import { build as esbuild, type Plugin } from "esbuild"; +import { createHash } from "node:crypto"; import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { dirname, join, relative, resolve, sep } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { compileModule, findQjsc } from "./qjsc.ts"; import { TransformError, transformJsx } from "./transform.ts"; @@ -22,6 +23,8 @@ export interface BuiltModule { name: string; js: string; map: string; + /** First 8 hex digits of the sha256 of the ESM output: pairs a stack trace with its source map (docs/build-chain.md). */ + buildId: string; bin?: string; } @@ -34,6 +37,7 @@ export interface BuildResult { export interface Manifest { runtime: string[]; pages: string[]; + buildIds: Record; } export async function build(options: BuildOptions): Promise { @@ -53,6 +57,10 @@ export async function build(options: BuildOptions): Promise { await rm(join(out, "runtime"), { recursive: true, force: true }); const runtime = await bundleRuntime(root, out); const pages = await bundlePages(root, out, pageNames); + for (const m of [...runtime, ...pages]) { + m.buildId = createHash("sha256").update(await readFile(m.js)).digest("hex").slice(0, 8); + await rootRelativeSources(root, m.map); + } if (qjsc) { for (const m of [...runtime, ...pages]) { m.bin = m.js.replace(/\.js$/, ".bin"); @@ -61,7 +69,11 @@ export async function build(options: BuildOptions): Promise { } const manifest = join(out, "manifest.json"); - const content: Manifest = { runtime: runtime.map((m) => m.name), pages: pages.map((m) => m.name) }; + const content: Manifest = { + runtime: runtime.map((m) => m.name), + pages: pages.map((m) => m.name), + buildIds: Object.fromEntries([...runtime, ...pages].map((m) => [m.name, m.buildId])), + }; await writeFile(manifest, JSON.stringify(content, null, 2) + "\n"); return { runtime, pages, manifest }; } @@ -96,7 +108,7 @@ async function bundleRuntime(root: string, out: string): Promise outfile, external: RUNTIME_MODULES.filter((m) => m !== name), }); - built.push({ name, js: outfile, map: outfile + ".map" }); + built.push({ name, js: outfile, map: outfile + ".map", buildId: "" }); } return built; } @@ -118,8 +130,19 @@ async function bundlePages(root: string, out: string, pages: Map }); return [...pages.keys()].map((name) => { const js = join(outdir, name.slice("pages/".length) + ".js"); - return { name, js, map: js + ".map" }; + return { name, js, map: js + ".map", buildId: "" }; + }); +} + +/** esbuild writes `sources` relative to the map; the runtime and offline symbolication want paths from the project root. */ +async function rootRelativeSources(root: string, mapFile: string): Promise { + const map = JSON.parse(await readFile(mapFile, "utf8")) as { sources: string[] }; + map.sources = map.sources.map((s) => { + // a bare scheme (`tinyui:jsx-shim`) stays; an absolute path (`/x` or `C:\x`) or a map-relative one becomes root-relative + if (!isAbsolute(s) && /^[a-z][a-z0-9+.-]*:/i.test(s)) return s; + return relative(root, resolve(dirname(mapFile), s)).split(sep).join("/"); }); + await writeFile(mapFile, JSON.stringify(map)); } const JSX_SHIM = "tinyui:jsx-shim"; @@ -140,7 +163,8 @@ const pagePlugin: Plugin = { 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); + // the map's `source` is resolved against the file's directory, so it has to be the absolute path + const { code, map } = transformJsx(args.path, source); const inline = Buffer.from(map).toString("base64"); return { contents: `${code}\n//# sourceMappingURL=data:application/json;base64,${inline}`, loader: "tsx" }; } catch (e) { diff --git a/packages/cli/test/build.test.ts b/packages/cli/test/build.test.ts index 76eef57..635eb0b 100644 --- a/packages/cli/test/build.test.ts +++ b/packages/cli/test/build.test.ts @@ -63,7 +63,9 @@ describe("tinyui build", () => { assert.ok(map.sources.length > 0, `${m.name} has sources`); } const home = JSON.parse(await readFile(join(out, "pages", "home.js.map"), "utf8")) as { sources: string[] }; - assert.ok(home.sources.some((s) => s.endsWith("src/pages/home.tsx"))); + assert.ok(home.sources.includes("src/pages/home.tsx"), `sources are root-relative: ${home.sources}`); + const core = JSON.parse(await readFile(join(out, "runtime", "core.js.map"), "utf8")) as { sources: string[] }; + assert.ok(core.sources.every((s) => s.includes(":") || !s.startsWith("/")), `runtime sources are relative: ${core.sources}`); }); it("bundles each runtime module on its own", async () => { @@ -86,10 +88,11 @@ describe("tinyui build", () => { it("lists everything in manifest.json", async () => { const manifest = JSON.parse(await readFile(result.manifest, "utf8")); - assert.deepEqual(manifest, { - runtime: ["@tiny-ui/core", "@tiny-ui/native"], - pages: ["pages/home", "pages/nested/detail"], - }); + assert.deepEqual(manifest.runtime, ["@tiny-ui/core", "@tiny-ui/native"]); + assert.deepEqual(manifest.pages, ["pages/home", "pages/nested/detail"]); + assert.deepEqual(Object.keys(manifest.buildIds), [...manifest.runtime, ...manifest.pages]); + for (const id of Object.values(manifest.buildIds)) assert.match(id as string, /^[0-9a-f]{8}$/); + assert.equal(manifest.buildIds["pages/home"], result.pages[0]!.buildId); }); it("compiles every module to bytecode when qjsc-kmp is available", { skip: !process.env["TINYUI_QJSC"] && "TINYUI_QJSC not set" }, async () => { diff --git a/packages/core/src/host.ts b/packages/core/src/host.ts index 2655fce..c6481f6 100644 --- a/packages/core/src/host.ts +++ b/packages/core/src/host.ts @@ -8,6 +8,8 @@ declare const __host_report: (kind: string, detailJson: string) => void; interface Pending { resolve: (value: unknown) => void; reject: (error: HostError) => void; + /** Stack at the J3 call: a rejection's own stack would only show the K3 entry. */ + site?: string; } export class HostError extends Error { @@ -33,8 +35,9 @@ export function query(name: string, args: Record = {}): T { /** J3: asynchronous capability; settles when the host calls K3. */ export function call(name: string, args: Record = {}): Promise { const cbId = nextCbId++; + const site = new Error().stack; return new Promise((resolve, reject) => { - pending.set(cbId, { resolve: resolve as (v: unknown) => void, reject }); + pending.set(cbId, { resolve: resolve as (v: unknown) => void, reject, ...(site !== undefined && { site }) }); __host_call(name, cbId, JSON.stringify(args)); }); } @@ -72,7 +75,9 @@ export function rejectPending(cbId: number, errorJson: string): void { } catch { e = { code: "E_BAD_JSON", message: errorJson }; } - p.reject(new HostError(e.code ?? "unknown", e.message ?? "host call failed")); + const error = new HostError(e.code ?? "unknown", e.message ?? "host call failed"); + if (p.site !== undefined) error.stack = p.site; + p.reject(error); } export function cancelPending(cbId: number): void { diff --git a/packages/core/test/async.test.ts b/packages/core/test/async.test.ts index e042812..c52bf80 100644 --- a/packages/core/test/async.test.ts +++ b/packages/core/test/async.test.ts @@ -25,10 +25,11 @@ describe("resource + K3", () => { unmount(); }); - it("rejection lands in error() and is not reported", async () => { + it("rejection lands in error() with the call site's stack and is not reported", async () => { + let seen: HostError | undefined; mount(() => { const [, { error }] = resource(() => call("http.get")); - return h(Column, null, h(Text, { text: thunk(() => { const e = error(); return e instanceof HostError ? e.code : "ok"; }) })); + return h(Column, null, h(Text, { text: thunk(() => { const e = error(); if (e instanceof HostError) seen = e; return e instanceof HostError ? e.code : "ok"; }) })); }); tinyui().reject(bridge.calls.at(-1)!.cbId, JSON.stringify({ code: "E_NET", message: "offline" })); await drain(); @@ -36,6 +37,8 @@ describe("resource + K3", () => { tinyui().flush(); assert.deepEqual(bridge.applied.slice(before).flat(), [["p", 1, "text", "E_NET"]]); assert.equal(bridge.reports.length, 0); + assert.match(seen!.stack ?? "", /at resource /, "the stack is where call() happened (inside resource's fetcher)"); + assert.doesNotMatch(seen!.stack ?? "", /rejectPending/, "not where reject() arrived"); unmount(); }); diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index 108c612..01ed857 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -65,7 +65,11 @@ val buildTinyUIPages by tasks.registering(Exec::class) { } val collectTinyUIResources by tasks.registering(Sync::class) { - from(buildTinyUIPages) { include("**/*.bin", "manifest.json") } + // maps ride along for the debug-only failure screen (docs/build-chain.md); -Ptinyui.maps=false leaves them out + from(buildTinyUIPages) { + include("**/*.bin", "manifest.json") + if (providers.gradleProperty("tinyui.maps").orNull != "false") include("**/*.js.map") + } into(layout.buildDirectory.dir("tinyui-resources/files/tinyui")) } diff --git a/sample/shared/src/commonMain/kotlin/wang/harlon/tinyui/sample/App.kt b/sample/shared/src/commonMain/kotlin/wang/harlon/tinyui/sample/App.kt index 40e4e92..7b8ad37 100644 --- a/sample/shared/src/commonMain/kotlin/wang/harlon/tinyui/sample/App.kt +++ b/sample/shared/src/commonMain/kotlin/wang/harlon/tinyui/sample/App.kt @@ -15,27 +15,30 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import kotlinx.coroutines.delay import org.jetbrains.compose.resources.ExperimentalResourceApi +import wang.harlon.tinyui.BuildManifest import wang.harlon.tinyui.HostException import wang.harlon.tinyui.HostServices import wang.harlon.tinyui.HttpClient import wang.harlon.tinyui.HttpRequest import wang.harlon.tinyui.HttpResponse +import wang.harlon.tinyui.PageError +import wang.harlon.tinyui.PageModule import wang.harlon.tinyui.PageSink import wang.harlon.tinyui.RuntimeBundle +import wang.harlon.tinyui.SourceMaps +import wang.harlon.tinyui.TinyUI import wang.harlon.tinyui.TinyUIPage import wang.harlon.tinyui.components.registerBuiltins -import wang.harlon.tinyui.node.PatchProblem import wang.harlon.tinyui.sample.res.Res import wang.harlon.tinyui.schema.ComponentRegistry -private class Bundle(val runtime: RuntimeBundle, val page: ByteArray) +private class Bundle(val runtime: RuntimeBundle, val page: PageModule, val maps: SourceMaps) /** App-level, built once (docs/adr-003 §3.3). */ private val registry = ComponentRegistry().registerBuiltins() private val sink = object : PageSink { - override fun patchProblem(problem: PatchProblem) = println("TinyUI E5 $problem") - override fun businessError(entry: String, message: String, stack: String?) = println("TinyUI E1 [$entry] $message") + override fun error(error: PageError) = println("TinyUI $error") override fun log(line: String) = println("TinyUI $line") } @@ -58,18 +61,27 @@ private val services = HostServices(http = FakeTodos, deviceInfo = mapOf("app" t fun App() { var bundle by remember { mutableStateOf(null) } LaunchedEffect(Unit) { + val manifest = BuildManifest.parse(Res.readBytes("files/tinyui/manifest.json").decodeToString()) + // debug builds ship the maps; without them (-Ptinyui.maps=false) stacks stay as the engine printed them + val maps = (manifest.runtime + manifest.pages).mapNotNull { name -> + val file = if (name.startsWith("@tiny-ui/")) "runtime/" + name.removePrefix("@tiny-ui/") else name + runCatching { Res.readBytes("files/tinyui/$file.js.map").decodeToString() }.getOrNull()?.let { name to it } + }.toMap() + // stacks on the failure screen only when the maps came along, i.e. the same switch as -Ptinyui.maps + TinyUI.debug = maps.isNotEmpty() bundle = Bundle( RuntimeBundle( core = Res.readBytes("files/tinyui/runtime/core.bin"), native = Res.readBytes("files/tinyui/runtime/native.bin"), ), - page = Res.readBytes("files/tinyui/pages/todos.bin"), + page = PageModule("pages/todos", Res.readBytes("files/tinyui/pages/todos.bin"), manifest.buildId("pages/todos")), + maps = SourceMaps(maps), ) } MaterialTheme { Box(Modifier.fillMaxSize().safeDrawingPadding(), contentAlignment = Alignment.Center) { val b = bundle - if (b == null) Text("loading…") else TinyUIPage(b.runtime, b.page, registry, sink, services) + if (b == null) Text("loading…") else TinyUIPage(b.runtime, b.page, registry, sink, services, sourceMaps = b.maps) } } }