@barefootjs/jsx 0.29.0 → 0.30.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 (87) 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 +876 -352
  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/rich-type-evidence.d.ts.map +1 -1
  34. package/dist/ssr-defaults.d.ts.map +1 -1
  35. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  36. package/dist/types.d.ts +78 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +2 -2
  39. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +6 -24
  40. package/src/__tests__/branch-loop-plain.test.ts +28 -45
  41. package/src/__tests__/csr-substitution-safety-divergence.test.ts +9 -3
  42. package/src/__tests__/csr-template-scope-soundness.test.ts +96 -0
  43. package/src/__tests__/early-return-branch-switch.test.ts +96 -0
  44. package/src/__tests__/form-control-value-ssr.test.ts +102 -0
  45. package/src/__tests__/lazy-conditional.test.ts +190 -0
  46. package/src/__tests__/lazy-preamble.test.ts +297 -0
  47. package/src/__tests__/lazy-row-eligibility.test.ts +97 -28
  48. package/src/__tests__/loop-item-root-scope.test.ts +213 -0
  49. package/src/__tests__/map-multi-return-body.test.ts +30 -3
  50. package/src/__tests__/nested-loop-conditional.test.ts +27 -6
  51. package/src/__tests__/preamble-attr-reactivity.test.ts +217 -0
  52. package/src/__tests__/preamble-declarations.test.ts +207 -0
  53. package/src/__tests__/ssr-defaults.test.ts +44 -0
  54. package/src/adapters/child-scope.ts +22 -0
  55. package/src/adapters/loop-bound-names.ts +6 -0
  56. package/src/analyzer-context.ts +43 -0
  57. package/src/analyzer.ts +33 -3
  58. package/src/index.ts +1 -0
  59. package/src/ir-to-client-js/collect-elements.ts +51 -10
  60. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +1 -1
  61. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -2
  62. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +3 -3
  63. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -2
  64. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +147 -5
  65. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -2
  66. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
  67. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +151 -0
  68. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +287 -0
  69. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +85 -22
  70. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  71. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +133 -35
  72. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +24 -4
  73. package/src/ir-to-client-js/csr-substitute.ts +6 -1
  74. package/src/ir-to-client-js/emit-registration.ts +1 -1
  75. package/src/ir-to-client-js/html-template.ts +51 -40
  76. package/src/ir-to-client-js/index.ts +1 -1
  77. package/src/ir-to-client-js/plan/build-declaration-emit.ts +2 -0
  78. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +1 -1
  79. package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
  80. package/src/ir-to-client-js/reactivity.ts +25 -0
  81. package/src/ir-to-client-js/stringify/declaration-emit.ts +9 -4
  82. package/src/ir-to-client-js/types.ts +12 -0
  83. package/src/jsx-to-ir.ts +400 -24
  84. package/src/rich-type-evidence.ts +6 -7
  85. package/src/ssr-defaults.ts +7 -2
  86. package/src/to-locale-date-lowering.ts +8 -2
  87. 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]
@@ -5968,6 +6270,30 @@ function tryResolveIdentifierAsTemplateLiteral(
5968
6270
  return [{ type: 'string', value: ast.text }]
5969
6271
  }
5970
6272
 
6273
+ // `const cls = variantClasses[variant]` (#2477): the initializer IS the
6274
+ // #2300 element-access shape, just not wrapped in a template literal.
6275
+ // Route it through the same span resolver so the one-hop const
6276
+ // (`className={cls}`) lowers to the identical `lookup` part as the
6277
+ // inline form (`className={variantClasses[variant]}`, the #2300 arm in
6278
+ // `getAttributeValue`) and the template-literal hop
6279
+ // (`const cls = \`${variantClasses[variant]}\``, the arm below) — both
6280
+ // of which every adapter already renders. Without this arm the attr
6281
+ // fell through to a bare-expression `cls`, which JSX-runtime SSR
6282
+ // (Hono) evaluates fine but every template backend emits as a
6283
+ // reference to a variable the template never defines, rendering
6284
+ // `class=""` with zero diagnostics. Same dynamic-key guard as the
6285
+ // inline arm: a static-key index (`paths['icon']`) stays on the
6286
+ // bare-expression path (jsx-to-ir regression pin — adapters resolve a
6287
+ // constant-key index already, and it must remain a plain `expression`
6288
+ // attr, not a single-case `lookup`).
6289
+ if (
6290
+ ts.isElementAccessExpression(ast) &&
6291
+ !ts.isStringLiteralLike(ast.argumentExpression) &&
6292
+ !ts.isNumericLiteral(ast.argumentExpression)
6293
+ ) {
6294
+ return tryResolveTemplateSpanFromConst(ast, ctx)
6295
+ }
6296
+
5971
6297
  if (!ts.isTemplateExpression(ast)) return null
5972
6298
 
5973
6299
  // We require at least one structurally-meaningful span (a string
@@ -6433,8 +6759,20 @@ function processComponentProps(
6433
6759
  // (Hono) keep using `expr`. Boolean-attr is also promoted to
6434
6760
  // shorthand (`<X disabled />` → `disabled={true}`).
6435
6761
  if (value.kind === 'template') {
6436
- value = AttrValueOf.expression(templatePartsToJsString(value.parts), {
6762
+ // Collapse using the raw part values, but also carry the
6763
+ // template-variant projection (parts' `templateValue` /
6764
+ // `templateCondition` / `templateKey`, i.e. destructured-prop refs
6765
+ // rewritten to `_p.X`) as `templateExpr`. Without it, the
6766
+ // `templateExpr` derivation below walks the ORIGINAL initializer AST
6767
+ // — for a const-resolved template (`className={classes}`) that AST is
6768
+ // the bare `classes` identifier with no prop refs, so the rewrite
6769
+ // no-ops and the module-scope registration template leaks bare
6770
+ // destructured props into `renderChild(...)` (#2468).
6771
+ const collapsed = templatePartsToJsString(value.parts)
6772
+ const collapsedTemplate = templatePartsToJsString(value.parts, { useTemplate: true })
6773
+ value = AttrValueOf.expression(collapsed, {
6437
6774
  parts: value.parts,
6775
+ ...(collapsedTemplate !== collapsed && { templateExpr: collapsedTemplate }),
6438
6776
  })
6439
6777
  } else if (value.kind === 'boolean-attr') {
6440
6778
  value = AttrValueOf.booleanShorthand()
@@ -6479,18 +6817,20 @@ function processComponentProps(
6479
6817
  * needs to be collapsed into an `expression` for component-prop forwarding —
6480
6818
  * component props are runtime JS values, not HTML attribute bodies.
6481
6819
  */
6482
- function templatePartsToJsString(parts: readonly IRTemplatePart[]): string {
6820
+ function templatePartsToJsString(parts: readonly IRTemplatePart[], opts?: { useTemplate?: boolean }): string {
6483
6821
  let result = '`'
6484
6822
  for (const part of parts) {
6485
6823
  if (part.type === 'string') {
6486
- result += part.value
6824
+ result += (opts?.useTemplate && part.templateValue) ? part.templateValue : part.value
6487
6825
  } else if (part.type === 'ternary') {
6488
- result += `\${${part.condition} ? '${part.whenTrue}' : '${part.whenFalse}'}`
6826
+ const cond = (opts?.useTemplate && part.templateCondition) ? part.templateCondition : part.condition
6827
+ result += `\${${cond} ? '${part.whenTrue}' : '${part.whenFalse}'}`
6489
6828
  } else if (part.type === 'lookup') {
6829
+ const key = (opts?.useTemplate && part.templateKey) ? part.templateKey : part.key
6490
6830
  const obj = '{' + Object.entries(part.cases).map(
6491
6831
  ([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`
6492
6832
  ).join(', ') + '}'
6493
- result += `\${(${obj})[${part.key}]}`
6833
+ result += `\${(${obj})[${key}]}`
6494
6834
  }
6495
6835
  }
6496
6836
  result += '`'
@@ -6875,14 +7215,21 @@ function replaceBranchLocalRefs(
6875
7215
  */
6876
7216
  function buildIfStatementChain(
6877
7217
  analyzer: AnalyzerContext,
6878
- ctx: TransformContext
6879
- ): IRIfStatement {
7218
+ ctx: TransformContext,
7219
+ opts?: { asConditional?: boolean }
7220
+ ): IRIfStatement | IRConditional | null {
6880
7221
  const conditionalReturns = analyzer.conditionalReturns
7222
+ // #2463: conditional mode builds the same IRConditional links the root
7223
+ // ternary produces (reactive insert() plan). The caller wraps the chain
7224
+ // in the synthetic display:contents scope element, so branches must NOT
7225
+ // claim the root scope themselves — mirror the ternary path's
7226
+ // `ctx.isRoot = false`.
7227
+ const asConditional = opts?.asConditional === true
6881
7228
 
6882
7229
  // Start with the final return (else case) if it exists
6883
7230
  let alternate: IRNode | null = null
6884
7231
  if (analyzer.jsxReturn) {
6885
- ctx.isRoot = true
7232
+ ctx.isRoot = !asConditional
6886
7233
  alternate = transformNode(analyzer.jsxReturn, ctx)
6887
7234
  }
6888
7235
 
@@ -7010,8 +7357,9 @@ function buildIfStatementChain(
7010
7357
  }
7011
7358
 
7012
7359
  // Transform the JSX return in the then branch
7013
- // Reset isRoot so each branch gets needsScope=true
7014
- ctx.isRoot = true
7360
+ // Reset isRoot so each branch gets needsScope=true (statement mode);
7361
+ // in conditional mode (#2463) the synthetic wrapper carries the scope.
7362
+ ctx.isRoot = !asConditional
7015
7363
  let consequent: IRNode | null
7016
7364
  try {
7017
7365
  consequent = transformNode(condReturn.jsxReturn, ctx)
@@ -7062,6 +7410,33 @@ function buildIfStatementChain(
7062
7410
  analyzer.filePath
7063
7411
  )
7064
7412
 
7413
+ if (asConditional && alternate) {
7414
+ // #2463: emit the same node shape `transformConditional` builds for
7415
+ // the root ternary, so the downstream insert() plan, slot wrapping,
7416
+ // and template substitution all apply unchanged. Eligibility
7417
+ // (checked by the caller) guarantees a reactive condition and no
7418
+ // branch scopeVariables.
7419
+ const conditional: IRConditional = {
7420
+ type: 'conditional',
7421
+ condition,
7422
+ templateCondition,
7423
+ conditionType: null,
7424
+ reactive: true,
7425
+ whenTrue: consequent,
7426
+ whenFalse: alternate,
7427
+ slotId: generateSlotId(ctx),
7428
+ loc,
7429
+ origin: {
7430
+ phase: 'tick',
7431
+ scope: 'template',
7432
+ effect: 'pure',
7433
+ freeRefs: resolveFreeRefs(condReturn.condition, makeBindingEnv(ctx)),
7434
+ },
7435
+ }
7436
+ alternate = conditional
7437
+ continue
7438
+ }
7439
+
7065
7440
  // Create the if statement node
7066
7441
  const ifStmt: IRIfStatement = {
7067
7442
  type: 'if-statement',
@@ -7077,6 +7452,7 @@ function buildIfStatementChain(
7077
7452
  alternate = ifStmt
7078
7453
  }
7079
7454
 
7080
- // The final result should be an IRIfStatement (the first if in the chain)
7081
- return alternate as IRIfStatement
7455
+ // The final result: the first link of the chain (an IRIfStatement in
7456
+ // statement mode, an IRConditional in #2463's conditional mode).
7457
+ return alternate as IRIfStatement | IRConditional | null
7082
7458
  }
@@ -69,13 +69,12 @@ type EvidenceMetadata = Pick<IRMetadata, 'propsType' | 'propsObjectName' | 'prop
69
69
  * check callers gate on).
70
70
  */
71
71
  function isNullishArm(t: TypeInfo): boolean {
72
- if (t.kind === 'primitive' && (t.primitive === 'null' || t.primitive === 'undefined')) return true
73
- // `null` as a type annotation is a `ts.LiteralTypeNode` (not the
74
- // `NullKeyword` `typeNodeToTypeInfo`'s primitive switch checks for), so it
75
- // falls through to `{ kind: 'unknown', raw: 'null' }` there — a pre-existing
76
- // gap in that shared helper, out of scope to fix here. Match on `raw` too
77
- // so `Date | null` still strips down to `Date`.
78
- return t.kind === 'unknown' && (t.raw === 'null' || t.raw === 'undefined')
72
+ // Purely structural: `typeNodeToTypeInfo`'s literal-type arm lowers a
73
+ // `null` union member (`ts.LiteralTypeNode(NullKeyword)`) to
74
+ // `{ kind: 'primitive', primitive: 'null' }`, the same shape a bare
75
+ // `null` annotation always produced the raw-text fallback this helper
76
+ // used to carry for the analyzer gap is gone with the gap.
77
+ return t.kind === 'primitive' && (t.primitive === 'null' || t.primitive === 'undefined')
79
78
  }
80
79
 
81
80
  export function stripUnion(type: TypeInfo | null): TypeInfo | null {
@@ -107,15 +107,20 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
107
107
  // wins via `propName`).
108
108
  for (const p of metadata.propsParams) {
109
109
  if (p.isRest) continue
110
+ // `propName` is the CALLER-facing key (`$props->{propName}` in the Perl
111
+ // consumer above) — for a renaming destructure (`{ n: count }`) that's
112
+ // `n`, not the local binding `count` the template variable is keyed by.
113
+ // `sourceName ?? name` is an identity for every un-aliased prop.
114
+ const callerPropName = p.sourceName ?? p.name
110
115
  if (metadata.propsObjectName === null && p.defaultValue !== undefined) {
111
116
  const value = tryStaticEval(p.defaultValue, { bindings: {}, propsLike })
112
- out[p.name] = { propName: p.name, value: resultToJsonable(value) }
117
+ out[p.name] = { propName: callerPropName, value: resultToJsonable(value) }
113
118
  } else {
114
119
  // No destructure default — the template reads the prop as-is;
115
120
  // Perl's `//` operator decides what `undef` becomes. The entry
116
121
  // still matters so the template variable exists and consumers
117
122
  // can supply the propName even with no static fallback.
118
- out[p.name] = { propName: p.name, value: null }
123
+ out[p.name] = { propName: callerPropName, value: null }
119
124
  }
120
125
  }
121
126
  // Rest-props bag (`...props`) — tracked separately from `propsParams`
@@ -393,8 +393,14 @@ function deriveFormat(locale: string, probeOptions: Record<string, string>): Loc
393
393
  */
394
394
  /** A quoted string-literal union member's value, or null when the member is anything else. */
395
395
  function unionMemberLiteral(member: TypeInfo): string | null {
396
- const m = /^'([^'\\]*)'$|^"([^"\\]*)"$/.exec(member.raw.trim())
397
- return m ? (m[1] ?? m[2]) : null
396
+ // Structural since the analyzer's literal-type arm: a string-literal
397
+ // member is `kind: 'primitive', primitive: 'string'` carrying its value
398
+ // in `literalValue`. (Previously regexed `member.raw` because literal
399
+ // members lowered to `kind: 'unknown'`.)
400
+ if (member.kind === 'primitive' && member.primitive === 'string' && member.literalValue !== undefined) {
401
+ return member.literalValue
402
+ }
403
+ return null
398
404
  }
399
405
 
400
406
  /**