@abide/abide 0.43.0 → 0.44.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 (160) hide show
  1. package/AGENTS.md +293 -219
  2. package/CHANGELOG.md +46 -0
  3. package/README.md +191 -67
  4. package/package.json +2 -2
  5. package/src/abideLsp.ts +8 -4
  6. package/src/abideResolverPlugin.ts +11 -1
  7. package/src/build.ts +9 -0
  8. package/src/buildDisconnected.ts +2 -0
  9. package/src/devEntry.ts +1 -0
  10. package/src/lib/bundle/probeAbideServer.ts +1 -1
  11. package/src/lib/cli/parseArgvForRpc.ts +4 -2
  12. package/src/lib/cli/types/CliManifest.ts +6 -4
  13. package/src/lib/mcp/annotationsForMethod.ts +1 -1
  14. package/src/lib/mcp/toolResultFromResponse.ts +1 -1
  15. package/src/lib/mcp/types/JsonRpcRequest.ts +3 -3
  16. package/src/lib/server/AppModule.ts +1 -1
  17. package/src/lib/server/cli/handleCliDownload.ts +7 -5
  18. package/src/lib/server/cli/installScript.ts +3 -3
  19. package/src/lib/server/prompts/promptRegistry.ts +1 -1
  20. package/src/lib/server/prompts/types/Prompt.ts +2 -1
  21. package/src/lib/server/rpc/dispatchRpcInProcess.ts +2 -2
  22. package/src/lib/server/rpc/types/RpcHelper.ts +1 -1
  23. package/src/lib/server/runtime/createPublicAssetServer.ts +2 -2
  24. package/src/lib/server/runtime/createRouteDispatcher.ts +1 -1
  25. package/src/lib/server/runtime/createServer.ts +21 -0
  26. package/src/lib/server/runtime/createUiPageRenderer.ts +7 -11
  27. package/src/lib/server/runtime/crossOriginForbidden.ts +2 -2
  28. package/src/lib/server/runtime/crossOriginGate.ts +1 -1
  29. package/src/lib/server/runtime/devHotModuleResponse.ts +2 -2
  30. package/src/lib/server/runtime/disableIdleTimeoutForStream.ts +1 -1
  31. package/src/lib/server/runtime/logExposedSurfaces.ts +3 -4
  32. package/src/lib/server/runtime/serializeCacheSnapshot.ts +3 -8
  33. package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -7
  34. package/src/lib/server/runtime/streamFromIterator.ts +2 -2
  35. package/src/lib/server/runtime/types/InspectorRpc.ts +2 -2
  36. package/src/lib/server/socket.ts +1 -1
  37. package/src/lib/server/sockets/createSocketDispatcher.ts +2 -2
  38. package/src/lib/server/sockets/socketRegistry.ts +2 -2
  39. package/src/lib/server/sockets/types/SocketClientFrame.ts +3 -3
  40. package/src/lib/shared/CACHE_WRAPPED.ts +3 -2
  41. package/src/lib/shared/REMOTE_FUNCTION.ts +1 -1
  42. package/src/lib/shared/STREAMING_CONTENT_TYPES.ts +3 -2
  43. package/src/lib/shared/bodyValueForKind.ts +35 -0
  44. package/src/lib/shared/cache.ts +1 -1
  45. package/src/lib/shared/cacheEntryFromSnapshot.ts +23 -18
  46. package/src/lib/shared/createPushIterator.ts +1 -1
  47. package/src/lib/shared/decodeResponse.ts +11 -8
  48. package/src/lib/shared/hasReplayableRequest.ts +17 -0
  49. package/src/lib/shared/html.ts +7 -26
  50. package/src/lib/shared/manifestModule.ts +2 -2
  51. package/src/lib/shared/online.ts +1 -1
  52. package/src/lib/shared/outboxProbeSlot.ts +1 -1
  53. package/src/lib/shared/pageUrlForFile.ts +1 -1
  54. package/src/lib/shared/routeParamsShape.ts +20 -0
  55. package/src/lib/shared/snapshotShippable.ts +22 -0
  56. package/src/lib/shared/types/CacheEntry.ts +1 -1
  57. package/src/lib/shared/types/CacheInvalidation.ts +3 -3
  58. package/src/lib/shared/types/ClientFlags.ts +5 -3
  59. package/src/lib/shared/types/LastConnection.ts +1 -1
  60. package/src/lib/shared/url.ts +4 -1
  61. package/src/lib/shared/withBase.ts +1 -2
  62. package/src/lib/shared/writeDts.ts +2 -2
  63. package/src/lib/shared/writeRoutesDts.ts +3 -31
  64. package/src/lib/test/createTestSocketChannel.ts +3 -3
  65. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +1 -1
  66. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +40 -6
  67. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +1 -1
  68. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +49 -37
  69. package/src/lib/ui/compile/abideUiPlugin.ts +1 -1
  70. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +3 -1
  71. package/src/lib/ui/compile/awaitPlan.ts +14 -2
  72. package/src/lib/ui/compile/catchBinding.ts +8 -0
  73. package/src/lib/ui/compile/classStyleMergePlan.ts +108 -0
  74. package/src/lib/ui/compile/collectAbideDiagnostics.ts +11 -0
  75. package/src/lib/ui/compile/compileComponent.ts +4 -4
  76. package/src/lib/ui/compile/compileModule.ts +20 -6
  77. package/src/lib/ui/compile/compileSSR.ts +12 -10
  78. package/src/lib/ui/compile/compileShadow.ts +107 -16
  79. package/src/lib/ui/compile/composeProps.ts +3 -3
  80. package/src/lib/ui/compile/createShadowLanguageService.ts +30 -2
  81. package/src/lib/ui/compile/createShadowProgram.ts +2 -1
  82. package/src/lib/ui/compile/createShadowScope.ts +42 -0
  83. package/src/lib/ui/compile/decodeHtmlEntities.ts +4 -0
  84. package/src/lib/ui/compile/desugarSignals.ts +12 -7
  85. package/src/lib/ui/compile/eachPlan.ts +61 -0
  86. package/src/lib/ui/compile/elementPlan.ts +55 -0
  87. package/src/lib/ui/compile/encodeSemanticTokens.ts +24 -1
  88. package/src/lib/ui/compile/generateBuild.ts +150 -127
  89. package/src/lib/ui/compile/generateSSR.ts +112 -73
  90. package/src/lib/ui/compile/ifPlan.ts +7 -1
  91. package/src/lib/ui/compile/interpolatedTemplateLiteral.ts +25 -0
  92. package/src/lib/ui/compile/isPlainIdentifier.ts +7 -0
  93. package/src/lib/ui/compile/isTextLeaf.ts +8 -7
  94. package/src/lib/ui/compile/lowerContext.ts +36 -46
  95. package/src/lib/ui/compile/lowerDocAccess.ts +2 -2
  96. package/src/lib/ui/compile/lowerScript.ts +1 -1
  97. package/src/lib/ui/compile/markupTokens.ts +313 -0
  98. package/src/lib/ui/compile/pagePropsType.ts +23 -0
  99. package/src/lib/ui/compile/parseTemplate.ts +131 -61
  100. package/src/lib/ui/compile/reactiveBinding.ts +47 -0
  101. package/src/lib/ui/compile/renameSignalRefs.ts +44 -10
  102. package/src/lib/ui/compile/skeletonable.ts +5 -5
  103. package/src/lib/ui/compile/snippetPlan.ts +28 -0
  104. package/src/lib/ui/compile/spreadExcludedNames.ts +4 -4
  105. package/src/lib/ui/compile/structuralBlockTokens.ts +3 -4
  106. package/src/lib/ui/compile/switchPlan.ts +7 -1
  107. package/src/lib/ui/compile/tryPlan.ts +8 -1
  108. package/src/lib/ui/compile/types/AnalyzedComponent.ts +2 -2
  109. package/src/lib/ui/compile/types/Binding.ts +24 -0
  110. package/src/lib/ui/compile/types/CompiledShadow.ts +13 -1
  111. package/src/lib/ui/compile/types/ElementPlan.ts +43 -0
  112. package/src/lib/ui/compile/types/ShadowKind.ts +9 -0
  113. package/src/lib/ui/compile/types/ShadowScope.ts +19 -0
  114. package/src/lib/ui/compile/types/TemplateAttr.ts +7 -0
  115. package/src/lib/ui/compile/types/TemplateNode.ts +8 -8
  116. package/src/lib/ui/compile/withBindings.ts +45 -0
  117. package/src/lib/ui/createScope.ts +2 -2
  118. package/src/lib/ui/dom/MATHML_NAMESPACE.ts +2 -2
  119. package/src/lib/ui/dom/SVG_NAMESPACE.ts +3 -2
  120. package/src/lib/ui/dom/appendText.ts +1 -1
  121. package/src/lib/ui/dom/applyResolved.ts +1 -1
  122. package/src/lib/ui/dom/buildDetachedRange.ts +4 -3
  123. package/src/lib/ui/dom/cloneStatic.ts +3 -2
  124. package/src/lib/ui/dom/each.ts +26 -7
  125. package/src/lib/ui/dom/foreignWrapperTag.ts +2 -2
  126. package/src/lib/ui/dom/mountChild.ts +8 -1
  127. package/src/lib/ui/dom/readCall.ts +6 -4
  128. package/src/lib/ui/dom/scopeLabel.ts +3 -3
  129. package/src/lib/ui/dom/switchBlock.ts +3 -1
  130. package/src/lib/ui/dom/types/EachRow.ts +4 -0
  131. package/src/lib/ui/dom/types/SkeletonHoles.ts +4 -2
  132. package/src/lib/ui/history.ts +1 -2
  133. package/src/lib/ui/html.ts +28 -0
  134. package/src/lib/ui/installHotBridge.ts +0 -2
  135. package/src/lib/ui/renderChain.ts +16 -3
  136. package/src/lib/ui/router.ts +66 -15
  137. package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +1 -1
  138. package/src/lib/ui/runtime/CHILD_PRESENT.ts +8 -0
  139. package/src/lib/ui/runtime/OUTLET_MARKER.ts +2 -2
  140. package/src/lib/ui/runtime/RANGE_MARKER.ts +1 -1
  141. package/src/lib/ui/runtime/REACTIVE_CONTEXT.ts +9 -3
  142. package/src/lib/ui/runtime/captureModelDoc.ts +1 -1
  143. package/src/lib/ui/runtime/createDoc.ts +5 -1
  144. package/src/lib/ui/runtime/createEffectNode.ts +5 -5
  145. package/src/lib/ui/runtime/flushEffects.ts +22 -9
  146. package/src/lib/ui/runtime/historyEntries.ts +1 -1
  147. package/src/lib/ui/runtime/track.ts +4 -2
  148. package/src/lib/ui/runtime/trigger.ts +34 -20
  149. package/src/lib/ui/runtime/types/Route.ts +2 -2
  150. package/src/lib/ui/runtime/types/UiComponent.ts +3 -2
  151. package/src/lib/ui/runtime/types/UiProps.ts +9 -12
  152. package/src/lib/ui/runtime/walkPath.ts +15 -3
  153. package/src/lib/ui/startClient.ts +41 -14
  154. package/src/lib/ui/state.ts +3 -3
  155. package/src/lib/ui/types/Scope.ts +3 -3
  156. package/src/serverBuildPlugins.ts +11 -5
  157. package/src/zodCjsPlugin.ts +36 -0
  158. package/template/.zed/settings.json +7 -1
  159. package/template/package.json +3 -0
  160. package/template/src/ui/pages/layout.abide +3 -5
@@ -10,22 +10,27 @@ import {
10
10
  import { asOutlet } from './asOutlet.ts'
11
11
  import { awaitPlan } from './awaitPlan.ts'
12
12
  import { composeProps } from './composeProps.ts'
13
+ import { eachPlan } from './eachPlan.ts'
14
+ import { elementPlan } from './elementPlan.ts'
13
15
  import { groupBindParts } from './groupBindParts.ts'
14
16
  import { ifPlan } from './ifPlan.ts'
17
+ import { interpolatedTemplateLiteral } from './interpolatedTemplateLiteral.ts'
15
18
  import { isAnchorPositioned } from './isAnchorPositioned.ts'
16
19
  import { lowerContext } from './lowerContext.ts'
17
20
  import { makeVarNamer } from './makeVarNamer.ts'
18
21
  import { scopeAttr } from './scopeAttr.ts'
19
22
  import { skeletonContext } from './skeletonContext.ts'
23
+ import { snippetPlan } from './snippetPlan.ts'
20
24
  import { spreadExcludedNames } from './spreadExcludedNames.ts'
21
25
  import { staticAttr } from './staticAttr.ts'
22
26
  import { staticTextPart } from './staticTextPart.ts'
23
27
  import { stripEffects } from './stripEffects.ts'
24
28
  import { switchPlan } from './switchPlan.ts'
25
29
  import { tryPlan } from './tryPlan.ts'
26
- import type { TemplateAttr } from './types/TemplateAttr.ts'
30
+ import type { Binding } from './types/Binding.ts'
31
+ import type { ShadowKind } from './types/ShadowKind.ts'
27
32
  import type { TemplateNode } from './types/TemplateNode.ts'
28
- import { VOID_TAGS } from './VOID_TAGS.ts'
33
+ import { withBindings } from './withBindings.ts'
29
34
 
30
35
  /* The range boundary comments a control-flow block emits around its content. Sourced
31
36
  from the SAME wire-alphabet constants the client's `document.createComment` markers
@@ -70,9 +75,15 @@ export function generateSSR(
70
75
  expression: lowerExpression,
71
76
  statement,
72
77
  withNestedScripts,
78
+ withShadow,
73
79
  bindRead,
74
80
  } = lowerContext(stateNames, derivedNames, computedNames)
75
81
 
82
+ /* SSR has no cells, so every plan `Binding` — `reactive` or `plain` — renders as a `plain`
83
+ shadow (a real JS local / loop var / arrow param, read as the bare identifier). The one
84
+ mapping `withBindings` registers every binding through on this back-end. */
85
+ const ssrBindingKind = (_binding: Binding): ShadowKind => 'plain'
86
+
76
87
  /* A scoped-script body for SSR: the shared lowering, then strip effects
77
88
  (client-only lifecycle that emits no HTML) — the one SSR-side asymmetry. */
78
89
  const lowerScript = (code: string): string => stripEffects(statement(code))
@@ -248,14 +259,19 @@ export function generateSSR(
248
259
  return ''
249
260
  }
250
261
  if (node.kind === 'snippet') {
262
+ const plan = snippetPlan(node)
251
263
  /* A hoisted function returning the snippet's `$snip`-branded HTML string;
252
- `{name(args)}` pushes it via `$text`, which wraps it in markers. */
253
- const body = generateInto(node.children, '$o')
264
+ `{name(args)}` pushes it via `$text`, which wraps it in markers. `args` are plain
265
+ call parameters `withBindings` registers the plan's `plain` bindings so the body
266
+ reads the bare local, shadowing a same-named component signal rather than reading it. */
267
+ const body = withBindings(withShadow, plan.bindings, ssrBindingKind, () =>
268
+ generateInto(plan.children, '$o'),
269
+ )
254
270
  /* `async` only when the body produces an `await` (it inlines a child component) — then
255
271
  call sites `await` it (`$text(await frag())`). A sync snippet stays a plain function
256
272
  called inline, preserving the sync render contract. */
257
- const keyword = asyncSnippets.has(node.name) ? 'async function' : 'function'
258
- return `${keyword} ${node.name}(${node.params ?? ''}) {\nconst $o = [];\n${body}return $snip($o.join(''));\n}\n`
273
+ const keyword = asyncSnippets.has(plan.name) ? 'async function' : 'function'
274
+ return `${keyword} ${plan.name}(${plan.params ?? ''}) {\nconst $o = [];\n${body}return $snip($o.join(''));\n}\n`
259
275
  }
260
276
  if (node.kind === 'script') {
261
277
  /* A scoped reactive block: re-seed its local signals (lowered, in scope)
@@ -268,22 +284,35 @@ export function generateSSR(
268
284
  return ''
269
285
  }
270
286
  if (node.kind === 'each') {
287
+ const plan = eachPlan(node)
271
288
  /* Async each (`await`) is drained on the client — render no rows on the
272
289
  server (an infinite stream would hang SSR); the client inserts its anchor
273
290
  before the next sibling during hydration, like an empty sync each. In a
274
291
  skeleton the `<!--a-->` anchor still marks its position (the client mounts
275
292
  there); no range markers, since there are no server rows to claim. */
276
- if (node.async) {
293
+ if (plan.async) {
277
294
  return anchor
278
295
  }
279
- const rowBody = `${openRange(target)}${branchContent(node.children, target)}${closeRange(target)}`
296
+ /* The row item (and index) are real `for`-loop locals, so the body must lower
297
+ references to them as the bare identifier — `withBindings` registers the plan's
298
+ row bindings (under `plain`, SSR's only kind) so a row binding that shadows a
299
+ same-named component signal reads the loop value, not the (whole-list) signal it
300
+ shadows. The names come straight from `plan.bindings` (the single source the
301
+ client also reads); the items expression stays outside the shadow. */
302
+ const rowBody = withBindings(
303
+ withShadow,
304
+ plan.bindings,
305
+ ssrBindingKind,
306
+ () =>
307
+ `${openRange(target)}${branchContent(plan.children, target)}${closeRange(target)}`,
308
+ )
280
309
  /* `index="i"` binds the row position. SSR reads it as a plain number from
281
310
  `entries()` over a materialized array; the client reads the same number from a
282
311
  cell, so first paint is congruent. No index → a plain `for…of` over the items. */
283
312
  const header =
284
- node.index === undefined
285
- ? `for (const ${node.as} of (${lowerExpression(node.items)}))`
286
- : `for (const [${node.index}, ${node.as}] of [...(${lowerExpression(node.items)})].entries())`
313
+ plan.index === undefined
314
+ ? `for (const ${plan.as} of (${lowerExpression(plan.items)}))`
315
+ : `for (const [${plan.index}, ${plan.as}] of [...(${lowerExpression(plan.items)})].entries())`
287
316
  return `${anchor}${header} {\n${rowBody}}\n`
288
317
  }
289
318
  if (node.kind === 'await') {
@@ -369,30 +398,33 @@ export function generateSSR(
369
398
  for (const scope of node.scopes ?? []) {
370
399
  code += push(target, scopeAttr(scope))
371
400
  }
372
- /* `class:`/`style:` directives collapse with any static `class`/`style` into a SINGLE
373
- merged attribute (a duplicate attribute would be invalid and ignored). The client's
374
- classList.toggle / style.setProperty effects re-apply the same values on hydrate, so
375
- the merged SSR value already matches the post-mount DOM no flash, no desync. */
376
- const classDirectives = node.attrs.filter(
377
- (attr): attr is Extract<TemplateAttr, { kind: 'class' }> => attr.kind === 'class',
378
- )
379
- const styleDirectives = node.attrs.filter(
380
- (attr): attr is Extract<TemplateAttr, { kind: 'style' }> => attr.kind === 'style',
381
- )
382
- for (const attr of node.attrs) {
401
+ /* The shared per-element emission DECISION (one site, consulted by the build back-end
402
+ too): each attribute classified + tagged with its merge status, the class/style/
403
+ directive merge folded in, and void-tag status. `class:`/`style:` directives collapse
404
+ with any static/interpolated `class`/`style` base into a SINGLE merged attribute (a
405
+ duplicate attribute would be invalid and ignored). SSR must always emit one attribute
406
+ string, so it merges whenever a directive exists (`mergeClass`/`mergeStyle`); those
407
+ attrs are tagged `mergedSSR` and skipped below. The client's classList.toggle /
408
+ style.setProperty effects re-apply the same values on hydrate, so the merged SSR value
409
+ already matches the post-mount DOM — no flash, no desync. SSR RENDERS each kind below
410
+ as an escaped string. */
411
+ const plan = elementPlan(node, lowerExpression)
412
+ const merge = plan.merge
413
+ for (const { attr, mergedSSR } of plan.attrs) {
414
+ /* Skip attrs folded into the merged class/style string below. */
415
+ if (mergedSSR) {
416
+ continue
417
+ }
383
418
  if (attr.kind === 'static') {
384
- /* A static class/style is folded into the merge below when directives exist. */
385
- if (
386
- (attr.name === 'class' && classDirectives.length > 0) ||
387
- (attr.name === 'style' && styleDirectives.length > 0)
388
- ) {
389
- continue
390
- }
391
419
  code += push(target, staticAttr(attr.name, attr.value))
392
420
  } else if (attr.kind === 'expression') {
393
421
  /* present/absent semantics matching the client `attr` binding:
394
422
  false/null/undefined drops it, true emits the bare attribute. */
395
423
  code += `${target}.push($attr(${JSON.stringify(attr.name)}, ${lowerExpression(attr.code)}));\n`
424
+ } else if (attr.kind === 'interpolated') {
425
+ /* A string-valued attribute, always present (a merged class/style was skipped
426
+ above). */
427
+ code += `${target}.push($attr(${JSON.stringify(attr.name)}, ${lowerExpression(interpolatedTemplateLiteral(attr.parts))}));\n`
396
428
  } else if (attr.kind === 'spread') {
397
429
  /* `{...expr}` element spread: each key as an attribute, functions (event
398
430
  handlers) skipped — the client `spreadAttrs` wires those on hydrate. Keys
@@ -415,38 +447,16 @@ export function generateSSR(
415
447
  code += `${target}.push(${JSON.stringify(` ${attr.property}="`)} + $esc(${bindRead(attr.code)}) + '"');\n`
416
448
  }
417
449
  }
418
- /* Merged class: static value + each directive's name when its expression is truthy. */
419
- if (classDirectives.length > 0) {
420
- const staticClass = node.attrs.find(
421
- (attr): attr is Extract<TemplateAttr, { kind: 'static' }> =>
422
- attr.kind === 'static' && attr.name === 'class',
423
- )
424
- const parts = [
425
- ...(staticClass ? [JSON.stringify(staticClass.value)] : []),
426
- ...classDirectives.map(
427
- (dir) => `((${lowerExpression(dir.code)}) ? ${JSON.stringify(dir.name)} : "")`,
428
- ),
429
- ]
430
- code += `${target}.push(' class="' + $esc([${parts.join(', ')}].filter(Boolean).join(' ')) + '"');\n`
450
+ /* Merged class/style: the same composed parts the build effect uses (from the plan),
451
+ joined and escaped into one attribute string. */
452
+ if (merge.mergeClass) {
453
+ code += `${target}.push(' class="' + $esc([${merge.classParts.join(', ')}].filter(Boolean).join(' ')) + '"');\n`
431
454
  }
432
- /* Merged style: static value + each directive's `prop:value` (String()-coerced, matching
433
- the client's style.setProperty). */
434
- if (styleDirectives.length > 0) {
435
- const staticStyle = node.attrs.find(
436
- (attr): attr is Extract<TemplateAttr, { kind: 'static' }> =>
437
- attr.kind === 'static' && attr.name === 'style',
438
- )
439
- const parts = [
440
- ...(staticStyle ? [JSON.stringify(staticStyle.value)] : []),
441
- ...styleDirectives.map(
442
- (dir) =>
443
- `(${JSON.stringify(`${dir.property}:`)} + String(${lowerExpression(dir.code)}))`,
444
- ),
445
- ]
446
- code += `${target}.push(' style="' + $esc([${parts.join(', ')}].filter(Boolean).join(';')) + '"');\n`
455
+ if (merge.mergeStyle) {
456
+ code += `${target}.push(' style="' + $esc([${merge.styleParts.join(', ')}].filter(Boolean).join(';')) + '"');\n`
447
457
  }
448
458
  code += push(target, '>')
449
- if (!VOID_TAGS.has(node.tag)) {
459
+ if (!plan.isVoid) {
450
460
  /* Each child's skeleton position (whether its reactive text interleaves into an
451
461
  anchor, whether a nested block anchors) is already recorded by `skeletonContext`
452
462
  — read per node, not tracked here. A `<script>` child scopes its bindings to
@@ -470,15 +480,13 @@ export function generateSSR(
470
480
  anchor: string,
471
481
  ): string {
472
482
  const wrap = inSkeleton.get(node)
473
- const fallback = generateInto(node.children, target)
474
483
  /* `$children` is an async builder the parent passes lazily; `await` it here so the
475
- slot content's block ids allocate AT the `<slot>` position — the same order the
484
+ slot content's block ids allocate AT the slot position — the same order the
476
485
  client builds slot content — keeping hydration congruent. The `await` makes a
477
- component with a `<slot>` an async render (its caller already `await`s `render()`). */
478
- const body =
479
- fallback.trim() === ''
480
- ? `if ($props && $props.$children) { ${target}.push(await $props.$children()); }\n`
481
- : `if ($props && $props.$children) { ${target}.push(await $props.$children()); } else {\n${fallback}}\n`
486
+ component with a slot an async render (its caller already `await`s `render()`).
487
+ A fallback is now an authored `{#if children}…{:else}…{/if}`, so the slot node
488
+ carries no children. */
489
+ const body = `if ($props && $props.$children) { ${target}.push(await $props.$children()); }\n`
482
490
  if (!wrap) {
483
491
  return body
484
492
  }
@@ -511,19 +519,37 @@ export function generateSSR(
511
519
  ): string {
512
520
  const plan = awaitPlan(node)
513
521
  const id = nextVar('$aid')
522
+ /* The resolved value lands in a synthetic var FIRST, then the author binding is
523
+ declared from it inside a nested block. Two reasons: the promise expression is
524
+ lowered in the outer scope where the binding name is not yet declared, so
525
+ `{#await foo then foo}` reads the outer `foo` (no temporal-dead-zone crash on a
526
+ same-named binding); and the nested block is the lexical scope the branch's
527
+ `withBindings` shadow models, so a reference to the binding reads the plain
528
+ local rather than the (unresolved) component signal it shadows. */
529
+ const resolved = nextVar('$av')
514
530
  let code = `const ${id} = $ctx.next++;\n`
515
531
  code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
516
532
  code += `try {\n`
517
- code += `const ${plan.resolvedAs} = await (${lowerExpression(node.promise)});\n`
518
- code += branchContent(plan.resolvedChildren, target)
533
+ code += `const ${resolved} = await (${lowerExpression(node.promise)});\n`
534
+ code += `{\n`
535
+ code += `const ${plan.resolvedAs} = ${resolved};\n`
536
+ code += withBindings(withShadow, plan.resolvedBindings, ssrBindingKind, () =>
537
+ branchContent(plan.resolvedChildren, target),
538
+ )
539
+ /* `finally` does not bind the resolved value, so it is lowered OUTSIDE the `then`
540
+ shadow — matching the catch branch below, so a finally expression naming the
541
+ same identifier as the `then` binding reads the component signal, not the local. */
519
542
  code += branchContent(plan.finallyChildren, target)
520
543
  code += `$resume[${id}] = { ok: true, value: ${plan.resolvedAs} };\n`
544
+ code += `}\n`
521
545
  if (plan.surfaceRejection) {
522
546
  /* No catch/finally → let the rejection surface instead of an empty branch. */
523
547
  code += `} catch (_error) { throw _error; }\n`
524
548
  } else {
525
549
  code += `} catch (${plan.catchAs}) {\n`
526
- code += branchContent(plan.catchChildren, target)
550
+ code += withBindings(withShadow, plan.catchBindings, ssrBindingKind, () =>
551
+ branchContent(plan.catchChildren, target),
552
+ )
527
553
  code += branchContent(plan.finallyChildren, target)
528
554
  code += `$resume[${id}] = { ok: false, error: String(${plan.catchAs}) };\n`
529
555
  code += `}\n`
@@ -549,15 +575,26 @@ export function generateSSR(
549
575
  code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
550
576
  code += branchContent(plan.pending, target)
551
577
  code += `${target}.push("<!--/abide:await:" + ${id} + "-->");\n`
552
- const settled = (binding: string, children: TemplateNode[]) =>
553
- `async (${binding}) => { const $o = []; ${branchContent(children, '$o')}${branchContent(plan.finallyChildren, '$o')}return $o.join(''); }`
578
+ /* The settled renderer takes the resolved/error value as a real arrow parameter
579
+ (`async (foo) => …`), so its body must lower references to that name as the plain
580
+ local — `withBindings` registers the plan's bindings (under `plain`, SSR's only kind)
581
+ so a binding that shadows a same-named component signal reads the value, not the
582
+ signal. The arrow param is the binding's author name; the names enter scope from the
583
+ same `plan.*Bindings` the client reads. */
584
+ const settled = (param: string, bindings: Binding[], children: TemplateNode[]) =>
585
+ `async (${param}) => { const $o = []; ${withBindings(
586
+ withShadow,
587
+ bindings,
588
+ ssrBindingKind,
589
+ () => branchContent(children, '$o'),
590
+ )}${branchContent(plan.finallyChildren, '$o')}return $o.join(''); }`
554
591
  const catchProp = plan.surfaceRejection
555
592
  ? ''
556
- : `catch: ${settled(plan.catchAs, plan.catchChildren)} `
593
+ : `catch: ${settled(plan.catchAs, plan.catchBindings, plan.catchChildren)} `
557
594
  code +=
558
595
  `$awaits.push({ id: ${id}, ` +
559
596
  `promise: () => (${lowerExpression(node.promise)}), ` +
560
- `then: ${settled(plan.resolvedAs, plan.resolvedChildren)}, ` +
597
+ `then: ${settled(plan.resolvedAs, plan.resolvedBindings, plan.resolvedChildren)}, ` +
561
598
  `${catchProp}});\n`
562
599
  return code
563
600
  }
@@ -580,7 +617,9 @@ export function generateSSR(
580
617
  code += branchContent(plan.finallyChildren, target)
581
618
  code += `} catch (${plan.catchAs}) {\n${target}.length = ${mark};\n`
582
619
  if (plan.hasCatch) {
583
- code += branchContent(plan.catchChildren, target)
620
+ code += withBindings(withShadow, plan.catchBindings, ssrBindingKind, () =>
621
+ branchContent(plan.catchChildren, target),
622
+ )
584
623
  code += branchContent(plan.finallyChildren, target)
585
624
  } else {
586
625
  code += `throw ${plan.catchAs};\n`
@@ -1,10 +1,13 @@
1
+ import type { Binding } from './types/Binding.ts'
1
2
  import type { TemplateNode } from './types/TemplateNode.ts'
2
3
 
3
4
  type Case = Extract<TemplateNode, { kind: 'case' }>
4
5
 
5
6
  /* The structural shape of an `if` chain, resolved once so the build and SSR back-ends share
6
7
  one partition of its children and only own emission. Both previously recomputed the
7
- then-content / branch split and the elseif/else classification. */
8
+ then-content / branch split and the elseif/else classification. An `if`/`elseif`/`else`
9
+ introduces no names, so `bindings` is empty — present so every block plan answers the
10
+ single binding source uniformly. */
8
11
  export type IfPlan = {
9
12
  /* The `then` content — every child that is not an `elseif`/`else` branch. */
10
13
  thenChildren: TemplateNode[]
@@ -16,6 +19,8 @@ export type IfPlan = {
16
19
  hasElseif: boolean
17
20
  /* The match-less `else` branch, if present. */
18
21
  elseBranch: Case | undefined
22
+ /* An `if` chain binds no names. */
23
+ bindings: Binding[]
19
24
  }
20
25
 
21
26
  /* Partitions an `if` node's children into the shared structural plan. */
@@ -26,5 +31,6 @@ export function ifPlan(node: Extract<TemplateNode, { kind: 'if' }>): IfPlan {
26
31
  branches,
27
32
  hasElseif: branches.some((branch) => branch.condition !== undefined),
28
33
  elseBranch: branches.find((branch) => branch.condition === undefined),
34
+ bindings: [],
29
35
  }
30
36
  }
@@ -0,0 +1,25 @@
1
+ import type { TextPart } from './types/TextPart.ts'
2
+
3
+ /* Escapes a literal attribute chunk for embedding in a JS template literal: a
4
+ backslash, backtick, or `${` would otherwise start an escape/expression. */
5
+ function escapeTemplateChunk(value: string): string {
6
+ return value.replace(/[\\`]/g, '\\$&').replace(/\$\{/g, '\\${')
7
+ }
8
+
9
+ /*
10
+ Builds the RAW (un-lowered) template-literal source for an interpolated attribute
11
+ or prop value — static parts escaped, each `{expr}` spliced as `${code}`. The caller
12
+ lowers the whole result through its signal transformer, so the embedded expressions
13
+ are rewritten exactly like any other template expression. Both back-ends and the
14
+ component-prop path share this one builder, so server markup, client binding, and
15
+ prop value can't diverge on the concatenation. Always yields a string, so an
16
+ interpolated attribute is always present.
17
+ */
18
+ export function interpolatedTemplateLiteral(parts: TextPart[]): string {
19
+ const body = parts
20
+ .map((part) =>
21
+ part.kind === 'static' ? escapeTemplateChunk(part.value) : `\${${part.code}}`,
22
+ )
23
+ .join('')
24
+ return `\`${body}\``
25
+ }
@@ -0,0 +1,7 @@
1
+ /* Compiled once at module load — re-evaluating the literal per call would recompile it. */
2
+ const PLAIN_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
3
+
4
+ /* Matches a JS identifier — a plain binding `as`/index that reads/keys directly, vs a
5
+ destructuring pattern that re-applies per read. Shared by `reactiveBinding` (cell deref)
6
+ and `each` key derivation so the one identifier test stays single-source. */
7
+ export const isPlainIdentifier = (name: string): boolean => PLAIN_IDENTIFIER.test(name)
@@ -2,13 +2,14 @@ import type { TemplateNode } from './types/TemplateNode.ts'
2
2
 
3
3
  /*
4
4
  An element whose children are ONLY text and `<style>` — the common `<h1>{x}</h1>` shape.
5
- The shared anchor-placement decision for reactive text: BOTH back-ends consult this so they
6
- agree on where a text anchor goes. A text-leaf binds its reactive text marker-free on the
7
- located element (`generateBuild`) / emits it without an `<!--a-->` prefix (`generateSSR`);
8
- reactive text interleaved with element children is anchor-positioned instead. Computing it
9
- once keeps the SSR string and the client skeleton from disagreeing about a `<!--a-->` — the
10
- first slice of lifting the positional model out of the two parallel traversals, alongside
11
- the already-shared `isControlFlow` and `skeletonable`.
5
+ The anchor-placement decision for reactive text: `skeletonContext` consults this once and
6
+ records the result in `markText`; both back-ends read that map so they agree on where a
7
+ text anchor goes. A text-leaf binds its reactive text marker-free on the located element
8
+ (`generateBuild`) / emits it without an `<!--a-->` prefix (`generateSSR`); reactive text
9
+ interleaved with element children is anchor-positioned instead. Computing it once keeps
10
+ the SSR string and the client skeleton from disagreeing about a `<!--a-->` the first
11
+ slice of lifting the positional model out of the two parallel traversals, alongside the
12
+ already-shared `isControlFlow` and `skeletonable`.
12
13
  */
13
14
  export function isTextLeaf(node: Extract<TemplateNode, { kind: 'element' }>): boolean {
14
15
  return node.children.every((child) => child.kind === 'text' || child.kind === 'style')
@@ -1,4 +1,5 @@
1
1
  import ts from 'typescript'
2
+ import { createShadowScope } from './createShadowScope.ts'
2
3
  import { docAccessTransformer } from './lowerDocAccess.ts'
3
4
  import { nestedBindingNames } from './prepareNestedScript.ts'
4
5
  import { signalRefsTransformer } from './renameSignalRefs.ts'
@@ -6,6 +7,9 @@ import { TS_PRINTER } from './TS_PRINTER.ts'
6
7
  import type { TemplateNode } from './types/TemplateNode.ts'
7
8
  import { unwrapParens } from './unwrapParens.ts'
8
9
 
10
+ /* Compiled once — strips the trailing `;` off every lowered expression. */
11
+ const TRAILING_SEMICOLON = /;$/
12
+
9
13
  /*
10
14
  The shared expression-lowering context both back-ends build on: the signal→`model`
11
15
  rewrite and doc-access lowering that turns the signal surface the author writes
@@ -22,12 +26,14 @@ export function lowerContext(
22
26
  derivedNames: ReadonlySet<string>,
23
27
  computedNames: ReadonlySet<string> = new Set(),
24
28
  ) {
25
- /* Branch-scoped signal bindings (from nested `<script>`s, and the block value params
26
- pushed by `withLocalDerived`) they deref to `.value` like a `computed`, and as a
27
- nearer lexical scope they SHADOW a same-named component signal. Pushed while a
28
- branch's script + markup compile, popped after, so they shadow only within that
29
- subtree. */
30
- const localDerived = new Set<string>()
29
+ /* The typed branch-local shadow stack: one auto-popping value owning both kinds.
30
+ `derived` names deref to `.value` like a `computed` (block value params the client
31
+ binds as a reactive cell, and nested-`<script>` bindings); `plain` names deref as
32
+ the bare identifier (block value params SSR binds as a real JS local). Both, as a
33
+ nearer lexical scope, SHADOW a same-named component signal. `withShadow` pushes on
34
+ entry and pops in a `finally`, so a branch's shadows cannot outlive the branch even
35
+ if its body throws — the leak the old hand-written pop allowed. */
36
+ const scope = createShadowScope()
31
37
 
32
38
  /* Parse `code` once and chain the reference rename and doc-access lowering over the
33
39
  one tree — the two string passes would each parse + reprint. `localDerived` is
@@ -36,8 +42,14 @@ export function lowerContext(
36
42
  function lowerOnce(code: string): string {
37
43
  const source = ts.createSourceFile('expr.ts', code, ts.ScriptTarget.Latest, true)
38
44
  const result = ts.transform(source, [
39
- signalRefsTransformer(stateNames, derivedNames, computedNames, new Set(localDerived)),
40
- docAccessTransformer('model'),
45
+ signalRefsTransformer(
46
+ stateNames,
47
+ derivedNames,
48
+ computedNames,
49
+ new Set(scope.names('derived')),
50
+ new Set(scope.names('plain')),
51
+ ),
52
+ docAccessTransformer('$$model'),
41
53
  ])
42
54
  const output = TS_PRINTER.printFile(result.transformed[0] as ts.SourceFile).trim()
43
55
  result.dispose()
@@ -48,7 +60,7 @@ export function lowerContext(
48
60
  literal (`{ a: 1 }`) parses as an expression, not a block of labeled statements,
49
61
  through the rewrite; the wrapper is then peeled back off. */
50
62
  function expression(code: string): string {
51
- return unwrapParens(lowerOnce(`(${code})`).replace(/;$/, ''))
63
+ return unwrapParens(lowerOnce(`(${code})`).replace(TRAILING_SEMICOLON, ''))
52
64
  }
53
65
 
54
66
  /* As above but keeps the trailing `;` for a statement/handler body. */
@@ -93,45 +105,23 @@ export function lowerContext(
93
105
  }
94
106
 
95
107
  /* Adds any `<script>` children's binding names to the deref scope (so the script
96
- bodies and the branch's markup auto-deref them), runs `body` within that scope,
97
- then pops the names it added. Returns whatever `body` produces. */
108
+ bodies and the branch's markup auto-deref them) for the duration of `body`. */
98
109
  function withNestedScripts<T>(children: TemplateNode[], body: () => T): T {
99
- const added: string[] = []
100
- for (const child of children) {
101
- if (child.kind === 'script') {
102
- for (const name of nestedBindingNames(child.code)) {
103
- if (!localDerived.has(name)) {
104
- localDerived.add(name)
105
- added.push(name)
106
- }
107
- }
108
- }
109
- }
110
- const result = body()
111
- for (const name of added) {
112
- localDerived.delete(name)
113
- }
114
- return result
110
+ const names = children.flatMap((child) =>
111
+ child.kind === 'script' ? [...nestedBindingNames(child.code)] : [],
112
+ )
113
+ return scope.withShadow(names, 'derived', body)
115
114
  }
116
115
 
117
- /* Pushes explicit names into the deref scope for `body` then pops them — the
118
- programmatic counterpart to `withNestedScripts`, used to bind a block's value param
119
- (an `await` `then` value, a keyed `each` item) as a reactive `.value` cell so the
120
- branch reads it reactively and re-runs in place when the block sets the cell. */
121
- function withLocalDerived<T>(names: string[], body: () => T): T {
122
- const added: string[] = []
123
- for (const name of names) {
124
- if (!localDerived.has(name)) {
125
- localDerived.add(name)
126
- added.push(name)
127
- }
128
- }
129
- const result = body()
130
- for (const name of added) {
131
- localDerived.delete(name)
132
- }
133
- return result
116
+ return {
117
+ expression,
118
+ statement,
119
+ withNestedScripts,
120
+ /* The raw branch-local shadow registration both back-ends drive `withBindings`
121
+ through: a block's bindings flow to a `ShadowKind` only via that one shared loop,
122
+ never through a per-block `scope.withShadow` call here. */
123
+ withShadow: scope.withShadow,
124
+ bindRead,
125
+ bindWrite,
134
126
  }
135
-
136
- return { expression, statement, withNestedScripts, withLocalDerived, bindRead, bindWrite }
137
127
  }
@@ -191,7 +191,7 @@ export function docAccessTransformer(docName: string): ts.TransformerFactory<ts.
191
191
  naming the authored scope path and member — the key and member are both
192
192
  in hand here, the `.abide` location comes free off the mapped stack. */
193
193
  return ts.factory.createCallExpression(
194
- ts.factory.createIdentifier('readCall'),
194
+ ts.factory.createIdentifier('$$readCall'),
195
195
  undefined,
196
196
  [
197
197
  read,
@@ -268,7 +268,7 @@ function buildPath(segments: Segment[]): ts.Expression {
268
268
  key holding `/`|`~` (a date, a composite id) addresses one segment, not many. */
269
269
  fragments.push(
270
270
  ts.factory.createCallExpression(
271
- ts.factory.createIdentifier('escapeKey'),
271
+ ts.factory.createIdentifier('$$escapeKey'),
272
272
  undefined,
273
273
  [segment.node],
274
274
  ),
@@ -34,7 +34,7 @@ export function lowerScript(scriptBody: string): {
34
34
  const result = ts.transform(source, [
35
35
  transformer,
36
36
  signalRefsTransformer(stateNames, derivedNames, computedNames),
37
- docAccessTransformer('model'),
37
+ docAccessTransformer('$$model'),
38
38
  ])
39
39
  const transformed = result.transformed[0] as ts.SourceFile
40
40
  /* Top-level imports must live at module scope, not inside the mount/render