@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
@@ -6,23 +6,30 @@ import { asOutlet } from './asOutlet.ts'
6
6
  import { awaitPlan } from './awaitPlan.ts'
7
7
  import { bindListenEvent } from './bindListenEvent.ts'
8
8
  import { composeProps } from './composeProps.ts'
9
- import { destructureBindingNames } from './destructureBindingNames.ts'
9
+ import { eachPlan } from './eachPlan.ts'
10
+ import { elementPlan } from './elementPlan.ts'
10
11
  import { groupBindParts } from './groupBindParts.ts'
11
12
  import { ifPlan } from './ifPlan.ts'
13
+ import { interpolatedTemplateLiteral } from './interpolatedTemplateLiteral.ts'
12
14
  import { isControlFlow } from './isControlFlow.ts'
15
+ import { isPlainIdentifier } from './isPlainIdentifier.ts'
13
16
  import { isWhitespaceText } from './isWhitespaceText.ts'
14
17
  import { lowerContext } from './lowerContext.ts'
15
18
  import { makeVarNamer } from './makeVarNamer.ts'
16
- import { resolveBranches } from './resolveBranches.ts'
19
+ import { reactiveBinding } from './reactiveBinding.ts'
17
20
  import { scopeAttr } from './scopeAttr.ts'
18
21
  import { skeletonContext } from './skeletonContext.ts'
22
+ import { snippetPlan } from './snippetPlan.ts'
19
23
  import { spreadExcludedNames } from './spreadExcludedNames.ts'
20
24
  import { staticAttr } from './staticAttr.ts'
21
25
  import { staticTextPart } from './staticTextPart.ts'
22
26
  import { switchPlan } from './switchPlan.ts'
23
27
  import { tryPlan } from './tryPlan.ts'
28
+ import type { Binding } from './types/Binding.ts'
29
+ import type { ShadowKind } from './types/ShadowKind.ts'
24
30
  import type { TemplateNode } from './types/TemplateNode.ts'
25
31
  import { VOID_TAGS } from './VOID_TAGS.ts'
32
+ import { withBindings } from './withBindings.ts'
26
33
 
27
34
  /* The skeleton positioning anchor a control-flow block / component / slot / outlet
28
35
  stamps into its skeleton markup, sourced from the same `ANCHOR` wire-alphabet constant
@@ -36,8 +43,9 @@ attributes, reactive `attr`/`text` bindings, `on` listeners, keyed `each`, and
36
43
  conditional `when`. Every embedded expression is first rewritten from the signal
37
44
  surface (`count` → `model.count`) and then lowered to the doc patch/read API
38
45
  (cell-hoisting runs over the whole result afterwards). The output operates on
39
- `hostVar` and expects the dom bindings, `doc`, `effect`, and the component's
40
- `model` in scope — the body the component compiler wraps and hoists cells into.
46
+ `hostVar` and expects the `$$`-aliased dom bindings (`$$effect`, `$$each`, …)
47
+ and the component's `$$model` (emitted by `desugarSignals`) in scope — the body
48
+ the component compiler wraps and hoists cells into.
41
49
  */
42
50
 
43
51
  /* A JS-identifier-safe frame name from an authored construct label (an attribute name
@@ -97,49 +105,17 @@ export function generateBuild(
97
105
  expression: lowerExpression,
98
106
  statement: lowerStatement,
99
107
  withNestedScripts,
100
- withLocalDerived,
108
+ withShadow,
101
109
  bindRead,
102
110
  bindWrite,
103
111
  } = lowerContext(stateNames, derivedNames, computedNames)
104
112
 
105
- /* A value binding the runtime can update in place (an `await` `then` value, a keyed `each`
106
- item) is passed as a reactive `.value` cell; the consuming branch/row reads its NAME(s)
107
- as a deref so it re-runs in place when a re-settle/re-key sets the cell. */
108
- const isPlainIdentifier = (name: string | undefined): name is string =>
109
- name !== undefined && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)
110
-
111
- /* How a reactive value param binds: `param` is the thunk's value parameter (the cell),
112
- `prefix` declares any per-leaf readers, `localNames` enter the deref scope for the body.
113
- A plain identifier reads the cell directly (`item` → `item.value`); a destructure
114
- re-applies over the cell per read so each leaf stays reactive (JS handles
115
- defaults/rest/rename/nesting). The caller lowers the body with `localNames` derived. */
116
- function reactiveBinding(authorParam: string): {
117
- param: string
118
- prefix: string
119
- localNames: string[]
120
- } {
121
- if (isPlainIdentifier(authorParam)) {
122
- return { param: authorParam, prefix: '', localNames: [authorParam] }
123
- }
124
- const cellParam = nextVar('aw')
125
- const deriveVar = nextVar('ad')
126
- const leaves = destructureBindingNames(authorParam)
127
- /* Lower the destructure declaration so a default/computed-key initializer that
128
- references a component signal (`{ label = fallback }`, `{ [key]: v }`) is rewritten
129
- to its `model`/cell form — the bound leaf names are name-slots and stay untouched.
130
- A pattern with no such initializer lowers to itself, so the common case is unchanged
131
- and SSR (which lowers the same declaration) stays congruent. */
132
- const declaration = lowerStatement(`const ${authorParam} = ${cellParam}.value`)
133
- const prefix =
134
- `const ${deriveVar} = { get value() { ${declaration} return { ${leaves.join(', ')} }; } };\n` +
135
- leaves
136
- .map(
137
- (leaf) =>
138
- `const ${leaf} = { get value() { return ${deriveVar}.value.${leaf}; } };\n`,
139
- )
140
- .join('')
141
- return { param: cellParam, prefix, localNames: leaves }
142
- }
113
+ /* Maps a plan `Binding`'s classification to the client `ShadowKind`: a `reactive` value
114
+ derefs as a `.value` cell (`derived`), a `plain` value as the bare local (`plain`). The
115
+ injected mapping `withBindings` registers every binding through the one place a name's
116
+ kind is chosen on this back-end. */
117
+ const buildBindingKind = (binding: Binding): ShadowKind =>
118
+ binding.classification === 'reactive' ? 'derived' : 'plain'
143
119
 
144
120
  /* Emits the wiring for one non-static attribute against an already-obtained skeleton
145
121
  element var — reactive `attr`, `on` listener, `attach`, or a two-way `bind`. */
@@ -147,27 +123,36 @@ export function generateBuild(
147
123
  node: Extract<TemplateNode, { kind: 'element' }>,
148
124
  attr: Extract<
149
125
  (typeof node.attrs)[number],
150
- { kind: 'expression' | 'event' | 'attach' | 'bind' | 'class' | 'style' }
126
+ {
127
+ kind:
128
+ 'expression' | 'interpolated' | 'event' | 'attach' | 'bind' | 'class' | 'style'
129
+ }
151
130
  >,
152
131
  varName: string,
153
132
  ): string {
154
133
  if (attr.kind === 'expression') {
155
- return `attr(${varName}, ${JSON.stringify(attr.name)}, ${namedThunk(`attr_${attr.name}`, `return (${lowerExpression(attr.code)})`)});\n`
134
+ return `$$attr(${varName}, ${JSON.stringify(attr.name)}, ${namedThunk(`attr_${attr.name}`, `return (${lowerExpression(attr.code)})`)});\n`
135
+ }
136
+ /* `name="literal {expr}"` — the template-literal concatenation bound as a reactive
137
+ string attribute (always present). A class/style with directives is merged
138
+ upstream of this dispatch, so it never reaches here. */
139
+ if (attr.kind === 'interpolated') {
140
+ return `$$attr(${varName}, ${JSON.stringify(attr.name)}, ${namedThunk(`attr_${attr.name}`, `return (${lowerExpression(interpolatedTemplateLiteral(attr.parts))})`)});\n`
156
141
  }
157
142
  if (attr.kind === 'event') {
158
- return `on(${varName}, ${JSON.stringify(attr.event)}, (${lowerExpression(attr.code)}));\n`
143
+ return `$$on(${varName}, ${JSON.stringify(attr.event)}, (${lowerExpression(attr.code)}));\n`
159
144
  }
160
145
  if (attr.kind === 'attach') {
161
- return `attach(${varName}, (${lowerExpression(attr.code)}));\n`
146
+ return `$$attach(${varName}, (${lowerExpression(attr.code)}));\n`
162
147
  }
163
148
  /* `class:<name>` — toggle the class by truthiness; surgical, no element re-render.
164
149
  Layers on top of any static `class="…"` in the skeleton (classList is additive). */
165
150
  if (attr.kind === 'class') {
166
- return `effect(${namedThunk(`class_${attr.name}`, `${varName}.classList.toggle(${JSON.stringify(attr.name)}, !!(${lowerExpression(attr.code)}));`)});\n`
151
+ return `$$effect(${namedThunk(`class_${attr.name}`, `${varName}.classList.toggle(${JSON.stringify(attr.name)}, !!(${lowerExpression(attr.code)}));`)});\n`
167
152
  }
168
153
  /* `style:<property>` — write one inline style / custom property reactively. */
169
154
  if (attr.kind === 'style') {
170
- return `effect(${namedThunk(`style_${attr.property}`, `${varName}.style.setProperty(${JSON.stringify(attr.property)}, String(${lowerExpression(attr.code)}));`)});\n`
155
+ return `$$effect(${namedThunk(`style_${attr.property}`, `${varName}.style.setProperty(${JSON.stringify(attr.property)}, String(${lowerExpression(attr.code)}));`)});\n`
171
156
  }
172
157
  if (attr.property === 'group') {
173
158
  /* Grouped two-way: radio binds the path to the single checked `value`;
@@ -180,13 +165,13 @@ export function generateBuild(
180
165
  const value = lowerExpression(valueCode)
181
166
  if (isRadio) {
182
167
  return (
183
- `effect(${namedThunk('bind_group', `${varName}.checked = (${lowerExpression(attr.code)}) === (${value});`)});\n` +
184
- `on(${varName}, "change", () => { if (${varName}.checked) { ${lowerStatement(`${attr.code} = ${valueCode}`)} } });\n`
168
+ `$$effect(${namedThunk('bind_group', `${varName}.checked = (${lowerExpression(attr.code)}) === (${value});`)});\n` +
169
+ `$$on(${varName}, "change", () => { if (${varName}.checked) { ${lowerStatement(`${attr.code} = ${valueCode}`)} } });\n`
185
170
  )
186
171
  }
187
172
  return (
188
- `effect(${namedThunk('bind_group', `${varName}.checked = (${lowerExpression(attr.code)}).includes(${value});`)});\n` +
189
- `on(${varName}, "change", () => { const $groupValue = ${value}; if (${varName}.checked) { if (!(${lowerExpression(attr.code)}).includes($groupValue)) { ${lowerStatement(`${attr.code}.push($groupValue)`)} } } else { const $groupIndex = (${lowerExpression(attr.code)}).indexOf($groupValue); if ($groupIndex !== -1) { ${lowerStatement(`delete ${attr.code}[$groupIndex]`)} } } });\n`
173
+ `$$effect(${namedThunk('bind_group', `${varName}.checked = (${lowerExpression(attr.code)}).includes(${value});`)});\n` +
174
+ `$$on(${varName}, "change", () => { const $groupValue = ${value}; if (${varName}.checked) { if (!(${lowerExpression(attr.code)}).includes($groupValue)) { ${lowerStatement(`${attr.code}.push($groupValue)`)} } } else { const $groupIndex = (${lowerExpression(attr.code)}).indexOf($groupValue); if ($groupIndex !== -1) { ${lowerStatement(`delete ${attr.code}[$groupIndex]`)} } } });\n`
190
175
  )
191
176
  }
192
177
  /* Two-way: drive the property from the bind target, and write it back on the
@@ -196,8 +181,8 @@ export function generateBuild(
196
181
  `.get()` and writes via `.set(v)` — see `bindRead`/`bindWrite`. */
197
182
  const event = bindListenEvent(attr.property, node.tag)
198
183
  return (
199
- `effect(${namedThunk(`bind_${attr.property}`, `${varName}.${attr.property} = ${bindRead(attr.code)};`)});\n` +
200
- `on(${varName}, ${JSON.stringify(event)}, () => { ${bindWrite(attr.code, `${varName}.${attr.property}`)} });\n`
184
+ `$$effect(${namedThunk(`bind_${attr.property}`, `${varName}.${attr.property} = ${bindRead(attr.code)};`)});\n` +
185
+ `$$on(${varName}, ${JSON.stringify(event)}, () => { ${bindWrite(attr.code, `${varName}.${attr.property}`)} });\n`
201
186
  )
202
187
  }
203
188
 
@@ -231,7 +216,7 @@ export function generateBuild(
231
216
  return staticTextPart(part.value)
232
217
  }
233
218
  binds.push(
234
- `appendTextAt(${skVar}.an[${holeIndex(anIndex, part)}], ${namedThunk('text', `return (${lowerExpression(part.code)})`)});\n`,
219
+ `$$appendTextAt(${skVar}.an[${holeIndex(anIndex, part)}], ${namedThunk('text', `return (${lowerExpression(part.code)})`)});\n`,
235
220
  )
236
221
  return ANCHOR_COMMENT
237
222
  })
@@ -245,7 +230,9 @@ export function generateBuild(
245
230
  (`anchor.parentNode`). A component takes an anchor like a block — no wrapper
246
231
  element — so its root lays out as a true direct child of `anchor.parentNode`. */
247
232
  const anchorVar = anchorVarAt(node, skVar, binds)
248
- binds.push(generateChild(node, `${anchorVar}.parentNode`, `anchorCursor(${anchorVar})`))
233
+ binds.push(
234
+ generateChild(node, `${anchorVar}.parentNode`, `$$anchorCursor(${anchorVar})`),
235
+ )
249
236
  return ANCHOR_COMMENT
250
237
  }
251
238
  if (node.kind === 'script') {
@@ -270,7 +257,7 @@ export function generateBuild(
270
257
  `outlet` boundary the router fills with the next chain layer (`fillBoundary`).
271
258
  No wrapper element — the filled child lays out as a direct child of the parent. */
272
259
  const anchorVar = anchorVarAt(node, skVar, binds)
273
- binds.push(`outlet(${anchorVar}.parentNode, anchorCursor(${anchorVar}));\n`)
260
+ binds.push(`$$outlet(${anchorVar}.parentNode, $$anchorCursor(${anchorVar}));\n`)
274
261
  return ANCHOR_COMMENT
275
262
  }
276
263
  if (node.tag === 'slot') {
@@ -279,7 +266,7 @@ export function generateBuild(
279
266
  const anchorVar = anchorVarAt(node, skVar, binds)
280
267
  const hostVar = nextVar('host')
281
268
  binds.push(
282
- `mountSlot(${anchorVar}.parentNode, (${hostVar}) => {\n${generateSlot(node, hostVar)}}, anchorCursor(${anchorVar}));\n`,
269
+ `$$mountSlot(${anchorVar}.parentNode, (${hostVar}) => {\n${generateSlot(node, hostVar)}}, $$anchorCursor(${anchorVar}));\n`,
283
270
  )
284
271
  return ANCHOR_COMMENT
285
272
  }
@@ -294,6 +281,12 @@ export function generateBuild(
294
281
  SSR back-end also reads, rather than re-deriving leaf-ness via `isTextLeaf` here. */
295
282
  const textLeafBind =
296
283
  reactiveTextChild !== undefined && markText.get(reactiveTextChild) === false
284
+ /* The shared per-element emission DECISION (one site both back-ends consult): each
285
+ attribute classified + tagged with its merge status, the class/style/directive merge
286
+ folded in, and void-tag status. The composed `classParts`/`styleParts` and the
287
+ merge triggers come from the same plan SSR renders, so neither the attribute set nor
288
+ the merged value can drift. Build still RENDERS each kind below as live wiring. */
289
+ const plan = elementPlan(node, lowerExpression)
297
290
  let openTag = `<${node.tag}`
298
291
  let elVar = ''
299
292
  if (hasReactiveAttr || textLeafBind) {
@@ -303,13 +296,33 @@ export function generateBuild(
303
296
  elVar = nextVar('el')
304
297
  binds.push(`const ${elVar} = ${skVar}.el[${holeIndex(elIndex, node)}];\n`)
305
298
  openTag += ` ${HOLE_ATTRIBUTE}`
306
- for (const attr of node.attrs) {
299
+ /* An interpolated (reactive) class/style base can't layer additive directive toggles
300
+ on top — re-setting the base would wipe them — so base + its directives collapse
301
+ into ONE effect computing the whole value (`mergeClassBuild`/`mergeStyleBuild`,
302
+ the build trigger); their attrs are then skipped (`mergedBuild`) in the dispatch
303
+ below. A STATIC base keeps the surgical-toggle model (the base sits in the cloned
304
+ skeleton). */
305
+ if (plan.merge.mergeClassBuild) {
306
+ binds.push(
307
+ `$$effect(${namedThunk('class_merge', `${elVar}.setAttribute("class", [${plan.merge.classParts.join(', ')}].filter(Boolean).join(' '));`)});\n`,
308
+ )
309
+ }
310
+ if (plan.merge.mergeStyleBuild) {
311
+ binds.push(
312
+ `$$effect(${namedThunk('style_merge', `${elVar}.setAttribute("style", [${plan.merge.styleParts.join(', ')}].filter(Boolean).join(';'));`)});\n`,
313
+ )
314
+ }
315
+ for (const { attr, mergedBuild } of plan.attrs) {
316
+ /* Skip the attrs already folded into a merged class/style effect. */
317
+ if (mergedBuild) {
318
+ continue
319
+ }
307
320
  if (attr.kind === 'spread') {
308
321
  /* `{...expr}` onto the element: each key binds as a reactive attribute
309
322
  (or an `on<event>` function as a listener) via `spreadAttrs`, skipping
310
323
  any key explicitly named on the element (the explicit attr wins). */
311
324
  binds.push(
312
- `spreadAttrs(${elVar}, ${namedThunk('spread', `return (${lowerExpression(attr.code)})`)}, ${JSON.stringify(spreadExcludedNames(node.attrs))});\n`,
325
+ `$$spreadAttrs(${elVar}, ${namedThunk('spread', `return (${lowerExpression(attr.code)})`)}, ${JSON.stringify(spreadExcludedNames(node.attrs))});\n`,
313
326
  )
314
327
  } else if (attr.kind !== 'static') {
315
328
  binds.push(dynamicAttr(node, attr, elVar))
@@ -319,13 +332,16 @@ export function generateBuild(
319
332
  for (const scope of node.scopes ?? []) {
320
333
  openTag += scopeAttr(scope)
321
334
  }
322
- for (const attr of node.attrs) {
323
- if (attr.kind === 'static') {
335
+ /* Static attrs sit in the cloned skeleton markup — emitted here regardless of whether
336
+ the element is a hole. A static class/style folded into a merged attribute (build
337
+ trigger) is skipped, since the merge effect re-sets the whole value. */
338
+ for (const { attr, mergedBuild } of plan.attrs) {
339
+ if (attr.kind === 'static' && !mergedBuild) {
324
340
  openTag += staticAttr(attr.name, attr.value)
325
341
  }
326
342
  }
327
343
  openTag += '>'
328
- if (VOID_TAGS.has(node.tag)) {
344
+ if (plan.isVoid) {
329
345
  return openTag
330
346
  }
331
347
  if (textLeafBind) {
@@ -352,7 +368,7 @@ export function generateBuild(
352
368
  const skVar = nextVar('sk')
353
369
  const binds: string[] = []
354
370
  const html = skeletonMarkup(node, skVar, binds)
355
- return `const ${skVar} = skeleton(${parentVar}, ${JSON.stringify(html)});\n${binds.join('')}`
371
+ return `const ${skVar} = $$skeleton(${parentVar}, ${JSON.stringify(html)});\n${binds.join('')}`
356
372
  }
357
373
 
358
374
  /* Emits code appending `node` to `parentVar`. */
@@ -378,8 +394,8 @@ export function generateBuild(
378
394
  .map((part, index) => {
379
395
  const splitAlways = index < consumers.length - 1 ? ', true' : ''
380
396
  return part.kind === 'static'
381
- ? `appendStatic(${parentVar}, ${JSON.stringify(part.value)}${splitAlways});\n`
382
- : `appendText(${parentVar}, ${namedThunk('text', `return (${lowerExpression(part.code)})`)}${splitAlways});\n`
397
+ ? `$$appendStatic(${parentVar}, ${JSON.stringify(part.value)}${splitAlways});\n`
398
+ : `$$appendText(${parentVar}, ${namedThunk('text', `return (${lowerExpression(part.code)})`)}${splitAlways});\n`
383
399
  })
384
400
  .join('')
385
401
  }
@@ -387,7 +403,7 @@ export function generateBuild(
387
403
  /* A standalone layout outlet (a top-level/element-nested `<slot/>` rewritten by
388
404
  `asOutlet`, reached outside any skeleton): an empty `outlet` boundary at
389
405
  `before`, no anchor — the router fills it with the next chain layer. */
390
- return `outlet(${parentVar}, ${before});\n`
406
+ return `$$outlet(${parentVar}, ${before});\n`
391
407
  }
392
408
  if (node.kind === 'element' && node.tag === 'slot') {
393
409
  /* In a layout, `<slot/>` is the router's page outlet (`outlet` boundary the
@@ -395,7 +411,7 @@ export function generateBuild(
395
411
  are rewritten to `OUTLET_TAG` up front by `asOutlet` and handled above; this
396
412
  covers a layout slot reached inside a control-flow branch. */
397
413
  if (isLayout) {
398
- return `outlet(${parentVar}, ${before});\n`
414
+ return `$$outlet(${parentVar}, ${before});\n`
399
415
  }
400
416
  return generateSlot(node, parentVar)
401
417
  }
@@ -452,7 +468,7 @@ export function generateBuild(
452
468
  let runHtml = ''
453
469
  const flush = (): void => {
454
470
  if (runHtml !== '') {
455
- code += `cloneStatic(${parentVar}, ${JSON.stringify(runHtml)});\n`
471
+ code += `$$cloneStatic(${parentVar}, ${JSON.stringify(runHtml)});\n`
456
472
  runHtml = ''
457
473
  }
458
474
  }
@@ -473,8 +489,14 @@ export function generateBuild(
473
489
  the component scope (its `model`/cells); `args` are plain parameters bound by
474
490
  the call. Appends nothing at the declaration site — `{name(args)}` mounts it. */
475
491
  function generateSnippet(node: Extract<TemplateNode, { kind: 'snippet' }>): string {
476
- const body = node.children.map((child) => generateChild(child, '$host')).join('')
477
- return `function ${node.name}(${node.params ?? ''}) {\nreturn snippet(($host) => {\n${body}});\n}\n`
492
+ const plan = snippetPlan(node)
493
+ /* `args` are plain call parameters, not component cells — `withBindings` registers the
494
+ plan's `plain` bindings so the body reads the bare local, shadowing a same-named
495
+ component signal rather than reading it. */
496
+ const body = withBindings(withShadow, plan.bindings, buildBindingKind, () =>
497
+ plan.children.map((child) => generateChild(child, '$host')).join(''),
498
+ )
499
+ return `function ${plan.name}(${plan.params ?? ''}) {\nreturn $$snippet(($host) => {\n${body}});\n}\n`
478
500
  }
479
501
 
480
502
  /* A switch: each `case` is `{ match: () => value, render }`, the default is
@@ -493,21 +515,17 @@ export function generateBuild(
493
515
  return `{ match: ${match}, render: ${branchThunk(branch.children)} }`
494
516
  })
495
517
  .join(', ')
496
- return `switchBlock(${parentVar}, () => (${lowerExpression(node.subject)}), [${cases}], ${before});\n`
518
+ return `$$switchBlock(${parentVar}, () => (${lowerExpression(node.subject)}), [${cases}], ${before});\n`
497
519
  }
498
520
 
499
- /* A `<slot>` outlet: render the parent-provided content (`$children`), falling
500
- back to the slot's own children when the parent supplied none. */
521
+ /* A `{children()}` slot fill point: render the parent-provided content (`$children`).
522
+ A fallback is now an authored `{#if children}…{:else}…{/if}`, so the slot node never
523
+ carries children — the invoke is unconditional but guarded against a propless mount. */
501
524
  function generateSlot(
502
- node: Extract<TemplateNode, { kind: 'element' }>,
525
+ _node: Extract<TemplateNode, { kind: 'element' }>,
503
526
  parentVar: string,
504
527
  ): string {
505
- const fallback = generateChildren(node.children, parentVar)
506
- const invoke = `$props.$children(${parentVar})`
507
- if (fallback.trim() === '') {
508
- return `if ($props && $props.$children) { ${invoke}; }\n`
509
- }
510
- return `if ($props && $props.$children) { ${invoke}; } else {\n${fallback}}\n`
528
+ return `if ($props && $props.$children) { $props.$children(${parentVar}); }\n`
511
529
  }
512
530
 
513
531
  /* The child's slot content as a host-taking builder (`$children`), or undefined when
@@ -534,11 +552,11 @@ export function generateBuild(
534
552
  parentVar: string,
535
553
  before: string,
536
554
  ): string {
537
- return `mountChild(${parentVar}, ${node.name}, ${propsArg(node)}, ${before}, ${JSON.stringify(node.name)});\n`
555
+ return `$$mountChild(${parentVar}, ${node.name}, ${propsArg(node)}, ${before}, ${JSON.stringify(node.name)});\n`
538
556
  }
539
557
 
540
558
  /* An await block: pending → resolved(value) / error branches. Each branch is a
541
- single-element root; a render thunk returns its node. */
559
+ void render thunk `(parent[, value]) => void` that builds its content into `parent`. */
542
560
  function generateAwait(
543
561
  node: Extract<TemplateNode, { kind: 'await' }>,
544
562
  parentVar: string,
@@ -551,21 +569,20 @@ export function generateBuild(
551
569
  resolved content/binding feeds one thunk regardless of mode. */
552
570
  const thenThunk = branchThunk(
553
571
  plan.resolvedChildren,
554
- plan.resolvedAs,
572
+ plan.resolvedBindings,
555
573
  plan.finallyChildren,
556
- true,
557
574
  )
558
575
  /* Neither catch nor finally → pass `undefined` so awaitBlock re-throws the
559
576
  rejection (surfacing it) instead of rendering an empty branch. A finally-only
560
577
  block keeps a catch thunk that renders just finally. */
561
578
  const catchThunk = plan.surfaceRejection
562
579
  ? 'undefined'
563
- : branchThunk(plan.catchChildren, plan.catchAs, plan.finallyChildren)
580
+ : branchThunk(plan.catchChildren, plan.catchBindings, plan.finallyChildren)
564
581
  const pendingThunk = hasRenderableContent(plan.pending)
565
582
  ? branchThunk(plan.pending)
566
583
  : 'undefined'
567
584
  return (
568
- `awaitBlock(${parentVar}, nextBlockId(), () => (${lowerExpression(node.promise)}), ` +
585
+ `$$awaitBlock(${parentVar}, $$nextBlockId(), () => (${lowerExpression(node.promise)}), ` +
569
586
  `${pendingThunk}, ` +
570
587
  `${thenThunk}, ` +
571
588
  `${catchThunk}, ${before});\n`
@@ -576,28 +593,33 @@ export function generateBuild(
576
593
  builds its children — and an optional trailing `finally` branch — into
577
594
  `parent`. The full-range model tracks the built content between markers, so a
578
595
  branch holds ANY content (components, text, nested control-flow, snippets) and
579
- is generated exactly like a normal child list. `valueParam` binds a resolved /
580
- error / item value into scope. Nested `<script>`s are emitted in document order
581
- by `generateChildren`; `withNestedScripts` puts their bindings in deref scope. */
596
+ is generated exactly like a normal child list. `bindings` are the value param(s)
597
+ the plan declared (an `await` `then` value `reactive`; a `catch` error → `plain`)
598
+ at most one. Names flow to the deref scope ONLY through `withBindings` over the
599
+ plan's bindings; the cell wiring of a `reactive` binding is arranged by
600
+ `reactiveBinding`. Nested `<script>`s are emitted in document order by
601
+ `generateChildren`; `withNestedScripts` puts their bindings in deref scope. */
582
602
  function branchThunk(
583
603
  children: TemplateNode[],
584
- valueParam?: string,
604
+ bindings: Binding[] = [],
585
605
  finallyChildren: TemplateNode[] = [],
586
- reactiveValue = false,
587
606
  ): string {
588
607
  const parentParam = nextVar('p')
589
- /* A reactive value (an `await` `then`) arrives as a `.value` cell the runtime can set
590
- in place, so the branch re-runs in place on a re-settle instead of being rebuilt. */
591
- const binding =
592
- reactiveValue && valueParam !== undefined ? reactiveBinding(valueParam) : undefined
593
- const param = binding?.param ?? valueParam
594
- const prefix = binding?.prefix ?? ''
595
- const localNames = binding?.localNames ?? []
608
+ /* A `reactive` binding arrives as a `.value` cell the runtime can set in place (the
609
+ branch re-runs in place on a re-settle); `reactiveBinding` renders its cell param +
610
+ per-leaf reader prefix. A `plain` binding is the author param verbatim — a real arrow
611
+ parameter `withBindings` registers under `plain`, read as the bare local. */
612
+ const reactive = bindings.find((binding) => binding.classification === 'reactive')
613
+ const wiring =
614
+ reactive === undefined ? undefined : reactiveBinding(reactive, nextVar, lowerStatement)
615
+ const plainBinding = bindings.find((binding) => binding.classification === 'plain')
616
+ const param = wiring?.param ?? plainBinding?.name
617
+ const prefix = wiring?.prefix ?? ''
596
618
  const head = param === undefined ? `(${parentParam})` : `(${parentParam}, ${param})`
597
619
  const body = withNestedScripts(children, () =>
598
- localNames.length === 0
599
- ? generateChildren(children, parentParam)
600
- : withLocalDerived(localNames, () => generateChildren(children, parentParam)),
620
+ withBindings(withShadow, bindings, buildBindingKind, () =>
621
+ generateChildren(children, parentParam),
622
+ ),
601
623
  )
602
624
  const finallyBody =
603
625
  finallyChildren.length > 0
@@ -630,11 +652,11 @@ export function generateBuild(
630
652
  before: string,
631
653
  ): string {
632
654
  const plan = tryPlan(node)
633
- const tryThunk = branchThunk(plan.guarded, undefined, plan.finallyChildren)
655
+ const tryThunk = branchThunk(plan.guarded, [], plan.finallyChildren)
634
656
  const catchThunk = plan.hasCatch
635
- ? branchThunk(plan.catchChildren, plan.catchAs, plan.finallyChildren)
657
+ ? branchThunk(plan.catchChildren, plan.catchBindings, plan.finallyChildren)
636
658
  : 'undefined'
637
- return `tryBlock(${parentVar}, nextBlockId(), ${tryThunk}, ${catchThunk}, ${before});\n`
659
+ return `$$tryBlock(${parentVar}, $$nextBlockId(), ${tryThunk}, ${catchThunk}, ${before});\n`
638
660
  }
639
661
 
640
662
  /* A conditional with an optional nested `<template else>` (a `case` child). Each
@@ -652,7 +674,7 @@ export function generateBuild(
652
674
  const thenThunk = branchThunk(plan.thenChildren)
653
675
  const elseThunk =
654
676
  plan.elseBranch === undefined ? 'undefined' : branchThunk(plan.elseBranch.children)
655
- return `when(${parentVar}, () => (${lowerExpression(node.condition)}), ${thenThunk}, ${elseThunk}, ${before});\n`
677
+ return `$$when(${parentVar}, () => (${lowerExpression(node.condition)}), ${thenThunk}, ${elseThunk}, ${before});\n`
656
678
  }
657
679
  /* if/elseif/else is a cond-chain — reuse `switchBlock` over a constant `true`
658
680
  subject with `Boolean`-coerced match thunks, so the first truthy branch wins
@@ -665,7 +687,7 @@ export function generateBuild(
665
687
  : `{ match: undefined, render: ${branchThunk(branch.children)} }`,
666
688
  ),
667
689
  ]
668
- return `switchBlock(${parentVar}, () => true, [${entries.join(', ')}], ${before});\n`
690
+ return `$$switchBlock(${parentVar}, () => true, [${entries.join(', ')}], ${before});\n`
669
691
  }
670
692
 
671
693
  /* A keyed each. Each row is a content RANGE (any content, tracked between the
@@ -675,6 +697,7 @@ export function generateBuild(
675
697
  parentVar: string,
676
698
  before: string,
677
699
  ): string {
700
+ const plan = eachPlan(node)
678
701
  const rowParam = nextVar('p')
679
702
  /* The item is a reactive `.value` cell so a re-key with a changed value updates the row
680
703
  in place (no rebuild). `keyOf` receives the RAW item; the key expression is lowered
@@ -683,34 +706,34 @@ export function generateBuild(
683
706
  plain `as` returns its name; a destructuring `as` binds a fresh param and returns
684
707
  THAT, not the pattern re-wrapped (`[i,crumb]` → `[i,crumb]` would allocate a fresh
685
708
  array per reconcile, so keys never match and every row rebuilds). An explicit `key`
686
- destructures the item via `node.as` to read its leaves. */
687
- const rawItemParam = isPlainIdentifier(node.as) ? node.as : nextVar('k')
688
- const keyParam = node.key === undefined ? rawItemParam : node.as
689
- const keyExpression = node.key === undefined ? rawItemParam : lowerExpression(node.key)
690
- const binding = reactiveBinding(node.as)
691
- /* `index="i"` binds the row's position as a third reactive cell param (the runtime
692
- always passes it). It is a plain identifier read as `i.value` so it enters the
693
- body's deref scope alongside the item's leaf names; an unnamed param when absent. */
694
- const indexParam = node.index === undefined ? '' : `, ${node.index}`
695
- const bodyLocalNames =
696
- node.index === undefined ? binding.localNames : [...binding.localNames, node.index]
709
+ destructures the item via `plan.as` to read its leaves. */
710
+ const rawItemParam = isPlainIdentifier(plan.as) ? plan.as : nextVar('k')
711
+ const keyParam = plan.key === undefined ? rawItemParam : plan.as
712
+ const keyExpression = plan.key === undefined ? rawItemParam : lowerExpression(plan.key)
713
+ /* The item is the row's `reactive` binding (its `.value` cell + per-leaf reader prefix
714
+ rendered by `reactiveBinding`); `index="i"` is a second reactive cell param, a plain
715
+ identifier read as `i.value` (no prefix). The names of BOTH enter the deref scope via
716
+ `withBindings` over `plan.bindings`, not a hand-built list. */
717
+ const itemWiring = reactiveBinding(plan.bindings[0] as Binding, nextVar, lowerStatement)
718
+ const indexParam = plan.index === undefined ? '' : `, ${plan.index}`
697
719
  /* The row body builds its children (a `<script>` declares per-row local signals,
698
720
  emitted in document order) into the row parent. A `<template catch>` child is
699
721
  consumed by the async-each, not the row — `generateChildren` skips it. */
700
- const rowBody = withNestedScripts(node.children, () =>
701
- withLocalDerived(bodyLocalNames, () => generateChildren(node.children, rowParam)),
722
+ const rowBody = withNestedScripts(plan.children, () =>
723
+ withBindings(withShadow, plan.bindings, buildBindingKind, () =>
724
+ generateChildren(plan.children, rowParam),
725
+ ),
702
726
  )
703
727
  /* `await` → the AsyncIterable runtime, drained row-by-row on the client, with an
704
728
  optional `<template catch>` branch rendered (after the streamed rows) when the
705
729
  iterator rejects. Absent → `undefined`, so the rejection surfaces instead. */
706
- const fn = node.async ? 'eachAsync' : 'each'
707
- const [catchBranch] = resolveBranches(node, 'catch')
708
- const catchArg = node.async
709
- ? `, ${catchBranch === undefined ? 'undefined' : branchThunk(catchBranch.children, catchBranch.as ?? '_error')}`
730
+ const fn = plan.async ? '$$eachAsync' : '$$each'
731
+ const catchArg = plan.async
732
+ ? `, ${plan.hasCatch ? branchThunk(plan.catchChildren, plan.catchBindings) : 'undefined'}`
710
733
  : ''
711
734
  return (
712
- `${fn}(${parentVar}, () => (${lowerExpression(node.items)}), ` +
713
- `(${keyParam}) => (${keyExpression}), (${rowParam}, ${binding.param}${indexParam}) => {\n${binding.prefix}${rowBody}}${catchArg}, ${before});\n`
735
+ `${fn}(${parentVar}, () => (${lowerExpression(plan.items)}), ` +
736
+ `(${keyParam}) => (${keyExpression}), (${rowParam}, ${itemWiring.param}${indexParam}) => {\n${itemWiring.prefix}${rowBody}}${catchArg}, ${before});\n`
714
737
  )
715
738
  }
716
739