@barefootjs/jsx 0.29.0 → 0.30.2

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 (90) hide show
  1. package/dist/adapters/child-scope.d.ts +17 -0
  2. package/dist/adapters/child-scope.d.ts.map +1 -0
  3. package/dist/adapters/loop-bound-names.d.ts.map +1 -1
  4. package/dist/analyzer-context.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +990 -367
  9. package/dist/ir-to-client-js/collect-elements.d.ts +5 -25
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +66 -2
  12. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +92 -0
  15. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -0
  16. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +134 -0
  17. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -0
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +46 -11
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
  21. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/html-template.d.ts +2 -3
  25. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
  27. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/reactivity.d.ts +1 -10
  29. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/types.d.ts +12 -0
  31. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  32. package/dist/jsx-to-ir.d.ts.map +1 -1
  33. package/dist/prop-rewrite.d.ts +24 -7
  34. package/dist/prop-rewrite.d.ts.map +1 -1
  35. package/dist/rich-type-evidence.d.ts.map +1 -1
  36. package/dist/ssr-defaults.d.ts.map +1 -1
  37. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  38. package/dist/types.d.ts +78 -0
  39. package/dist/types.d.ts.map +1 -1
  40. package/package.json +2 -2
  41. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +6 -24
  42. package/src/__tests__/branch-loop-plain.test.ts +28 -45
  43. package/src/__tests__/csr-substitution-safety-divergence.test.ts +9 -3
  44. package/src/__tests__/csr-template-scope-soundness.test.ts +167 -0
  45. package/src/__tests__/early-return-branch-switch.test.ts +96 -0
  46. package/src/__tests__/form-control-value-ssr.test.ts +102 -0
  47. package/src/__tests__/lazy-conditional.test.ts +190 -0
  48. package/src/__tests__/lazy-preamble.test.ts +297 -0
  49. package/src/__tests__/lazy-row-eligibility.test.ts +97 -28
  50. package/src/__tests__/loop-item-root-scope.test.ts +213 -0
  51. package/src/__tests__/map-multi-return-body.test.ts +30 -3
  52. package/src/__tests__/nested-loop-conditional.test.ts +27 -6
  53. package/src/__tests__/preamble-attr-reactivity.test.ts +217 -0
  54. package/src/__tests__/preamble-declarations.test.ts +207 -0
  55. package/src/__tests__/ssr-defaults.test.ts +44 -0
  56. package/src/adapters/child-scope.ts +22 -0
  57. package/src/adapters/loop-bound-names.ts +6 -0
  58. package/src/analyzer-context.ts +43 -0
  59. package/src/analyzer.ts +33 -3
  60. package/src/index.ts +1 -0
  61. package/src/ir-to-client-js/collect-elements.ts +51 -10
  62. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +1 -1
  63. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -2
  64. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +3 -3
  65. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -2
  66. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +147 -5
  67. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -2
  68. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
  69. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +151 -0
  70. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +287 -0
  71. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +85 -22
  72. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  73. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +133 -35
  74. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +24 -4
  75. package/src/ir-to-client-js/csr-substitute.ts +6 -1
  76. package/src/ir-to-client-js/emit-registration.ts +1 -1
  77. package/src/ir-to-client-js/html-template.ts +51 -40
  78. package/src/ir-to-client-js/index.ts +1 -1
  79. package/src/ir-to-client-js/plan/build-declaration-emit.ts +2 -0
  80. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +1 -1
  81. package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
  82. package/src/ir-to-client-js/reactivity.ts +25 -0
  83. package/src/ir-to-client-js/stringify/declaration-emit.ts +9 -4
  84. package/src/ir-to-client-js/types.ts +12 -0
  85. package/src/jsx-to-ir.ts +409 -24
  86. package/src/prop-rewrite.ts +149 -19
  87. package/src/rich-type-evidence.ts +6 -7
  88. package/src/ssr-defaults.ts +7 -2
  89. package/src/to-locale-date-lowering.ts +8 -2
  90. package/src/types.ts +80 -0
package/src/jsx-to-ir.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  type MapCallbackPreamble,
30
30
  type PreambleSegment,
31
31
  type PreambleRegionSource,
32
+ type PreambleValueDeclaration,
32
33
  tsxSourceText,
33
34
  type SourceLocation,
34
35
  type TypeInfo,
@@ -945,6 +946,36 @@ function buildIRRoot(analyzer: AnalyzerContext): IRNode | null {
945
946
  // build an if-statement chain instead of a single node
946
947
  if (analyzer.conditionalReturns.length > 0) {
947
948
  const ctx = createTransformContext(analyzer)
949
+ // #2463: a chain whose conditions are ALL reactive (signal/memo reads)
950
+ // is semantically the root ternary, and the IRIfStatement contract
951
+ // ("client JS handles all branches and switches at runtime") demands
952
+ // the same insert() plan the ternary gets. Lower it through the
953
+ // conditional path + synthetic scope wrapper. Gated to chains with no
954
+ // branch-local scope variables — those carry #1409/#1414 machinery
955
+ // (branch consts, branch-scoped signals) that the conditional plan
956
+ // has no seat for, so they stay on the IRIfStatement path; a static
957
+ // or prop-conditioned chain stays there too (its branches cannot
958
+ // change after render without a re-render, so no runtime switch is
959
+ // owed). The eligibility check is analysis-only (no transforms run),
960
+ // so falling through to the statement path never skews slot ids.
961
+ // `exprCallsReactiveGetters` (signal/memo getter CALLS), not
962
+ // `isReactiveExpression`: bare destructured-prop reads also count as
963
+ // "reactive" to the latter, and prop-conditioned chains must stay on
964
+ // the IRIfStatement path — their branches only change on re-render,
965
+ // and converting them would churn every #1401-family emission.
966
+ const allReactiveNoLocals =
967
+ analyzer.jsxReturn != null &&
968
+ analyzer.conditionalReturns.every(cr =>
969
+ cr.scopeVariables.length === 0 &&
970
+ exprCallsReactiveGetters(cr.condition, ctx),
971
+ )
972
+ if (allReactiveNoLocals) {
973
+ const chain = buildIfStatementChain(analyzer, ctx, { asConditional: true })
974
+ if (!chain) return null
975
+ // Degenerate transforms can fall a link back to statement mode; the
976
+ // wrapper is owed only to a conditional root (the ternary shape).
977
+ return chain.type === 'conditional' ? wrapInScopeElement(chain) : chain
978
+ }
948
979
  return buildIfStatementChain(analyzer, ctx)
949
980
  }
950
981
 
@@ -1231,6 +1262,84 @@ function transformJsxElement(
1231
1262
  return transformHtmlElement(node, ctx, tagName)
1232
1263
  }
1233
1264
 
1265
+ /**
1266
+ * SSR projection of a controlled form-control `value` (#2464 / #2465).
1267
+ *
1268
+ * `value` is not an attribute on `<textarea>` (its initial value is its
1269
+ * element content) and not an attribute on `<select>` (the selection is
1270
+ * `selected` on the matching `<option>`). Emitting it verbatim ships
1271
+ * invalid HTML that browsers ignore, so no-JS and pre-hydration users see
1272
+ * an empty textarea / the wrong option until the client `.value` effect
1273
+ * snaps it.
1274
+ *
1275
+ * Lowering (shared IR, so every adapter inherits it):
1276
+ * - the `value` attr is marked `clientOnly` — SSR skips it, the existing
1277
+ * hydrate-time `.value` property binding is unchanged;
1278
+ * - `<textarea>` with no children gains a NON-reactive expression child
1279
+ * (the initial value as element content — updates keep flowing through
1280
+ * the `.value` effect, deliberately not a live text slot);
1281
+ * - `<select>` distributes `selected={(value) === 'opt'}` onto each
1282
+ * statically-valued `<option>` (incl. under `<optgroup>`/fragments) —
1283
+ * the exact per-option comparison shape the `select-option-selected`
1284
+ * fixture already proves across every adapter. Options rendered by a
1285
+ * dynamic loop can't be statically distributed and are left to the
1286
+ * hydrate-time effect (tracked with #2466 on the #2464 thread).
1287
+ */
1288
+ function lowerFormControlValueSsr(
1289
+ tagName: string,
1290
+ attrs: IRAttribute[],
1291
+ children: IRNode[],
1292
+ ): void {
1293
+ if (tagName !== 'textarea' && tagName !== 'select') return
1294
+ const valueAttr = attrs.find(a => a.name === 'value')
1295
+ if (!valueAttr || valueAttr.clientOnly || valueAttr.value.kind !== 'expression') return
1296
+ const { expr, templateExpr } = valueAttr.value
1297
+
1298
+ valueAttr.clientOnly = true
1299
+
1300
+ if (tagName === 'textarea') {
1301
+ if (children.length > 0) return
1302
+ children.push({
1303
+ type: 'expression',
1304
+ expr,
1305
+ // The client-side registration template interpolates slotless
1306
+ // expression children RAW (no text-slot `escapeText` wrapper), so a
1307
+ // value containing `</textarea>` would break out of the element on
1308
+ // CSR mount. Escape in the client-only template variant; `expr`
1309
+ // stays clean for the SSR adapters, whose template engines (and
1310
+ // hono/jsx) already escape text children natively.
1311
+ templateExpr: `escapeText(${templateExpr ?? expr})`,
1312
+ typeInfo: null,
1313
+ reactive: false,
1314
+ slotId: null,
1315
+ loc: valueAttr.loc,
1316
+ origin: { phase: 'ssr', scope: 'template', effect: 'pure' },
1317
+ })
1318
+ return
1319
+ }
1320
+
1321
+ const selectedFor = (optValue: string): AttrValue =>
1322
+ AttrValueOf.expression(
1323
+ `(${expr}) === ${JSON.stringify(optValue)}`,
1324
+ templateExpr !== undefined
1325
+ ? { templateExpr: `(${templateExpr}) === ${JSON.stringify(optValue)}` }
1326
+ : undefined,
1327
+ )
1328
+ const distribute = (nodes: IRNode[]): void => {
1329
+ for (const n of nodes) {
1330
+ if (n.type === 'element' && n.tag === 'option') {
1331
+ if (n.attrs.some(a => a.name === 'selected')) continue
1332
+ const optValue = n.attrs.find(a => a.name === 'value')
1333
+ if (!optValue || optValue.value.kind !== 'literal') continue
1334
+ n.attrs.push({ name: 'selected', value: selectedFor(optValue.value.value), loc: n.loc })
1335
+ } else if (n.type === 'fragment' || (n.type === 'element' && n.tag === 'optgroup')) {
1336
+ distribute(n.children)
1337
+ }
1338
+ }
1339
+ }
1340
+ distribute(children)
1341
+ }
1342
+
1234
1343
  function transformHtmlElement(
1235
1344
  node: ts.JsxElement,
1236
1345
  ctx: TransformContext,
@@ -1246,6 +1355,7 @@ function transformHtmlElement(
1246
1355
  ctx.isRoot = false
1247
1356
 
1248
1357
  const children = transformChildren(node.children, ctx)
1358
+ lowerFormControlValueSsr(tagName, attrs, children)
1249
1359
 
1250
1360
  // Determine if this element needs a slot ID
1251
1361
  // Elements need slotIds if they have: events, dynamic children, reactive attributes, or refs
@@ -1301,6 +1411,8 @@ function transformSelfClosingElement(
1301
1411
  }
1302
1412
 
1303
1413
  const { attrs, events, ref } = processAttributes(node.attributes, ctx)
1414
+ const selfClosingChildren: IRNode[] = []
1415
+ lowerFormControlValueSsr(tagName, attrs, selfClosingChildren)
1304
1416
 
1305
1417
  // Elements need slotIds if they have events, reactive attributes, or refs
1306
1418
  const needsSlot = events.length > 0 || hasReactiveAttributes(attrs, ctx) || ref !== null
@@ -1315,7 +1427,7 @@ function transformSelfClosingElement(
1315
1427
  attrs,
1316
1428
  events,
1317
1429
  ref,
1318
- children: [],
1430
+ children: selfClosingChildren,
1319
1431
  slotId,
1320
1432
  needsScope,
1321
1433
  loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
@@ -3794,6 +3906,27 @@ function branchHasNoElement(node: IRNode): boolean {
3794
3906
  * element ternaries (which always render exactly one element and stay on the
3795
3907
  * legacy `mapArray` path).
3796
3908
  */
3909
+ /**
3910
+ * Tag every component that is a loop item root — a DIRECT member of the
3911
+ * loop body, unwrapping through nested conditionals (`cond ? <A/> : <B/>`,
3912
+ * `cond && <A/>`) but NOT through elements or fragments. Mirrors the Hono
3913
+ * reference adapter's `renderConditional` ctx-forwarding exactly:
3914
+ * `isLoopItemRoot` threads through nested conditional branches (a bare
3915
+ * ternary/logical loop body, arbitrarily nested), but `renderElement` /
3916
+ * `renderFragment` never forward it to their children — a component nested
3917
+ * inside a plain element or fragment is never a loop item root, even if
3918
+ * that element/fragment is itself the loop's row. See #2444.
3919
+ */
3920
+ function tagLoopItemRootComponents(nodes: readonly IRNode[]): void {
3921
+ for (const node of nodes) {
3922
+ if (node.type === 'component') {
3923
+ node.loopItemRoot = true
3924
+ } else if (node.type === 'conditional') {
3925
+ tagLoopItemRootComponents([node.whenTrue, node.whenFalse])
3926
+ }
3927
+ }
3928
+ }
3929
+
3797
3930
  function loopBodyItemConditional(children: IRNode[]): IRConditional | null {
3798
3931
  const real = children.filter(
3799
3932
  (c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
@@ -4229,20 +4362,29 @@ function transformMapCall(
4229
4362
  // verbatim.
4230
4363
  preamble = preambleFromValueStatements(pre, ctx)
4231
4364
 
4232
- // A DSL adapter can't carry a loop-local `const` into a conditional
4233
- // branch template, so it would render the branches with the local
4234
- // undefined (silent divergence). Refuse instead adapter-gated like
4235
- // the filter/sort sites: a JS-runtime target folds and runs it, a DSL
4236
- // target errors with the /* @client */ escape (which renders the map
4237
- // client-only, where the browser runs the preamble). Stage 2 of
4238
- // spec/callback-fidelity.md.
4239
- if (!isClientOnly && !(ctx.analyzer.acceptsCallbackBody?.('map') ?? false)) {
4365
+ // Stage 2 of spec/callback-fidelity.md, narrowed by #2447. This used
4366
+ // to refuse EVERY branch preamble on a DSL target, on the premise
4367
+ // that a loop-local can't be carried into a conditional branch
4368
+ // template. That premise stopped holding once `declarations` lower
4369
+ // to a per-row local: the fold emits the conditional INSIDE the loop
4370
+ // body, so a local declared ahead of it is in scope in both arms —
4371
+ // `{{$label := bf_upper .Kind}}{{if .On}}…{{$label}}…{{else}}…`. What
4372
+ // remains true is the original hazard for a preamble that CANNOT be
4373
+ // declared: nothing lowers, both branches read an unassigned name.
4374
+ // So the refusal now keys off the same condition as the single-return
4375
+ // gate below, not off the presence of branches.
4376
+ if (
4377
+ !preamble.declarations &&
4378
+ !isClientOnly &&
4379
+ !(ctx.analyzer.acceptsCallbackBody?.('map') ?? false)
4380
+ ) {
4240
4381
  ctx.analyzer.errors.push(
4241
4382
  createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN, loc, {
4242
4383
  message:
4243
- 'A .map() callback body with a `const`/`let` preamble before its ' +
4244
- 'branches cannot be lowered to a template: the loop-local binding ' +
4245
- 'cannot be carried into a conditional branch on this backend.',
4384
+ 'A .map() callback body with a preamble before its branches cannot be ' +
4385
+ 'lowered to a template: the preamble is not a sequence of value ' +
4386
+ 'declarations, so this backend has no per-row local to carry into the ' +
4387
+ 'branches.',
4246
4388
  suggestion: {
4247
4389
  message: 'Add /* @client */ to evaluate this expression on the client only',
4248
4390
  },
@@ -4381,6 +4523,37 @@ function transformMapCall(
4381
4523
  }
4382
4524
  if (valueStmts.length > 0) {
4383
4525
  preamble = preambleFromValueStatements(valueStmts, ctx)
4526
+
4527
+ // #2447. A DSL adapter cannot execute `preamble.segments` (JS
4528
+ // text), so it lowers `declarations` — one per-row local each —
4529
+ // and the row markup reads them as locals. When the preamble
4530
+ // isn't fully declarable there is nothing to lower, and the
4531
+ // adapter used to emit the row anyway, reading names it never
4532
+ // assigned: the class rendered empty, on every DSL backend, with
4533
+ // no diagnostic. Refuse loudly instead, with the same
4534
+ // `/* @client */` escape as the branch-preamble, array-builder
4535
+ // and flatMap gates above.
4536
+ if (
4537
+ !preamble.declarations &&
4538
+ !isClientOnly &&
4539
+ !(ctx.analyzer.acceptsCallbackBody?.('map') ?? false)
4540
+ ) {
4541
+ ctx.analyzer.errors.push(
4542
+ createError(
4543
+ ErrorCodes.UNSUPPORTED_JSX_PATTERN,
4544
+ getSourceLocation(body, ctx.sourceFile, ctx.filePath),
4545
+ {
4546
+ message:
4547
+ 'A .map() callback preamble that is not a sequence of value ' +
4548
+ 'declarations cannot be lowered to a template: this backend can ' +
4549
+ 'declare a per-row local, but cannot run arbitrary statements per row.',
4550
+ suggestion: {
4551
+ message: 'Add /* @client */ to evaluate this expression on the client only',
4552
+ },
4553
+ },
4554
+ )
4555
+ )
4556
+ }
4384
4557
  }
4385
4558
  }
4386
4559
  }
@@ -4605,6 +4778,15 @@ function transformMapCall(
4605
4778
  preamble = undefined
4606
4779
  }
4607
4780
 
4781
+ // A component that is a DIRECT member of the loop body (the row root, or
4782
+ // one of several flatMap-sibling roots) owns its own per-row identity and
4783
+ // gets a freshly randomized `bf-s` rather than deriving one from parent
4784
+ // scope + slot. Tag it here — the single place that knows which nodes are
4785
+ // direct loop-body members — so every backend can consult
4786
+ // `derivesScopeFromSlot()` instead of re-deriving this fact from a mutable
4787
+ // "am I inside a loop" flag.
4788
+ tagLoopItemRootComponents(children)
4789
+
4608
4790
  // Extract childComponent info if the loop body is a single component
4609
4791
  // This enables createComponent-based rendering with proper prop passing
4610
4792
  let childComponent: IRLoopChildComponent | undefined
@@ -4675,6 +4857,19 @@ function transformMapCall(
4675
4857
  ? collectPreambleRegions(children, new Set(preamble.declaredNames), ctx)
4676
4858
  : undefined
4677
4859
 
4860
+ // #2447 follow-up — the ATTRIBUTE twin of the regions above. `class={cls}`
4861
+ // where the preamble declares `cls` froze at its row-construction value for
4862
+ // the same reason `{cells}` did, and for one extra reason on top: an
4863
+ // attribute only gets wired at all if its element carries a slot id, and
4864
+ // `hasReactiveAttributes` (which decides that at element-build time, before
4865
+ // any loop preamble is known) scores a bare local as non-reactive. So the
4866
+ // slot has to be granted HERE, in the same after-the-fact pass the regions
4867
+ // use — after which `collectLoopChildReactiveAttrs` classifies the attr and
4868
+ // the row effect re-runs the preamble ahead of the write.
4869
+ if (preamble && !isStaticArray) {
4870
+ markPreambleAttrSlots(children, new Set(preamble.declaredNames), ctx)
4871
+ }
4872
+
4678
4873
  // Collect nested components for both static and dynamic arrays.
4679
4874
  // Static arrays: needed for initChild hydration.
4680
4875
  // Dynamic arrays with native root + component descendants: enables
@@ -5061,6 +5256,72 @@ function collectPreambleRegions(
5061
5256
  return regions
5062
5257
  }
5063
5258
 
5259
+ /**
5260
+ * Grant a slot id to every loop-body element carrying an attribute that reads
5261
+ * a `.map()` preamble local (#2447 follow-up).
5262
+ *
5263
+ * Only the slot id is decided here; whether the attribute is *wired* is the
5264
+ * client-JS classifier's call (`collectLoopChildReactiveAttrs`, which takes
5265
+ * the same `declaredNames` set). Splitting it that way keeps this pass free of
5266
+ * reactivity policy — it removes the one obstacle the classifier cannot remove
5267
+ * for itself, since `hasReactiveAttributes` already ran when the element was
5268
+ * built and a slot id cannot be granted from the client-JS pass (the SSR
5269
+ * template is rendered from this same IR and would not carry the `bf` marker).
5270
+ *
5271
+ * Mirrors `collectPreambleRegions`' traversal exactly, conditional arms
5272
+ * included — an arm element gets its slot id here too, which costs nothing
5273
+ * when the arm's own wiring declines to use it.
5274
+ */
5275
+ function markPreambleAttrSlots(
5276
+ nodes: IRNode[],
5277
+ declared: ReadonlySet<string>,
5278
+ ctx: TransformContext,
5279
+ ): void {
5280
+ const readsDeclared = (attr: IRAttribute): boolean => {
5281
+ if (attr.name === 'key') return false
5282
+ const value = attr.value
5283
+ if (value.kind !== 'expression' && value.kind !== 'template') return false
5284
+ const refs = attr.freeIdentifiers ?? extractFreeIdentifiersFromText(attrValueText(value))
5285
+ for (const r of refs) if (declared.has(r)) return true
5286
+ return false
5287
+ }
5288
+ const visit = (list: IRNode[]): void => {
5289
+ for (const node of list) {
5290
+ switch (node.type) {
5291
+ case 'element':
5292
+ if (!node.slotId && node.attrs.some(readsDeclared)) node.slotId = generateSlotId(ctx)
5293
+ visit(node.children)
5294
+ break
5295
+ case 'fragment':
5296
+ visit(node.children)
5297
+ break
5298
+ case 'conditional':
5299
+ visit([node.whenTrue, ...(node.whenFalse ? [node.whenFalse] : [])])
5300
+ break
5301
+ }
5302
+ }
5303
+ }
5304
+ visit(nodes)
5305
+ }
5306
+
5307
+ /**
5308
+ * Expression-bearing source text of an attribute value, for the
5309
+ * free-identifier scan. Only used as a FALLBACK — an attribute normally
5310
+ * carries `freeIdentifiers` from its own AST walk, and this reconstruction
5311
+ * exists for the few IR producers that don't populate it. Static string parts
5312
+ * are dropped so a literal word can never be mistaken for an identifier.
5313
+ */
5314
+ function attrValueText(value: AttrValue): string {
5315
+ if (value.kind === 'expression') return value.expr
5316
+ if (value.kind !== 'template') return ''
5317
+ const out: string[] = []
5318
+ for (const p of value.parts) {
5319
+ if (p.type === 'ternary') out.push(p.condition, p.whenTrue, p.whenFalse)
5320
+ else if (p.type === 'lookup') out.push(p.key)
5321
+ }
5322
+ return out.join(' ')
5323
+ }
5324
+
5064
5325
  /**
5065
5326
  * Collect the names a binding introduces, recursing through object/array
5066
5327
  * destructuring patterns (`const { id: k } = r`, `const [k, ...rest] = xs`) so
@@ -5118,9 +5379,50 @@ function preambleFromValueStatements(
5118
5379
  declaredNames: [...declared],
5119
5380
  // Value-only preambles accumulate no JSX, so no child needs the array join.
5120
5381
  builderNames: [],
5382
+ declarations: neutralPreambleDeclarations(statements, ctx) ?? undefined,
5121
5383
  }
5122
5384
  }
5123
5385
 
5386
+ /**
5387
+ * The DSL SSR half of a value preamble (#2447): every statement as a neutral
5388
+ * {@link PreambleValueDeclaration}, or `null` if any statement is not one.
5389
+ *
5390
+ * All-or-nothing — see `MapCallbackPreamble.declarations`. `null` is what the
5391
+ * Phase-1 gate turns into a loud refusal, so every rejection here must be a
5392
+ * shape a template language genuinely cannot express per row, not a shape this
5393
+ * function merely hasn't got round to.
5394
+ */
5395
+ function neutralPreambleDeclarations(
5396
+ statements: readonly ts.Statement[],
5397
+ ctx: TransformContext,
5398
+ ): PreambleValueDeclaration[] | null {
5399
+ const out: PreambleValueDeclaration[] = []
5400
+ for (const stmt of statements) {
5401
+ // A non-declaration statement (assignment, `for`, a call for its side
5402
+ // effects) has no per-row template form at all.
5403
+ if (!ts.isVariableStatement(stmt)) return null
5404
+ for (const decl of stmt.declarationList.declarations) {
5405
+ // A destructuring binding declares several names from one initializer;
5406
+ // no adapter has a single per-row local form for that.
5407
+ if (!ts.isIdentifier(decl.name)) return null
5408
+ if (!decl.initializer) return null
5409
+ const valueParsed = tsNodeToParsedExpr(decl.initializer)
5410
+ // The same subset gate every other DSL-lowered expression passes
5411
+ // through. An adapter that additionally cannot emit a supported shape
5412
+ // (Go has no array-literal form, say) still records its own BF101 from
5413
+ // its own emitter — this only decides whether the shape is expressible
5414
+ // in principle.
5415
+ if (!isSupported(valueParsed).supported) return null
5416
+ out.push({
5417
+ name: decl.name.text,
5418
+ valueParsed,
5419
+ raw: decl.initializer.getText(ctx.sourceFile),
5420
+ })
5421
+ }
5422
+ }
5423
+ return out.length > 0 ? out : null
5424
+ }
5425
+
5124
5426
  /** Drop the trailing separator space from the final js segment. */
5125
5427
  function trimPreambleSegments(segments: PreambleSegment[]): PreambleSegment[] {
5126
5428
  const last = segments[segments.length - 1]
@@ -5772,6 +6074,11 @@ function tryResolveTemplateSpanFromConst(
5772
6074
  ): IRTemplatePart[] | null {
5773
6075
  // ${IDENT}
5774
6076
  if (ts.isIdentifier(expr)) {
6077
+ // #2222-family: inside a loop callback the name may be the loop's
6078
+ // item/index binding shadowing a same-named const — resolving the
6079
+ // const would bake the outer value into every row. Fall back to
6080
+ // the bare-expression path, which sees the loop binding.
6081
+ if (ctx.loopParams.has(expr.text)) return null
5775
6082
  const constInfo = findLocalConst(expr.text, ctx.analyzer)
5776
6083
  if (!constInfo) return null
5777
6084
  const ast = parseConstInitializer(constInfo)
@@ -5785,6 +6092,10 @@ function tryResolveTemplateSpanFromConst(
5785
6092
  // ${IDENT[KEY]}
5786
6093
  if (ts.isElementAccessExpression(expr)) {
5787
6094
  if (!ts.isIdentifier(expr.expression)) return null
6095
+ // Same loop-shadowing guard as the ${IDENT} arm: `tone[k]` inside
6096
+ // `items.map((tone) => …)` must read the row's `tone`, not a
6097
+ // same-named module/component record const.
6098
+ if (ctx.loopParams.has(expr.expression.text)) return null
5788
6099
  const constInfo = findLocalConst(expr.expression.text, ctx.analyzer)
5789
6100
  if (!constInfo) return null
5790
6101
  const ast = parseConstInitializer(constInfo)
@@ -5968,6 +6279,30 @@ function tryResolveIdentifierAsTemplateLiteral(
5968
6279
  return [{ type: 'string', value: ast.text }]
5969
6280
  }
5970
6281
 
6282
+ // `const cls = variantClasses[variant]` (#2477): the initializer IS the
6283
+ // #2300 element-access shape, just not wrapped in a template literal.
6284
+ // Route it through the same span resolver so the one-hop const
6285
+ // (`className={cls}`) lowers to the identical `lookup` part as the
6286
+ // inline form (`className={variantClasses[variant]}`, the #2300 arm in
6287
+ // `getAttributeValue`) and the template-literal hop
6288
+ // (`const cls = \`${variantClasses[variant]}\``, the arm below) — both
6289
+ // of which every adapter already renders. Without this arm the attr
6290
+ // fell through to a bare-expression `cls`, which JSX-runtime SSR
6291
+ // (Hono) evaluates fine but every template backend emits as a
6292
+ // reference to a variable the template never defines, rendering
6293
+ // `class=""` with zero diagnostics. Same dynamic-key guard as the
6294
+ // inline arm: a static-key index (`paths['icon']`) stays on the
6295
+ // bare-expression path (jsx-to-ir regression pin — adapters resolve a
6296
+ // constant-key index already, and it must remain a plain `expression`
6297
+ // attr, not a single-case `lookup`).
6298
+ if (
6299
+ ts.isElementAccessExpression(ast) &&
6300
+ !ts.isStringLiteralLike(ast.argumentExpression) &&
6301
+ !ts.isNumericLiteral(ast.argumentExpression)
6302
+ ) {
6303
+ return tryResolveTemplateSpanFromConst(ast, ctx)
6304
+ }
6305
+
5971
6306
  if (!ts.isTemplateExpression(ast)) return null
5972
6307
 
5973
6308
  // We require at least one structurally-meaningful span (a string
@@ -6433,8 +6768,20 @@ function processComponentProps(
6433
6768
  // (Hono) keep using `expr`. Boolean-attr is also promoted to
6434
6769
  // shorthand (`<X disabled />` → `disabled={true}`).
6435
6770
  if (value.kind === 'template') {
6436
- value = AttrValueOf.expression(templatePartsToJsString(value.parts), {
6771
+ // Collapse using the raw part values, but also carry the
6772
+ // template-variant projection (parts' `templateValue` /
6773
+ // `templateCondition` / `templateKey`, i.e. destructured-prop refs
6774
+ // rewritten to `_p.X`) as `templateExpr`. Without it, the
6775
+ // `templateExpr` derivation below walks the ORIGINAL initializer AST
6776
+ // — for a const-resolved template (`className={classes}`) that AST is
6777
+ // the bare `classes` identifier with no prop refs, so the rewrite
6778
+ // no-ops and the module-scope registration template leaks bare
6779
+ // destructured props into `renderChild(...)` (#2468).
6780
+ const collapsed = templatePartsToJsString(value.parts)
6781
+ const collapsedTemplate = templatePartsToJsString(value.parts, { useTemplate: true })
6782
+ value = AttrValueOf.expression(collapsed, {
6437
6783
  parts: value.parts,
6784
+ ...(collapsedTemplate !== collapsed && { templateExpr: collapsedTemplate }),
6438
6785
  })
6439
6786
  } else if (value.kind === 'boolean-attr') {
6440
6787
  value = AttrValueOf.booleanShorthand()
@@ -6479,18 +6826,20 @@ function processComponentProps(
6479
6826
  * needs to be collapsed into an `expression` for component-prop forwarding —
6480
6827
  * component props are runtime JS values, not HTML attribute bodies.
6481
6828
  */
6482
- function templatePartsToJsString(parts: readonly IRTemplatePart[]): string {
6829
+ function templatePartsToJsString(parts: readonly IRTemplatePart[], opts?: { useTemplate?: boolean }): string {
6483
6830
  let result = '`'
6484
6831
  for (const part of parts) {
6485
6832
  if (part.type === 'string') {
6486
- result += part.value
6833
+ result += (opts?.useTemplate && part.templateValue) ? part.templateValue : part.value
6487
6834
  } else if (part.type === 'ternary') {
6488
- result += `\${${part.condition} ? '${part.whenTrue}' : '${part.whenFalse}'}`
6835
+ const cond = (opts?.useTemplate && part.templateCondition) ? part.templateCondition : part.condition
6836
+ result += `\${${cond} ? '${part.whenTrue}' : '${part.whenFalse}'}`
6489
6837
  } else if (part.type === 'lookup') {
6838
+ const key = (opts?.useTemplate && part.templateKey) ? part.templateKey : part.key
6490
6839
  const obj = '{' + Object.entries(part.cases).map(
6491
6840
  ([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`
6492
6841
  ).join(', ') + '}'
6493
- result += `\${(${obj})[${part.key}]}`
6842
+ result += `\${(${obj})[${key}]}`
6494
6843
  }
6495
6844
  }
6496
6845
  result += '`'
@@ -6875,14 +7224,21 @@ function replaceBranchLocalRefs(
6875
7224
  */
6876
7225
  function buildIfStatementChain(
6877
7226
  analyzer: AnalyzerContext,
6878
- ctx: TransformContext
6879
- ): IRIfStatement {
7227
+ ctx: TransformContext,
7228
+ opts?: { asConditional?: boolean }
7229
+ ): IRIfStatement | IRConditional | null {
6880
7230
  const conditionalReturns = analyzer.conditionalReturns
7231
+ // #2463: conditional mode builds the same IRConditional links the root
7232
+ // ternary produces (reactive insert() plan). The caller wraps the chain
7233
+ // in the synthetic display:contents scope element, so branches must NOT
7234
+ // claim the root scope themselves — mirror the ternary path's
7235
+ // `ctx.isRoot = false`.
7236
+ const asConditional = opts?.asConditional === true
6881
7237
 
6882
7238
  // Start with the final return (else case) if it exists
6883
7239
  let alternate: IRNode | null = null
6884
7240
  if (analyzer.jsxReturn) {
6885
- ctx.isRoot = true
7241
+ ctx.isRoot = !asConditional
6886
7242
  alternate = transformNode(analyzer.jsxReturn, ctx)
6887
7243
  }
6888
7244
 
@@ -7010,8 +7366,9 @@ function buildIfStatementChain(
7010
7366
  }
7011
7367
 
7012
7368
  // Transform the JSX return in the then branch
7013
- // Reset isRoot so each branch gets needsScope=true
7014
- ctx.isRoot = true
7369
+ // Reset isRoot so each branch gets needsScope=true (statement mode);
7370
+ // in conditional mode (#2463) the synthetic wrapper carries the scope.
7371
+ ctx.isRoot = !asConditional
7015
7372
  let consequent: IRNode | null
7016
7373
  try {
7017
7374
  consequent = transformNode(condReturn.jsxReturn, ctx)
@@ -7062,6 +7419,33 @@ function buildIfStatementChain(
7062
7419
  analyzer.filePath
7063
7420
  )
7064
7421
 
7422
+ if (asConditional && alternate) {
7423
+ // #2463: emit the same node shape `transformConditional` builds for
7424
+ // the root ternary, so the downstream insert() plan, slot wrapping,
7425
+ // and template substitution all apply unchanged. Eligibility
7426
+ // (checked by the caller) guarantees a reactive condition and no
7427
+ // branch scopeVariables.
7428
+ const conditional: IRConditional = {
7429
+ type: 'conditional',
7430
+ condition,
7431
+ templateCondition,
7432
+ conditionType: null,
7433
+ reactive: true,
7434
+ whenTrue: consequent,
7435
+ whenFalse: alternate,
7436
+ slotId: generateSlotId(ctx),
7437
+ loc,
7438
+ origin: {
7439
+ phase: 'tick',
7440
+ scope: 'template',
7441
+ effect: 'pure',
7442
+ freeRefs: resolveFreeRefs(condReturn.condition, makeBindingEnv(ctx)),
7443
+ },
7444
+ }
7445
+ alternate = conditional
7446
+ continue
7447
+ }
7448
+
7065
7449
  // Create the if statement node
7066
7450
  const ifStmt: IRIfStatement = {
7067
7451
  type: 'if-statement',
@@ -7077,6 +7461,7 @@ function buildIfStatementChain(
7077
7461
  alternate = ifStmt
7078
7462
  }
7079
7463
 
7080
- // The final result should be an IRIfStatement (the first if in the chain)
7081
- return alternate as IRIfStatement
7464
+ // The final result: the first link of the chain (an IRIfStatement in
7465
+ // statement mode, an IRConditional in #2463's conditional mode).
7466
+ return alternate as IRIfStatement | IRConditional | null
7082
7467
  }