@abide/abide 0.46.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/AGENTS.md +370 -320
  2. package/CHANGELOG.md +94 -0
  3. package/README.md +203 -168
  4. package/package.json +12 -8
  5. package/src/abideLsp.ts +11 -12
  6. package/src/abideResolverPlugin.ts +9 -4
  7. package/src/lib/bundle/disconnected.abide +3 -0
  8. package/src/lib/cli/printCommandHelp.ts +43 -0
  9. package/src/lib/cli/printSessionHelp.ts +2 -1
  10. package/src/lib/cli/{printHelp.ts → printTopLevelHelp.ts} +3 -40
  11. package/src/lib/cli/runCli.ts +2 -1
  12. package/src/lib/mcp/buildPrompts.ts +25 -0
  13. package/src/lib/mcp/dispatchMcpRequest.ts +8 -6
  14. package/src/lib/mcp/mcpResourceServerSlot.ts +5 -10
  15. package/src/lib/mcp/mcpSurface.ts +13 -252
  16. package/src/lib/mcp/mcpTools.ts +187 -0
  17. package/src/lib/mcp/renderPrompt.ts +25 -0
  18. package/src/lib/mcp/types/McpSurface.ts +15 -0
  19. package/src/lib/mcp/types/PromptDescriptor.ts +5 -0
  20. package/src/lib/mcp/types/PromptMessage.ts +2 -0
  21. package/src/lib/mcp/types/ToolDescriptor.ts +7 -0
  22. package/src/lib/mcp/types/ToolResult.ts +2 -0
  23. package/src/lib/server/agent.ts +1 -1
  24. package/src/lib/server/jsonl.ts +2 -2
  25. package/src/lib/server/rpc/defineRpc.ts +5 -0
  26. package/src/lib/server/runtime/buildCacheSnapshot.ts +6 -6
  27. package/src/lib/server/runtime/containsTraversal.ts +21 -17
  28. package/src/lib/server/runtime/createAppAssetServer.ts +11 -16
  29. package/src/lib/server/runtime/createServer.ts +101 -71
  30. package/src/lib/server/runtime/createUiPageRenderer.ts +23 -4
  31. package/src/lib/server/runtime/devClientFingerprint.ts +3 -3
  32. package/src/lib/server/runtime/devHotModuleResponse.ts +2 -1
  33. package/src/lib/server/runtime/finalizeResponse.ts +32 -0
  34. package/src/lib/server/runtime/snapshotEntryFromCache.ts +8 -17
  35. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  36. package/src/lib/server/runtime/types/InspectorCacheSnapshot.ts +4 -4
  37. package/src/lib/server/runtime/types/InspectorContext.ts +1 -1
  38. package/src/lib/server/socket.ts +1 -1
  39. package/src/lib/server/sockets/createSocketDispatcher.ts +28 -5
  40. package/src/lib/server/sockets/defineSocket.ts +26 -7
  41. package/src/lib/server/sockets/types/SocketRegistryEntry.ts +1 -1
  42. package/src/lib/server/sockets/types/SocketRoutes.ts +1 -1
  43. package/src/lib/server/sse.ts +2 -2
  44. package/src/lib/shared/DEFER.ts +8 -0
  45. package/src/lib/shared/activeCacheStore.ts +2 -2
  46. package/src/lib/shared/activePage.ts +2 -2
  47. package/src/lib/shared/attachRpcSelectorMethods.ts +42 -0
  48. package/src/lib/shared/attachSocketSelectorMethods.ts +34 -0
  49. package/src/lib/shared/basePath.ts +2 -2
  50. package/src/lib/shared/baseSlot.ts +6 -5
  51. package/src/lib/shared/bodyValueForKind.ts +1 -2
  52. package/src/lib/shared/buildSocketOverChannel.ts +40 -4
  53. package/src/lib/shared/cache.ts +522 -117
  54. package/src/lib/shared/cacheEntryFromSnapshot.ts +3 -22
  55. package/src/lib/shared/cacheStoreSlot.ts +9 -5
  56. package/src/lib/shared/cacheStores.ts +3 -3
  57. package/src/lib/{server/runtime → shared}/createReachable.ts +2 -2
  58. package/src/lib/shared/createRemoteFunction.ts +58 -9
  59. package/src/lib/shared/createResolverSlot.ts +24 -31
  60. package/src/lib/shared/createSocketSubRegistry.ts +2 -2
  61. package/src/lib/shared/decodeRefJson.ts +4 -2
  62. package/src/lib/shared/decodeResponse.ts +8 -6
  63. package/src/lib/shared/done.ts +14 -0
  64. package/src/lib/shared/encodeRefJson.ts +4 -2
  65. package/src/lib/shared/hydratingSlot.ts +12 -0
  66. package/src/lib/shared/isLayoutFile.ts +12 -0
  67. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  68. package/src/lib/shared/keyPrefixForRemote.ts +12 -0
  69. package/src/lib/shared/matchRoute.ts +175 -0
  70. package/src/lib/shared/normalizePathname.ts +11 -0
  71. package/src/lib/shared/pageSlot.ts +14 -5
  72. package/src/lib/shared/pageUrlForFile.ts +3 -3
  73. package/src/lib/shared/parseRouteSegments.ts +16 -7
  74. package/src/lib/shared/patch.ts +41 -0
  75. package/src/lib/shared/peek.ts +35 -0
  76. package/src/lib/shared/prepareRemoteExport.ts +40 -0
  77. package/src/lib/shared/prepareRpcModule.ts +98 -16
  78. package/src/lib/shared/prepareSocketModule.ts +11 -15
  79. package/src/lib/shared/queryStringFromArgs.ts +11 -0
  80. package/src/lib/shared/reachable.ts +102 -0
  81. package/src/lib/shared/refresh.ts +22 -0
  82. package/src/lib/shared/requestScopeSlot.ts +10 -7
  83. package/src/lib/shared/routeParamsShape.ts +9 -9
  84. package/src/lib/shared/rpcErrorRegistry.ts +69 -0
  85. package/src/lib/shared/selectorPrefix.ts +3 -10
  86. package/src/lib/shared/setOwnProperty.ts +19 -0
  87. package/src/lib/shared/sharedCacheStore.ts +14 -0
  88. package/src/lib/shared/sharedCacheStoreSlot.ts +10 -0
  89. package/src/lib/shared/snippet.ts +1 -1
  90. package/src/lib/shared/subscribableProbes.ts +111 -0
  91. package/src/lib/shared/tailProbeSlot.ts +8 -1
  92. package/src/lib/shared/types/CacheEntry.ts +9 -15
  93. package/src/lib/shared/types/CacheOptions.ts +23 -11
  94. package/src/lib/shared/types/CacheSnapshotEntry.ts +0 -5
  95. package/src/lib/shared/types/CacheStats.ts +1 -1
  96. package/src/lib/shared/types/RemoteCallable.ts +25 -7
  97. package/src/lib/shared/types/RemoteFunction.ts +48 -13
  98. package/src/lib/shared/types/ResolverSlot.ts +7 -4
  99. package/src/lib/shared/types/RpcError.ts +26 -0
  100. package/src/lib/shared/types/SmartReadOptions.ts +36 -0
  101. package/src/lib/shared/types/Socket.ts +54 -0
  102. package/src/lib/shared/types/SsrBootState.ts +27 -0
  103. package/src/lib/shared/types/SsrPayload.ts +21 -0
  104. package/src/lib/shared/types/Subscribable.ts +13 -13
  105. package/src/lib/shared/types/TailHooks.ts +2 -1
  106. package/src/lib/shared/url.ts +29 -14
  107. package/src/lib/shared/wakeHydrationPeeks.ts +20 -0
  108. package/src/lib/shared/writeTestSocketsDts.ts +1 -1
  109. package/src/lib/test/createScriptedSurface.ts +1 -1
  110. package/src/lib/test/createTestApp.ts +8 -8
  111. package/src/lib/test/createTestSocketChannel.ts +3 -3
  112. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +5 -6
  113. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +10 -7
  114. package/src/lib/ui/compile/abideUiPlugin.ts +2 -2
  115. package/src/lib/ui/compile/analyzeComponent.ts +28 -6
  116. package/src/lib/ui/compile/compileModule.ts +137 -11
  117. package/src/lib/ui/compile/compileShadow.ts +101 -84
  118. package/src/lib/ui/compile/desugarSignals.ts +120 -107
  119. package/src/lib/ui/compile/generateBuild.ts +48 -16
  120. package/src/lib/ui/compile/generateSSR.ts +117 -14
  121. package/src/lib/ui/compile/identifierReferencePattern.ts +13 -0
  122. package/src/lib/ui/compile/lowerContext.ts +10 -4
  123. package/src/lib/ui/compile/lowerScript.ts +72 -5
  124. package/src/lib/ui/compile/parseTemplate.ts +1 -14
  125. package/src/lib/ui/compile/prepareNestedScript.ts +31 -17
  126. package/src/lib/ui/compile/resolveReactiveExport.ts +95 -0
  127. package/src/lib/ui/compile/signalCallee.ts +24 -0
  128. package/src/lib/ui/compile/stripEffects.ts +14 -11
  129. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  130. package/src/lib/ui/compile/types/TemplateNode.ts +0 -5
  131. package/src/lib/ui/dom/appendStatic.ts +9 -1
  132. package/src/lib/ui/dom/appendText.ts +15 -3
  133. package/src/lib/ui/dom/assertClaimedText.ts +20 -0
  134. package/src/lib/ui/dom/awaitBlock.ts +19 -83
  135. package/src/lib/ui/dom/bindSelectValue.ts +48 -0
  136. package/src/lib/ui/dom/each.ts +12 -1
  137. package/src/lib/ui/dom/hydrate.ts +10 -0
  138. package/src/lib/ui/dom/mountChild.ts +2 -5
  139. package/src/lib/ui/dom/mountRange.ts +0 -75
  140. package/src/lib/ui/effect.ts +4 -3
  141. package/src/lib/ui/installHotBridge.ts +4 -2
  142. package/src/lib/ui/remoteProxy.ts +18 -2
  143. package/src/lib/ui/renderToStream.ts +9 -13
  144. package/src/lib/ui/resumeSeedScript.ts +2 -2
  145. package/src/lib/ui/router.ts +21 -2
  146. package/src/lib/ui/runtime/RESUME.ts +0 -6
  147. package/src/lib/ui/runtime/ambientScopeBacking.ts +1 -1
  148. package/src/lib/ui/runtime/types/SsrRender.ts +3 -4
  149. package/src/lib/ui/scope.ts +6 -3
  150. package/src/lib/ui/seedBootState.ts +53 -0
  151. package/src/lib/ui/socketChannel.ts +2 -2
  152. package/src/lib/ui/socketProxy.ts +9 -3
  153. package/src/lib/ui/startClient.ts +17 -31
  154. package/src/lib/ui/state.ts +44 -13
  155. package/src/lib/ui/sync.ts +11 -5
  156. package/src/lib/ui/tryEncodeResume.ts +2 -5
  157. package/src/lib/ui/types/Scope.ts +14 -10
  158. package/src/lib/ui/watch.ts +140 -0
  159. package/src/serverEntry.ts +13 -11
  160. package/template/CLAUDE.md +1 -1
  161. package/template/package.json +1 -1
  162. package/template/src/server/rpc/getHello.ts +3 -3
  163. package/template/src/ui/pages/page.abide +2 -2
  164. package/template/test/app.test.ts +1 -1
  165. package/src/lib/server/reachable.ts +0 -45
  166. package/src/lib/server/sockets/types/Socket.ts +0 -23
  167. package/src/lib/shared/CACHE_WRAPPED.ts +0 -9
  168. package/src/lib/shared/DEFER_MIN_ARRAY_LENGTH.ts +0 -14
  169. package/src/lib/shared/baseResolver.ts +0 -10
  170. package/src/lib/shared/cacheKeyOf.ts +0 -7
  171. package/src/lib/shared/cacheKeyStore.ts +0 -8
  172. package/src/lib/shared/cacheStoreResolver.ts +0 -12
  173. package/src/lib/shared/globalCacheStore.ts +0 -15
  174. package/src/lib/shared/globalCacheStoreResolver.ts +0 -12
  175. package/src/lib/shared/globalCacheStoreSlot.ts +0 -9
  176. package/src/lib/shared/pageResolver.ts +0 -16
  177. package/src/lib/shared/recordCacheKey.ts +0 -6
  178. package/src/lib/shared/requestScopeResolver.ts +0 -12
  179. package/src/lib/shared/setBaseResolver.ts +0 -4
  180. package/src/lib/shared/setCacheStoreResolver.ts +0 -4
  181. package/src/lib/shared/setGlobalCacheStoreResolver.ts +0 -4
  182. package/src/lib/shared/setPageResolver.ts +0 -4
  183. package/src/lib/shared/setRequestScopeResolver.ts +0 -4
  184. package/src/lib/shared/toBunRoutePattern.ts +0 -28
  185. package/src/lib/shared/types/TailOptions.ts +0 -10
  186. package/src/lib/ui/deferResume.ts +0 -36
  187. package/src/lib/ui/dom/firstElementBetween.ts +0 -14
  188. package/src/lib/ui/matchRoute.ts +0 -106
  189. package/src/lib/ui/runtime/scheduleWake.ts +0 -28
  190. package/src/lib/ui/runtime/whenIdle.ts +0 -21
  191. package/src/lib/ui/runtime/whenVisible.ts +0 -105
  192. package/src/lib/ui/tail.ts +0 -324
  193. /package/src/lib/{server/sockets → shared}/types/SocketClientFrame.ts +0 -0
  194. /package/src/lib/{server/sockets → shared}/types/SocketServerFrame.ts +0 -0
@@ -23,6 +23,7 @@ import { skeletonContext } from './skeletonContext.ts'
23
23
  import { snippetPlan } from './snippetPlan.ts'
24
24
  import { spreadExcludedNames } from './spreadExcludedNames.ts'
25
25
  import { staticAttr } from './staticAttr.ts'
26
+ import { staticAttrValue } from './staticAttrValue.ts'
26
27
  import { staticTextPart } from './staticTextPart.ts'
27
28
  import { stripEffects } from './stripEffects.ts'
28
29
  import { switchPlan } from './switchPlan.ts'
@@ -70,6 +71,12 @@ export function generateSSR(
70
71
  allocated separately at runtime via `$ctx.next++`. */
71
72
  const nextVar = makeVarNamer()
72
73
 
74
+ /* The enclosing `<select bind:value>`s, innermost last: each carries the JS var holding
75
+ its bound value and whether it's a `multiple` (array) select, so an `<option>` rendered
76
+ within can emit `selected` by comparing its own value against the bound one. Pushed when
77
+ a bound select opens, popped after its children. */
78
+ const selectBinds: { variable: string; multiple: boolean }[] = []
79
+
73
80
  /* The shared signal→`model` lowering + branch-scoped nested-script deref scope. */
74
81
  const {
75
82
  expression: lowerExpression,
@@ -92,6 +99,47 @@ export function generateSSR(
92
99
  return `${target}.push(${JSON.stringify(literal)});\n`
93
100
  }
94
101
 
102
+ /* The JS expression for an `<option>`'s value, to compare against an enclosing bound
103
+ `<select>`: the `value` attribute (static/expression/interpolated), else the option's
104
+ static text content (the browser's own fallback, whitespace-trimmed). Returns undefined
105
+ when the value can't be known at compile time (a dynamic-text option with no `value`
106
+ attr) — the client selects it on hydrate. */
107
+ function optionValueForSSR(
108
+ node: Extract<TemplateNode, { kind: 'element' }>,
109
+ ): string | undefined {
110
+ const valueAttr = node.attrs.find(
111
+ (attr) =>
112
+ (attr.kind === 'static' ||
113
+ attr.kind === 'expression' ||
114
+ attr.kind === 'interpolated') &&
115
+ attr.name === 'value',
116
+ )
117
+ if (valueAttr !== undefined) {
118
+ if (valueAttr.kind === 'static') {
119
+ return JSON.stringify(valueAttr.value)
120
+ }
121
+ if (valueAttr.kind === 'expression') {
122
+ return lowerExpression(valueAttr.code)
123
+ }
124
+ if (valueAttr.kind === 'interpolated') {
125
+ return lowerExpression(interpolatedTemplateLiteral(valueAttr.parts))
126
+ }
127
+ }
128
+ let staticText = ''
129
+ for (const child of node.children) {
130
+ if (child.kind !== 'text') {
131
+ return undefined
132
+ }
133
+ for (const part of child.parts) {
134
+ if (part.kind !== 'static') {
135
+ return undefined
136
+ }
137
+ staticText += part.value
138
+ }
139
+ }
140
+ return JSON.stringify(staticText.trim())
141
+ }
142
+
95
143
  function generateInto(children: TemplateNode[], target: string): string {
96
144
  return children.map((child) => generate(child, target)).join('')
97
145
  }
@@ -110,8 +158,18 @@ export function generateSSR(
110
158
  never matches (`$` is a non-word char, so there is no word boundary before it), which
111
159
  would silently miss every `$row(...)` call. A negative lookbehind for word-or-`$`
112
160
  matches the same call sites as `\b` for word-leading names while also catching them. */
113
- const callPattern = (name: string): RegExp =>
114
- new RegExp(`(?<![$\\w])${escapeRegex(name)}\\s*\\(`)
161
+ /* Memoised per name: the pattern is a pure function of the name but tested across the
162
+ fixpoint loop below (per snippet × per text part × per iteration), so recompiling the
163
+ RegExp each test — far costlier than the test itself — dominated the snippet scan. */
164
+ const callPatternCache = new Map<string, RegExp>()
165
+ const callPattern = (name: string): RegExp => {
166
+ let pattern = callPatternCache.get(name)
167
+ if (pattern === undefined) {
168
+ pattern = new RegExp(`(?<![$\\w])${escapeRegex(name)}\\s*\\(`)
169
+ callPatternCache.set(name, pattern)
170
+ }
171
+ return pattern
172
+ }
115
173
  /* A subtree call to any of `names` from a TEXT interpolation (`name()` / `name(args)`). */
116
174
  const subtreeCalls = (children: TemplateNode[], names: ReadonlySet<string>): boolean =>
117
175
  children.some((child) => {
@@ -167,8 +225,18 @@ export function generateSSR(
167
225
  }
168
226
  /* A text-part expression that calls an async snippet, so its value is `await`ed before
169
227
  `$text`. */
170
- const callsAsyncSnippet = (code: string): boolean =>
171
- [...asyncSnippets].some((name) => callPattern(name).test(code))
228
+ const callsAsyncSnippet = (code: string): boolean => {
229
+ // The common no-async-snippet component pays zero regex work per text part.
230
+ if (asyncSnippets.size === 0) {
231
+ return false
232
+ }
233
+ for (const name of asyncSnippets) {
234
+ if (callPattern(name).test(code)) {
235
+ return true
236
+ }
237
+ }
238
+ return false
239
+ }
172
240
 
173
241
  /* Per-node skeleton position, computed once. Both back-ends read this single source of
174
242
  truth so their `<!--a-->` anchor placement cannot drift — the fresh-context boundaries
@@ -443,6 +511,14 @@ export function generateSSR(
443
511
  /* A boolean property — its mere presence means checked, so emit the
444
512
  attribute only when truthy (a string `checked="false"` still checks). */
445
513
  code += `${target}.push((${bindRead(attr.code)}) ? ' checked' : '');\n`
514
+ } else if (attr.kind === 'bind' && attr.property === 'open') {
515
+ /* `<details open>` — `open` is a boolean attribute, so `open="false"` would
516
+ still render open. Emit the bare attribute only when truthy, like checked. */
517
+ code += `${target}.push((${bindRead(attr.code)}) ? ' open' : '');\n`
518
+ } else if (attr.kind === 'bind' && attr.property === 'value' && node.tag === 'select') {
519
+ /* `<select bind:value>` selects via `selected` on the matching option (a
520
+ `value="…"` on the select is ignored by browsers), wired below. Emit nothing
521
+ here. */
446
522
  } else if (attr.kind === 'bind') {
447
523
  code += `${target}.push(${JSON.stringify(` ${attr.property}="`)} + $esc(${bindRead(attr.code)}) + '"');\n`
448
524
  }
@@ -455,7 +531,33 @@ export function generateSSR(
455
531
  if (merge.mergeStyle) {
456
532
  code += `${target}.push(' style="' + $esc([${merge.styleParts.join(', ')}].filter(Boolean).join(';')) + '"');\n`
457
533
  }
534
+ /* An `<option>` inside a bound `<select>`: emit `selected` when its value equals the
535
+ bound value (single) or is a member of it (multiple). */
536
+ if (node.tag === 'option' && selectBinds.length > 0) {
537
+ const optionValue = optionValueForSSR(node)
538
+ const bind = selectBinds[selectBinds.length - 1]
539
+ if (optionValue !== undefined && bind !== undefined) {
540
+ const present = bind.multiple
541
+ ? `Array.isArray(${bind.variable}) && ${bind.variable}.includes(${optionValue})`
542
+ : `(${optionValue}) === (${bind.variable})`
543
+ code += `${target}.push((${present}) ? ' selected' : '');\n`
544
+ }
545
+ }
458
546
  code += push(target, '>')
547
+ /* A bound `<select>` publishes its current value to a local so the options rendered
548
+ as its children can compare against it; popped once past those children. */
549
+ const selectValueBind =
550
+ node.tag === 'select'
551
+ ? node.attrs.find((attr) => attr.kind === 'bind' && attr.property === 'value')
552
+ : undefined
553
+ if (selectValueBind !== undefined && selectValueBind.kind === 'bind') {
554
+ const variable = nextVar('$sel')
555
+ code += `const ${variable} = ${bindRead(selectValueBind.code)};\n`
556
+ selectBinds.push({
557
+ variable,
558
+ multiple: staticAttrValue(node, 'multiple') !== undefined,
559
+ })
560
+ }
459
561
  if (!plan.isVoid) {
460
562
  /* Each child's skeleton position (whether its reactive text interleaves into an
461
563
  anchor, whether a nested block anchors) is already recorded by `skeletonContext`
@@ -464,6 +566,9 @@ export function generateSSR(
464
566
  code += withNestedScripts(node.children, () => generateInto(node.children, target))
465
567
  code += push(target, `</${node.tag}>`)
466
568
  }
569
+ if (selectValueBind !== undefined) {
570
+ selectBinds.pop()
571
+ }
467
572
  return code
468
573
  }
469
574
 
@@ -527,15 +632,10 @@ export function generateSSR(
527
632
  `withBindings` shadow models, so a reference to the binding reads the plain
528
633
  local rather than the (unresolved) component signal it shadows. */
529
634
  const resolved = nextVar('$av')
530
- /* Keep the promise object: a cache() read tagged it with its store key, which
531
- `$$deferResume` reads to defer a cache-backed value (ship a `{defer,key}` marker +
532
- lazy seed) instead of inlining it — the blocking form's contract. */
533
- const promiseVar = nextVar('$ap')
534
635
  let code = `const ${id} = $ctx.next++;\n`
535
636
  code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
536
637
  code += `try {\n`
537
- code += `const ${promiseVar} = (${lowerExpression(node.promise)});\n`
538
- code += `const ${resolved} = await ${promiseVar};\n`
638
+ code += `const ${resolved} = await (${lowerExpression(node.promise)});\n`
539
639
  code += `{\n`
540
640
  code += `const ${plan.resolvedAs} = ${resolved};\n`
541
641
  code += withBindings(withShadow, plan.resolvedBindings, ssrBindingKind, () =>
@@ -545,7 +645,12 @@ export function generateSSR(
545
645
  shadow — matching the catch branch below, so a finally expression naming the
546
646
  same identifier as the `then` binding reads the component signal, not the local. */
547
647
  code += branchContent(plan.finallyChildren, target)
548
- code += `$resume[${id}] = $$deferResume(${promiseVar}, ${plan.resolvedAs});\n`
648
+ /* Seed the resolved value into the resume manifest so hydration adopts the server
649
+ branch warm (no round-trip) and wires it live on the first frame. Seed the
650
+ resolved temp, NOT `plan.resolvedAs` — the latter is the author's binding
651
+ PATTERN (e.g. `{name}` or `{name = 'anon'}`), which as an expression rebuilds a
652
+ partial object or is a CoverInitializedName syntax error. */
653
+ code += `$resume[${id}] = { ok: true, value: ${resolved} };\n`
549
654
  code += `}\n`
550
655
  if (plan.surfaceRejection) {
551
656
  /* No catch/finally → let the rejection surface instead of an empty branch. */
@@ -569,9 +674,7 @@ export function generateSSR(
569
674
  `await` block inside the branch composes. `finally` appends after the outcome,
570
675
  matching the client's concatenated node range. Neither catch nor finally → omit
571
676
  `catch` so a rejection surfaces (renderToStream re-throws); a finally-only block
572
- keeps a catch renderer that renders just finally. A cache-backed streaming value is
573
- DEFERRED at settle time (renderToStream ships a `{defer,key}` marker + lazy body, the
574
- client adopts the streamed branch inert) — the machinery here is unchanged. */
677
+ keeps a catch renderer that renders just finally. */
575
678
  function generateStreamingAwait(
576
679
  node: Extract<TemplateNode, { kind: 'await' }>,
577
680
  target: string,
@@ -0,0 +1,13 @@
1
+ import { escapeRegex } from '../../shared/escapeRegex.ts'
2
+
3
+ /*
4
+ A RegExp that matches `name` used as a whole identifier in a body of code.
5
+ Boundaries are `(?<![$\w]) … (?![$\w])`, NOT `\b`: `\b` never fires before a
6
+ `$`-leading identifier (`$` is a non-word char, so there's no word boundary
7
+ before it), so `\b$e\b` silently matches nothing — the exact miss that made an
8
+ aliased reactive import (`import { effect as $e }`) read as dead. The name is
9
+ regex-escaped so a `$` or other metachar in it is literal, not an anchor.
10
+ */
11
+ export function identifierReferencePattern(name: string): RegExp {
12
+ return new RegExp(`(?<![$\\w])${escapeRegex(name)}(?![$\\w])`)
13
+ }
@@ -1,7 +1,7 @@
1
1
  import ts from 'typescript'
2
2
  import { createShadowScope } from './createShadowScope.ts'
3
3
  import { docAccessTransformer } from './lowerDocAccess.ts'
4
- import { nestedBindingNames } from './prepareNestedScript.ts'
4
+ import { nestedBindingNames, nestedPlainLocalNames } from './prepareNestedScript.ts'
5
5
  import { signalRefsTransformer } from './renameSignalRefs.ts'
6
6
  import { TS_PRINTER } from './TS_PRINTER.ts'
7
7
  import type { TemplateNode } from './types/TemplateNode.ts'
@@ -105,12 +105,18 @@ export function lowerContext(
105
105
  }
106
106
 
107
107
  /* Adds any `<script>` children's binding names to the deref scope (so the script
108
- bodies and the branch's markup auto-deref them) for the duration of `body`. */
108
+ bodies and the branch's markup auto-deref them) for the duration of `body`. Reactive
109
+ bindings register as `derived` `.value` cells; the script's PLAIN top-level locals
110
+ register as `plain` bare locals so a local that shares a name with a component signal
111
+ shadows it (else the reference lowers to `$$model.read(...)`, ignoring the local). */
109
112
  function withNestedScripts<T>(children: TemplateNode[], body: () => T): T {
110
- const names = children.flatMap((child) =>
113
+ const derived = children.flatMap((child) =>
111
114
  child.kind === 'script' ? [...nestedBindingNames(child.code)] : [],
112
115
  )
113
- return scope.withShadow(names, 'derived', body)
116
+ const plain = children.flatMap((child) =>
117
+ child.kind === 'script' ? [...nestedPlainLocalNames(child.code)] : [],
118
+ )
119
+ return scope.withShadow(derived, 'derived', () => scope.withShadow(plain, 'plain', body))
114
120
  }
115
121
 
116
122
  return {
@@ -1,11 +1,43 @@
1
1
  import ts from 'typescript'
2
+ import { ABIDE_PACKAGE_NAME } from '../../shared/ABIDE_PACKAGE_NAME.ts'
2
3
  import { assertTranspiles } from './assertTranspiles.ts'
3
4
  import { desugarSignals } from './desugarSignals.ts'
5
+ import { identifierReferencePattern } from './identifierReferencePattern.ts'
4
6
  import { docAccessTransformer } from './lowerDocAccess.ts'
5
7
  import { signalRefsTransformer } from './renameSignalRefs.ts'
6
8
  import { stripEffectsTransformer } from './stripEffects.ts'
7
9
  import { TS_PRINTER } from './TS_PRINTER.ts'
8
10
 
11
+ /* The `abide/ui/*` modules the reactive surface is imported from. An author's import of
12
+ one is compiler-recognised and lowered, so its binding is often fully consumed — a plain
13
+ `state(...)` becomes `$$model`/`$$scope` with no `state` reference left. Such a dead
14
+ import is dropped from the emitted module (see `deadReactiveImport`) so the output has no
15
+ spurious static `@abide/ui` dependency — load-bearing for hot modules, which forbid one. */
16
+ const REACTIVE_IMPORT_SPECIFIERS = new Set([
17
+ `${ABIDE_PACKAGE_NAME}/ui/state`,
18
+ `${ABIDE_PACKAGE_NAME}/ui/effect`,
19
+ ])
20
+
21
+ /* True when `statement` imports the reactive surface and none of its local bindings survive
22
+ into the lowered output (`used` = body + ssr body) — so emitting it would be a dead,
23
+ spurious runtime import. A binding that DOES survive (`state.share`, a bare `effect(...)`)
24
+ keeps the import. */
25
+ function deadReactiveImport(statement: ts.ImportDeclaration, used: string): boolean {
26
+ if (
27
+ !ts.isStringLiteral(statement.moduleSpecifier) ||
28
+ !REACTIVE_IMPORT_SPECIFIERS.has(statement.moduleSpecifier.text)
29
+ ) {
30
+ return false
31
+ }
32
+ const named = statement.importClause?.namedBindings
33
+ if (named === undefined || !ts.isNamedImports(named)) {
34
+ return false
35
+ }
36
+ return named.elements.every(
37
+ (element) => !identifierReferencePattern(element.name.text).test(used),
38
+ )
39
+ }
40
+
9
41
  /*
10
42
  The component-script lowering, done in ONE parse. The script is parsed once, then
11
43
  `desugarSignals` (signal declarations → `model` slots / `scope().derive`), reference
@@ -21,13 +53,22 @@ position corrupts the script (the failure mode the syntax fuzz corpus also guard
21
53
  surfaces here as a located compile error instead of shipping a broken bundle.
22
54
  */
23
55
 
24
- export function lowerScript(scriptBody: string): {
56
+ export function lowerScript(
57
+ scriptBody: string,
58
+ /* Reactive-surface identifiers referenced OUTSIDE this script — the nested branch
59
+ `<script>`s, which keep their `state.computed(...)` calls literal and so still need
60
+ the module-level import even after the leading script's own `state(...)` all
61
+ desugared away. Folded into the dead-import usage check so a live import isn't dropped
62
+ (→ `ReferenceError: state is not defined` in the branch). Empty for a nested script. */
63
+ externalUsage = '',
64
+ ): {
25
65
  body: string
26
66
  imports: string
27
67
  ssrBody: string
28
68
  stateNames: Set<string>
29
69
  derivedNames: Set<string>
30
70
  computedNames: Set<string>
71
+ droppedReactiveImports: Set<string>
31
72
  } {
32
73
  const source = ts.createSourceFile('component.ts', scriptBody, ts.ScriptTarget.Latest, true)
33
74
  const { transformer, stateNames, derivedNames, computedNames } = desugarSignals(source)
@@ -43,9 +84,6 @@ export function lowerScript(scriptBody: string): {
43
84
  const bodyStatements = transformed.statements.filter(
44
85
  (statement) => !ts.isImportDeclaration(statement),
45
86
  )
46
- const imports = importStatements
47
- .map((statement) => TS_PRINTER.printNode(ts.EmitHint.Unspecified, statement, transformed))
48
- .join('\n')
49
87
  const bodyFile = ts.factory.updateSourceFile(transformed, bodyStatements)
50
88
  const body = TS_PRINTER.printFile(bodyFile).trim()
51
89
  /* The SSR variant strips client-only `effect(...)` calls — run over the SAME lowered
@@ -55,10 +93,39 @@ export function lowerScript(scriptBody: string): {
55
93
  const ssrBody = TS_PRINTER.printFile(ssrResult.transformed[0] as ts.SourceFile).trim()
56
94
  ssrResult.dispose()
57
95
  result.dispose()
96
+ /* Drop reactive-surface imports fully consumed by lowering — keeping them would leave a
97
+ dead, spurious `@abide/ui` runtime dependency (checked against both back-ends' output). */
98
+ const used = `${body}\n${ssrBody}\n${externalUsage}`
99
+ const droppedReactiveImports = new Set<string>()
100
+ const imports = importStatements
101
+ .filter((statement) => {
102
+ if (!deadReactiveImport(statement, used)) {
103
+ return true
104
+ }
105
+ /* Record the local names of a dropped reactive import so the module wrapper can
106
+ independently confirm the drop stranded no live reference. */
107
+ const named = statement.importClause?.namedBindings
108
+ if (named !== undefined && ts.isNamedImports(named)) {
109
+ for (const element of named.elements) {
110
+ droppedReactiveImports.add(element.name.text)
111
+ }
112
+ }
113
+ return false
114
+ })
115
+ .map((statement) => TS_PRINTER.printNode(ts.EmitHint.Unspecified, statement, transformed))
116
+ .join('\n')
58
117
 
59
118
  assertTranspiles(
60
119
  [imports, body].filter((part) => part !== '').join('\n'),
61
120
  'component script lowering',
62
121
  )
63
- return { body, imports, ssrBody, stateNames, derivedNames, computedNames }
122
+ return {
123
+ body,
124
+ imports,
125
+ ssrBody,
126
+ stateNames,
127
+ derivedNames,
128
+ computedNames,
129
+ droppedReactiveImports,
130
+ }
64
131
  }
@@ -640,25 +640,12 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
640
640
  its children become slot content (rendered where the child puts <slot>). */
641
641
  if (UPPERCASE_START.test(tag)) {
642
642
  const slotted = selfClosing ? [] : readChildren(tag)
643
- /* `client:idle` / `client:visible` are island directives (bare) — pull them out of
644
- the attributes so they never round-trip as props; only the build back-end reads
645
- the trigger. Any other `client:*` falls through as a prop (a prop-check error). */
646
- const clientAttr = attrs.find(
647
- (attr) =>
648
- attr.kind === 'static' &&
649
- (attr.name === 'client:idle' || attr.name === 'client:visible'),
650
- )
651
- const clientTrigger =
652
- clientAttr?.kind === 'static'
653
- ? (clientAttr.name.slice('client:'.length) as 'idle' | 'visible')
654
- : undefined
655
643
  return {
656
644
  kind: 'component',
657
645
  name: tag,
658
646
  loc: baseOffset + tagStart,
659
- props: toProps(attrs.filter((attr) => attr !== clientAttr)),
647
+ props: toProps(attrs),
660
648
  children: slotted,
661
- clientTrigger,
662
649
  }
663
650
  }
664
651
  /* `{...expr}` spreads onto a component (its props) or a native element (its
@@ -1,5 +1,6 @@
1
1
  import ts from 'typescript'
2
- import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
2
+ import { NESTED_REACTIVE_BINDINGS } from './resolveReactiveExport.ts'
3
+ import { signalCallee } from './signalCallee.ts'
3
4
 
4
5
  /*
5
6
  The signal binding names a `<script>` nested in a control-flow branch declares
@@ -17,10 +18,14 @@ export function nestedBindingNames(code: string): Set<string> {
17
18
  continue
18
19
  }
19
20
  for (const declaration of statement.declarationList.declarations) {
20
- const callee = signalCallee(declaration)
21
+ const callee = signalCallee(declaration, NESTED_REACTIVE_BINDINGS)
22
+ /* `effect`/`watch` bind no readable value (they return a disposer, not a cell), so
23
+ they never join the deref scope; every other recognised primitive
24
+ (state/linked/computed/props) does. */
21
25
  if (
22
26
  callee !== undefined &&
23
- REACTIVE_CALLEES.has(callee) &&
27
+ callee !== 'effect' &&
28
+ callee !== 'watch' &&
24
29
  ts.isIdentifier(declaration.name)
25
30
  ) {
26
31
  names.add(declaration.name.text)
@@ -30,19 +35,28 @@ export function nestedBindingNames(code: string): Set<string> {
30
35
  return names
31
36
  }
32
37
 
33
- /* The callee name of a `NAME = state(...)` / `computed(...)` / `linked(...)` declaration —
34
- bare or the explicit scope form (`scope().state(...)` / `c.state(...)`), receiver-agnostic. */
35
- function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
36
- const initializer = declaration.initializer
37
- if (initializer === undefined || !ts.isCallExpression(initializer)) {
38
- return undefined
39
- }
40
- const callee = initializer.expression
41
- if (ts.isIdentifier(callee)) {
42
- return callee.text
43
- }
44
- if (ts.isPropertyAccessExpression(callee)) {
45
- return callee.name.text
38
+ /* The PLAIN (non-reactive) local names a nested branch `<script>` declares at its top
39
+ level a `const title = deriveLocal()` that is neither a signal nor an effect/watch.
40
+ These must shadow a same-named component signal so a later reference in the script or
41
+ branch reads the nearer local, not `$$model.read("title")`; registered as `plain`
42
+ shadows (bare locals), the counterpart to `nestedBindingNames`' `derived` cells. */
43
+ export function nestedPlainLocalNames(code: string): Set<string> {
44
+ const source = ts.createSourceFile('nested.ts', code, ts.ScriptTarget.Latest, true)
45
+ const names = new Set<string>()
46
+ for (const statement of source.statements) {
47
+ if (!ts.isVariableStatement(statement)) {
48
+ continue
49
+ }
50
+ for (const declaration of statement.declarationList.declarations) {
51
+ /* Reactive declarations are the `derived` cells `nestedBindingNames` owns; a
52
+ plain identifier declaration with a non-reactive callee (or no call) is a
53
+ local. Skip destructuring patterns — the reactive path does too. */
54
+ const callee = signalCallee(declaration, NESTED_REACTIVE_BINDINGS)
55
+ const isReactive = callee !== undefined && callee !== 'effect' && callee !== 'watch'
56
+ if (!isReactive && ts.isIdentifier(declaration.name)) {
57
+ names.add(declaration.name.text)
58
+ }
59
+ }
46
60
  }
47
- return undefined
61
+ return names
48
62
  }
@@ -0,0 +1,95 @@
1
+ import ts from 'typescript'
2
+ import { ABIDE_PACKAGE_NAME } from '../../shared/ABIDE_PACKAGE_NAME.ts'
3
+
4
+ /* The reactive primitive an import binding resolves to — the single recognition
5
+ vocabulary the desugarer, the nested-script scoper, and the shadow consume.
6
+ `linked`/`computed` are reached as members of a `state` import (`state.linked`),
7
+ so they are resolution results but carry no import specifier of their own. */
8
+ export type ReactivePrimitive = 'state' | 'linked' | 'computed' | 'effect' | 'watch' | 'props'
9
+
10
+ /* The `abide/ui/*` specifier each importable reactive primitive is published at,
11
+ mapped to its canonical name. Built from the package name so a rename is one edit.
12
+ `linked`/`computed` are members of `state`, not standalone imports; `props` is
13
+ ambient sugar today (no module) — kept here so a future `abide/ui/props` resolves
14
+ with no code change. */
15
+ const REACTIVE_SPECIFIERS: Record<string, ReactivePrimitive> = {
16
+ [`${ABIDE_PACKAGE_NAME}/ui/state`]: 'state',
17
+ [`${ABIDE_PACKAGE_NAME}/ui/effect`]: 'effect',
18
+ [`${ABIDE_PACKAGE_NAME}/ui/watch`]: 'watch',
19
+ [`${ABIDE_PACKAGE_NAME}/ui/props`]: 'props',
20
+ }
21
+
22
+ /* The reactive import bindings a source file declares: each local binding (alias-safe)
23
+ mapped to its canonical primitive, plus the set of locals bound to `state` (so a
24
+ `state.linked` / `state.computed` member call resolves off the right root). Scans the
25
+ file's `import` statements once — no checker, no `ts.Program`: recognition is purely
26
+ syntactic, correct because abide has no barrels (a primitive is imported directly from
27
+ its `abide/ui/*` module, never a re-export). */
28
+ export type ReactiveImportBindings = {
29
+ direct: Map<string, ReactivePrimitive>
30
+ stateRoots: Set<string>
31
+ }
32
+
33
+ /* The reactive bindings a nested `<template>` <script> resolves against. A nested script
34
+ cannot carry its own import (imports are module-scoped, hoisted off the leading script or
35
+ absent entirely), so it inherits the surface by the canonical names — the one recognition
36
+ site that is name-based because imports structurally cannot reach it. */
37
+ export const NESTED_REACTIVE_BINDINGS: ReactiveImportBindings = {
38
+ direct: new Map<string, ReactivePrimitive>([
39
+ ['state', 'state'],
40
+ ['effect', 'effect'],
41
+ ['watch', 'watch'],
42
+ ]),
43
+ stateRoots: new Set(['state']),
44
+ }
45
+
46
+ export function reactiveImportBindings(source: ts.SourceFile): ReactiveImportBindings {
47
+ const direct = new Map<string, ReactivePrimitive>()
48
+ const stateRoots = new Set<string>()
49
+ for (const statement of source.statements) {
50
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) {
51
+ continue
52
+ }
53
+ const canonical = REACTIVE_SPECIFIERS[statement.moduleSpecifier.text]
54
+ const named = statement.importClause?.namedBindings
55
+ if (canonical === undefined || named === undefined || !ts.isNamedImports(named)) {
56
+ continue
57
+ }
58
+ for (const element of named.elements) {
59
+ /* The imported name (`propertyName` when aliased, else `name`) must be the
60
+ module's canonical export; `name` is the local binding codegen resolves. */
61
+ if ((element.propertyName ?? element.name).text !== canonical) {
62
+ continue
63
+ }
64
+ direct.set(element.name.text, canonical)
65
+ if (canonical === 'state') {
66
+ stateRoots.add(element.name.text)
67
+ }
68
+ }
69
+ }
70
+ return { direct, stateRoots }
71
+ }
72
+
73
+ /* The reactive primitive a call's callee resolves to, or undefined. A bare `props`
74
+ identifier is always the ambient prop reader — the one primitive with no runtime module
75
+ (pure compiler sugar), so it resolves with no import. Every other bare identifier
76
+ resolves through the direct import bindings (alias-safe); a `stateRoot.linked` /
77
+ `.computed` member call resolves off a local bound to `state`. Every other callee is
78
+ undefined (a user's own function, an unrelated member access). */
79
+ export function resolveReactiveExport(
80
+ callee: ts.Expression,
81
+ bindings: ReactiveImportBindings,
82
+ ): ReactivePrimitive | undefined {
83
+ if (ts.isIdentifier(callee)) {
84
+ return callee.text === 'props' ? 'props' : bindings.direct.get(callee.text)
85
+ }
86
+ if (
87
+ ts.isPropertyAccessExpression(callee) &&
88
+ ts.isIdentifier(callee.expression) &&
89
+ bindings.stateRoots.has(callee.expression.text) &&
90
+ (callee.name.text === 'linked' || callee.name.text === 'computed')
91
+ ) {
92
+ return callee.name.text
93
+ }
94
+ return undefined
95
+ }
@@ -0,0 +1,24 @@
1
+ import ts from 'typescript'
2
+ import {
3
+ type ReactiveImportBindings,
4
+ type ReactivePrimitive,
5
+ resolveReactiveExport,
6
+ } from './resolveReactiveExport.ts'
7
+
8
+ /*
9
+ The canonical reactive primitive a `NAME = state(...)` declaration's callee resolves to,
10
+ else undefined. Import-resolution is the SOLE recognition path (alias-safe: an
11
+ `import { state as s }` still resolves `s(...)` to `state`); the legacy member forms are
12
+ not recognised. Shared by the desugar, shadow type-check, and nested-script passes so all
13
+ three agree on what is reactive — a divergence here silently mis-classifies a declaration.
14
+ */
15
+ export function signalCallee(
16
+ declaration: ts.VariableDeclaration,
17
+ bindings: ReactiveImportBindings,
18
+ ): ReactivePrimitive | undefined {
19
+ const initializer = declaration.initializer
20
+ if (initializer === undefined || !ts.isCallExpression(initializer)) {
21
+ return undefined
22
+ }
23
+ return resolveReactiveExport(initializer.expression, bindings)
24
+ }
@@ -2,14 +2,16 @@ import ts from 'typescript'
2
2
  import { TS_PRINTER } from './TS_PRINTER.ts'
3
3
 
4
4
  /*
5
- Removes effect calls from a script for the SSR back-end. Effects are client
6
- lifecycle — they touch the DOM / run side effects and emit no HTML, so the server
5
+ Removes effect/watch reaction calls from a script for the SSR back-end. Reactions are
6
+ client lifecycle — they touch the DOM / run side effects and emit no HTML, so the server
7
7
  render (a snapshot of the pre-effect markup, like every framework) must not run
8
- them. Both surfaces are stripped: the generated/runtime bare `effect(<args>)` and
9
- the authored scope form `scope().effect(<args>)` (and a captured/destructured handle
10
- `c.effect(<args>)`), each replaced by `undefined` an `effect(() => …)` statement
11
- becomes a no-op, and a `const stop = effect(…)` binding keeps a defined (unused) name.
12
- Client compilation keeps effects untouched.
8
+ them. Stripped: the bare `effect(<args>)` and its authored scope form
9
+ `scope().effect(<args>)` / `c.effect(<args>)`, and the bare reaction `watch(<args>)`
10
+ (its replacement) each replaced by `undefined`, so an `effect(() => …)` /
11
+ `watch(src, …)` statement becomes a no-op and a `const stop = watch(…)` binding keeps a
12
+ defined (unused) name. `watch` matches bare only (never a member — unlike the legacy
13
+ `.effect` scope method — so an unrelated `x.watch(...)` is untouched). Client
14
+ compilation keeps reactions untouched.
13
15
  */
14
16
  export function stripEffects(code: string): string {
15
17
  const source = ts.createSourceFile('script.ts', code, ts.ScriptTarget.Latest, true)
@@ -34,12 +36,13 @@ export function stripEffectsTransformer(): ts.TransformerFactory<ts.SourceFile>
34
36
  }
35
37
  }
36
38
 
37
- /* An effect callee: the bare runtime helper (`effect`) or the scope-method form
38
- (`scope().effect`, `c.effect`) the author writes. Receiver-agnostic on the `.effect`
39
- member name, matching `effect` being reserved as a scope primitive. */
39
+ /* A reaction callee to strip: the bare runtime helpers (`effect` / `watch`) or the
40
+ scope-method form (`scope().effect`, `c.effect`). Receiver-agnostic on the `.effect`
41
+ member name (effect was a scope primitive); `watch` matches bare only, so an unrelated
42
+ `x.watch(...)` member call is left intact. */
40
43
  function isEffectCallee(expression: ts.Expression): boolean {
41
44
  if (ts.isIdentifier(expression)) {
42
- return expression.text === 'effect'
45
+ return expression.text === 'effect' || expression.text === 'watch'
43
46
  }
44
47
  return ts.isPropertyAccessExpression(expression) && expression.name.text === 'effect'
45
48
  }
@@ -16,6 +16,11 @@ export type AnalyzedComponent = {
16
16
  /* Top-level import statements hoisted out of the script (e.g. child
17
17
  components), placed at module scope by the module wrapper. */
18
18
  imports: string
19
+ /* The local binding names of reactive-surface imports (`state`/`effect`) the dead-import
20
+ filter dropped as unused. The module wrapper's backstop asserts none of them still
21
+ appears in the generated output — an independent check that the drop didn't strand a
22
+ live reference (e.g. one a nested branch script keeps literal). */
23
+ droppedReactiveImports: Set<string>
19
24
  stateNames: Set<string>
20
25
  derivedNames: Set<string>
21
26
  computedNames: Set<string>