@barefootjs/jsx 0.28.1 → 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 (101) 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 +890 -357
  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/composite-loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +14 -0
  25. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/html-template.d.ts +2 -3
  28. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/imports.d.ts +2 -2
  30. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
  32. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/reactivity.d.ts +1 -10
  34. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/types.d.ts +12 -0
  36. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  37. package/dist/jsx-to-ir.d.ts.map +1 -1
  38. package/dist/rich-type-evidence.d.ts.map +1 -1
  39. package/dist/ssr-defaults.d.ts.map +1 -1
  40. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  41. package/dist/types.d.ts +78 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/value-references.d.ts +17 -4
  44. package/dist/value-references.d.ts.map +1 -1
  45. package/package.json +2 -2
  46. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +6 -24
  47. package/src/__tests__/branch-loop-plain.test.ts +28 -45
  48. package/src/__tests__/csr-substitution-safety-divergence.test.ts +9 -3
  49. package/src/__tests__/csr-template-scope-soundness.test.ts +96 -0
  50. package/src/__tests__/early-return-branch-switch.test.ts +96 -0
  51. package/src/__tests__/form-control-value-ssr.test.ts +102 -0
  52. package/src/__tests__/ir-to-client-js/imports.test.ts +18 -0
  53. package/src/__tests__/lazy-conditional.test.ts +190 -0
  54. package/src/__tests__/lazy-preamble.test.ts +297 -0
  55. package/src/__tests__/lazy-row-eligibility.test.ts +97 -28
  56. package/src/__tests__/loop-item-root-scope.test.ts +213 -0
  57. package/src/__tests__/loop-row-creation-paths.test.ts +169 -0
  58. package/src/__tests__/map-multi-return-body.test.ts +30 -3
  59. package/src/__tests__/nested-loop-conditional.test.ts +27 -6
  60. package/src/__tests__/preamble-attr-reactivity.test.ts +217 -0
  61. package/src/__tests__/preamble-declarations.test.ts +207 -0
  62. package/src/__tests__/ssr-defaults.test.ts +44 -0
  63. package/src/__tests__/value-references.test.ts +88 -0
  64. package/src/adapters/child-scope.ts +22 -0
  65. package/src/adapters/loop-bound-names.ts +6 -0
  66. package/src/analyzer-context.ts +43 -0
  67. package/src/analyzer.ts +33 -3
  68. package/src/index.ts +1 -0
  69. package/src/ir-to-client-js/collect-elements.ts +51 -10
  70. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +1 -1
  71. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -2
  72. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +3 -3
  73. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -2
  74. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +147 -5
  75. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -2
  76. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
  77. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +151 -0
  78. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +287 -0
  79. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +85 -22
  80. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  81. package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +4 -0
  82. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +133 -35
  83. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +24 -4
  84. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +23 -4
  85. package/src/ir-to-client-js/csr-substitute.ts +6 -1
  86. package/src/ir-to-client-js/emit-registration.ts +1 -1
  87. package/src/ir-to-client-js/html-template.ts +51 -40
  88. package/src/ir-to-client-js/imports.ts +5 -0
  89. package/src/ir-to-client-js/index.ts +1 -1
  90. package/src/ir-to-client-js/plan/build-declaration-emit.ts +2 -0
  91. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +1 -1
  92. package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
  93. package/src/ir-to-client-js/reactivity.ts +25 -0
  94. package/src/ir-to-client-js/stringify/declaration-emit.ts +9 -4
  95. package/src/ir-to-client-js/types.ts +12 -0
  96. package/src/jsx-to-ir.ts +400 -24
  97. package/src/rich-type-evidence.ts +6 -7
  98. package/src/ssr-defaults.ts +7 -2
  99. package/src/to-locale-date-lowering.ts +8 -2
  100. package/src/types.ts +80 -0
  101. package/src/value-references.ts +26 -4
package/src/analyzer.ts CHANGED
@@ -1201,10 +1201,21 @@ function collectSignal(node: ts.VariableDeclaration, ctx: AnalyzerContext): void
1201
1201
  const callExpr = node.initializer as ts.CallExpression
1202
1202
 
1203
1203
  const elements = pattern.elements
1204
+ // Getter-elided form: `const [, setActive] = createSignal(0)`. The first
1205
+ // element is a hole (ts.OmittedExpression), not a BindingElement.
1206
+ // Rejecting it here used to drop the whole declaration from the emitted
1207
+ // init while the setter stayed referenced by handlers — a guaranteed
1208
+ // ReferenceError (adapter-tests scope-gate, #2468 sweep). Accept it,
1209
+ // synthesize an internal getter name for the IR (never referenced by any
1210
+ // expression, so substitution/SSR consumers see an unused entry), and
1211
+ // mark `getterElided` so emit reproduces the source's `[, setter]` shape.
1212
+ const getterElided = elements.length === 2 && ts.isOmittedExpression(elements[0])
1204
1213
  if (
1205
1214
  elements.length < 1 || elements.length > 2 ||
1206
- !ts.isBindingElement(elements[0]) ||
1207
- !ts.isIdentifier(elements[0].name)
1215
+ (!getterElided && (
1216
+ !ts.isBindingElement(elements[0]) ||
1217
+ !ts.isIdentifier(elements[0].name)
1218
+ ))
1208
1219
  ) {
1209
1220
  return
1210
1221
  }
@@ -1216,10 +1227,11 @@ function collectSignal(node: ts.VariableDeclaration, ctx: AnalyzerContext): void
1216
1227
  return
1217
1228
  }
1218
1229
 
1219
- const getter = elements[0].name.text
1220
1230
  const setter = elements.length === 2 && ts.isBindingElement(elements[1]) && ts.isIdentifier(elements[1].name)
1221
1231
  ? elements[1].name.text
1222
1232
  : null
1233
+ if (getterElided && !setter) return
1234
+ const getter = getterElided ? `__bfGet_${setter}` : (elements[0] as ts.BindingElement & { name: ts.Identifier }).name.text
1223
1235
  const initialValue = callExpr.arguments[0] ? ctx.getJS(callExpr.arguments[0]) : ''
1224
1236
  const typedInitialValue = callExpr.arguments[0] ? callExpr.arguments[0].getText(ctx.sourceFile) : undefined
1225
1237
 
@@ -1257,6 +1269,7 @@ function collectSignal(node: ts.VariableDeclaration, ctx: AnalyzerContext): void
1257
1269
  ctx.signals.push({
1258
1270
  getter,
1259
1271
  setter,
1272
+ getterElided: getterElided || undefined,
1260
1273
  initialValue,
1261
1274
  typedInitialValue: typedInitialValue !== initialValue ? typedInitialValue : undefined,
1262
1275
  templateInitialValue,
@@ -1577,9 +1590,26 @@ function collectMemo(node: ts.VariableDeclaration, ctx: AnalyzerContext): void {
1577
1590
  const parsedBlockComplete =
1578
1591
  parsedBlock && blockBody ? parsedBlock.length === blockBody.statements.length : undefined
1579
1592
 
1593
+ // Destructured-arg components only — the memo twin of the signal
1594
+ // `templateInitialValue` rewrite (#2265) a few hundred lines up: a memo
1595
+ // body referencing a bare destructured prop (`createMemo(() => value *
1596
+ // 10)` with `{ value }: { value: number }`) is inlined into the CSR
1597
+ // `template:` arrow by `buildSignalMemoEnv`, and that arrow runs at
1598
+ // module scope — it is not a closure over `initXxx`'s `const value =
1599
+ // _p.value` extraction, so the bare ref must become `_p.value`
1600
+ // (adapter-tests scope-gate, #2468 sweep).
1601
+ let templateComputation: string | undefined
1602
+ if (!ctx.propsObjectName && callExpr.arguments[0]) {
1603
+ const propNames = new Set(ctx.propsParams.map(p => p.name))
1604
+ if (propNames.size > 0) {
1605
+ templateComputation = rewriteBarePropRefs(computation, callExpr.arguments[0], propNames)
1606
+ }
1607
+ }
1608
+
1580
1609
  ctx.memos.push({
1581
1610
  name,
1582
1611
  computation,
1612
+ templateComputation,
1583
1613
  parsedBlock,
1584
1614
  parsedBlockComplete,
1585
1615
  typedComputation: typedComputation !== computation ? typedComputation : undefined,
package/src/index.ts CHANGED
@@ -90,6 +90,7 @@ export { rewriteImportsForTemplate } from './adapters/template-imports.ts'
90
90
  export { emitParsedExpr, groupBinaryOperand, isStringTypedOperand, isStringConcatBinary } from './adapters/parsed-expr-emitter.ts'
91
91
  export type { ParsedExprEmitter, HigherOrderMethod, ArrayMethod, SortMethod, LiteralType } from './adapters/parsed-expr-emitter.ts'
92
92
  export { collectLoopBoundNames } from './adapters/loop-bound-names.ts'
93
+ export { derivesScopeFromSlot } from './adapters/child-scope.ts'
93
94
  export { evaluateSignalInit, tryEvaluateSignalInit, type SignalInitEvalResult } from './signal-init-eval.ts'
94
95
  export { evaluateStaticLiteral, isFullyStaticLiteral, resolveStaticLoopSource } from './static-literal.ts'
95
96
  export { importsSearchParams, searchParamsLocalNames, envSignalLocalNames, envSignalReaderFor, ENV_SIGNAL_READERS, queryHrefLocalNames, formatDateLocalNames, matchSearchParamsMethodCall } from './adapters/env-signal.ts'
@@ -328,10 +328,12 @@ export function collectInnerLoops(
328
328
  // `NestedLoop` because event delegation handles them through
329
329
  // the parent's bindings instead.
330
330
  const bindings: LoopChildBindings = emptyLoopChildBindings()
331
+ // Hoisted: one Set per loop, not one per child (Copilot review).
332
+ const innerPreambleNames = preambleNamesOf(n)
331
333
  if (ctx) {
332
334
  for (const child of n.children) {
333
335
  bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, n.param, n.paramBindings))
334
- bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, n.param, n.paramBindings))
336
+ bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, n.param, n.paramBindings, false, innerPreambleNames))
335
337
  bindings.refs.push(...collectLoopChildRefs(child))
336
338
  }
337
339
  }
@@ -669,7 +671,7 @@ export function collectElements(
669
671
  const childHandlers: string[] = []
670
672
  const bindings = projectionInner
671
673
  ? emptyLoopChildBindings()
672
- : collectLoopChildBindings(l.children, ctx, siblingOffsets, l.param, l.paramBindings)
674
+ : collectLoopChildBindings(l.children, ctx, siblingOffsets, l.param, l.paramBindings, preambleNamesOf(l))
673
675
  if (!projectionInner) {
674
676
  for (const child of l.children) {
675
677
  childHandlers.push(...collectEventHandlersFromIR(child))
@@ -708,10 +710,12 @@ export function collectElements(
708
710
  // Loop-param refs use the raw destructured binding (no loopParams
709
711
  // passed), matching the plain-element path's `staticItemTemplate`.
710
712
  if (l.isStaticArray && l.children[0]) {
711
- // `insideLoop=true` so the component-emit drops the parent's slot
712
- // suffix each iteration owns a distinct scope identified by
713
- // `data-key`, mirroring the SSR template's renderChild emit.
714
- staticItemTemplate = irToHtmlTemplate(l.children[0], buildRestSpreadNames(ctx), 0, undefined, undefined, /* insideLoop */ true)
713
+ // `l.children[0]` is the loop's row-root component already
714
+ // tagged `loopItemRoot` in the IR, so `derivesScopeFromSlot`
715
+ // drops the parent's slot suffix automatically. Each iteration
716
+ // owns a distinct scope identified by `data-key`, mirroring the
717
+ // SSR template's renderChild emit.
718
+ staticItemTemplate = irToHtmlTemplate(l.children[0], buildRestSpreadNames(ctx), 0, undefined, undefined)
715
719
  }
716
720
  } else if (l.children[0] && !projectionInner) {
717
721
  // Pass loopParams so expressions are wrapped at generation time,
@@ -728,8 +732,8 @@ export function collectElements(
728
732
  // accessor wrap so the CSR materialize fallback (#1247) can clone
729
733
  // items inside the forEach body without rewriting the template.
730
734
  if (l.isStaticArray) {
731
- // Plain-element body: leave `insideLoop=false` so any nested
732
- // component nodes keep their parent-slot context in the
735
+ // Plain-element body: nested component nodes are never tagged
736
+ // `loopItemRoot`, so they keep their parent-slot context in the
733
737
  // per-iteration renderChild call. Per #1249, the
734
738
  // `outer-nested` static-array-child-init plan addresses
735
739
  // children by the (bf-h, bf-m) pair against the enclosing
@@ -1139,7 +1143,7 @@ function collectBranchLoops(
1139
1143
  // which caused reactive reads inside simple loop bodies to silently
1140
1144
  // no-op for existing items.
1141
1145
  const branchBindings = ctx && !projectionInner
1142
- ? collectLoopChildBindings(n.children, ctx, siblingOffsets, n.param, n.paramBindings)
1146
+ ? collectLoopChildBindings(n.children, ctx, siblingOffsets, n.param, n.paramBindings, preambleNamesOf(n))
1143
1147
  : emptyLoopChildBindings()
1144
1148
 
1145
1149
  loops.push({
@@ -1296,12 +1300,49 @@ function collectBranchConditionals(
1296
1300
  * resolve child slot ids inside nested loops; the rest of the per-item
1297
1301
  * collectors don't read them but pass them through for symmetry.
1298
1302
  */
1303
+ /**
1304
+ * The names a loop's `.map()` callback preamble declares, or `undefined` when
1305
+ * there are none to consider — the input to the attribute classifier's
1306
+ * preamble-local check (#2447 follow-up). `undefined` rather than an empty set
1307
+ * so the classifier's fast path stays a single `!== undefined` for the
1308
+ * overwhelmingly common preamble-free loop.
1309
+ *
1310
+ * A STATIC-ARRAY loop returns `undefined` even when it has a preamble, and
1311
+ * this is load-bearing rather than an optimisation. Its items are emitted
1312
+ * through `static-array-child-init` — a `forEach` over the SSR-rendered
1313
+ * children, with no per-row body and therefore no preamble in scope. Wiring an
1314
+ * attribute there would emit `setAttribute('class', active ? … : …)` against a
1315
+ * free variable: a ReferenceError at init that takes the whole component's
1316
+ * hydration with it, including any nested child. (Caught exactly that way —
1317
+ * the gallery nav shells are static arrays whose rows read
1318
+ * `const active = item.key === currentRoute`, and the badge components mounted
1319
+ * inside them stopped appearing.)
1320
+ *
1321
+ * Nothing is lost by declining: a static array's items are rendered once at
1322
+ * SSR and never recreated from a signal, so there is no same-key update for a
1323
+ * patch to be missing from. The same reasoning gates `markPreambleAttrSlots`
1324
+ * and `preambleRegions` in Phase 1, and the two gates must agree — a slot
1325
+ * granted without wiring, or wiring without a slot, is a silent hole either
1326
+ * way.
1327
+ */
1328
+ function preambleNamesOf(loop: IRLoop): ReadonlySet<string> | undefined {
1329
+ if (loop.isStaticArray) return undefined
1330
+ const declared = loop.preamble?.declaredNames
1331
+ return declared && declared.length > 0 ? new Set(declared) : undefined
1332
+ }
1333
+
1299
1334
  export function collectLoopChildBindings(
1300
1335
  children: readonly IRNode[],
1301
1336
  ctx: ClientJsContext,
1302
1337
  siblingOffsets: Map<IRLoop, IRNode[]>,
1303
1338
  loopParam: string,
1304
1339
  loopParamBindings: readonly import('../types.ts').LoopParamBinding[] | undefined,
1340
+ /**
1341
+ * Names the loop's `.map()` callback preamble declares, so an attribute
1342
+ * reading one is classified reactive (#2447 follow-up) — see
1343
+ * `collectLoopChildReactiveAttrs`. Omitted for a loop with no preamble.
1344
+ */
1345
+ preambleNames?: ReadonlySet<string>,
1305
1346
  ): LoopChildBindings {
1306
1347
  const bindings = emptyLoopChildBindings()
1307
1348
  for (const child of children) {
@@ -1311,7 +1352,7 @@ export function collectLoopChildBindings(
1311
1352
  // `collectLoopChildConditionals`, which gives each its own insert() +
1312
1353
  // arm-scoped attrs/texts (`LoopChildBranchSummary.reactiveAttrs` /
1313
1354
  // `.reactiveTexts`) — descending into them here too would double-bind.
1314
- bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, loopParam, loopParamBindings, true))
1355
+ bindings.reactiveAttrs.push(...collectLoopChildReactiveAttrs(child, ctx, loopParam, loopParamBindings, true, preambleNames))
1315
1356
  bindings.reactiveTexts.push(...collectLoopChildReactiveTexts(child, ctx, loopParam, loopParamBindings, true))
1316
1357
  bindings.refs.push(...collectLoopChildRefs(child))
1317
1358
  bindings.conditionals.push(...collectLoopChildConditionals(child, ctx, siblingOffsets, loopParam, loopParamBindings))
@@ -58,7 +58,7 @@ export function buildBranchLoopPlan(
58
58
  const mapPreambleWrapped = loop.preamble
59
59
  ? renderPreamble(loop.preamble, {
60
60
  transformJs: (t) => wrapLoopParamAsAccessor(t, loop.param, loop.paramBindings),
61
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined, true),
61
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined),
62
62
  })
63
63
  : ''
64
64
  const preambleRegions = buildPreambleRegionPlans(loop.preambleRegions, loop.param, loop.paramBindings)
@@ -48,7 +48,7 @@ export function buildTopLevelCompositePlan(elem: TopLevelLoop, profileComponentN
48
48
  mapPreambleWrapped: elem.preamble
49
49
  ? renderPreamble(elem.preamble, {
50
50
  transformJs: wrap,
51
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined, true),
51
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined),
52
52
  })
53
53
  : '',
54
54
  template: elem.template,
@@ -108,7 +108,7 @@ export function buildBranchCompositePlan(loop: BranchLoop, cv: string, profileCo
108
108
  mapPreambleWrapped: loop.preamble
109
109
  ? renderPreamble(loop.preamble, {
110
110
  transformJs: wrap,
111
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined, true),
111
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: loop.param, bindings: loop.paramBindings }], undefined),
112
112
  })
113
113
  : '',
114
114
  template: loop.template,
@@ -44,7 +44,7 @@ export function buildDynamicLoopDelegationPlan(
44
44
  // which throws since `t` is a plain object in this scope.
45
45
  mapPreamble: elem.preamble
46
46
  ? renderPreamble(elem.preamble, {
47
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined, true),
47
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined),
48
48
  })
49
49
  : null,
50
50
  mapPreambleDeclaredNames: elem.preamble?.declaredNames ?? [],
@@ -76,7 +76,7 @@ export function buildBranchLoopDelegationPlan(
76
76
  // loopParams spec — leaf refs must stay in plain-object form here.
77
77
  mapPreamble: loop.preamble
78
78
  ? renderPreamble(loop.preamble, {
79
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined, true),
79
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined),
80
80
  })
81
81
  : null,
82
82
  mapPreambleDeclaredNames: loop.preamble?.declaredNames ?? [],
@@ -109,7 +109,7 @@ export function buildStaticArrayDelegationPlan(
109
109
  // loopParams spec — leaf refs must stay in plain-object form here.
110
110
  mapPreamble: elem.preamble
111
111
  ? renderPreamble(elem.preamble, {
112
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined, true),
112
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined),
113
113
  })
114
114
  : null,
115
115
  mapPreambleDeclaredNames: elem.preamble?.declaredNames ?? [],
@@ -243,7 +243,7 @@ function buildReactiveEmit(
243
243
  : [{ param: inner.param, bindings: inner.paramBindings }]
244
244
  preludeStatements.push(renderPreamble(inner.preamble, {
245
245
  transformJs: (t) => wrapInner(wrapOuter(t)),
246
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, leafLoopParams, undefined, true),
246
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, leafLoopParams, undefined),
247
247
  }))
248
248
  }
249
249
 
@@ -286,7 +286,7 @@ function buildStaticEmit(inner: NestedLoop, level: DepthLevel, uidSuffix: string
286
286
  // of the js segments above).
287
287
  if (inner.preamble) {
288
288
  preludeStatements.push(renderPreamble(inner.preamble, {
289
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined, true),
289
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, undefined, undefined),
290
290
  }))
291
291
  }
292
292
  return {
@@ -20,8 +20,11 @@
20
20
  import { freeIdentifiers, parseExpression } from '../../../expression-parser.ts'
21
21
  import { pickAttrMeta, type AttrMeta } from '../../../types.ts'
22
22
  import { extractFreeIdentifiersFromText } from '../../csr-substitute.ts'
23
+ import { addCondAttrToTemplate } from '../../html-template.ts'
23
24
  import { wrapLoopParamAsAccessor, PROPS_PARAM } from '../../utils.ts'
24
25
  import type { BranchLoop, ClientJsContext, TopLevelLoop } from '../../types.ts'
26
+ import { analyzeLazyConditional, type LazyConditionalFacts } from './lazy-conditional.ts'
27
+ import { analyzeLazyPreamble } from './lazy-preamble.ts'
25
28
  import {
26
29
  classifyLazyBinding,
27
30
  lazyRowEligibility,
@@ -44,6 +47,13 @@ export interface LazyRowAttrBinding {
44
47
  ordinal: number
45
48
  readsItem: boolean
46
49
  readsOuter: boolean
50
+ /**
51
+ * Reads a `.map()` preamble local, so every apply body this binding is
52
+ * emitted into must re-run the preamble first (#2447 follow-up). Per
53
+ * binding, not per loop, so `applyOuter` does not pay for a preamble only
54
+ * `applyItem`'s bindings read.
55
+ */
56
+ readsPreamble: boolean
47
57
  }
48
58
 
49
59
  /** One reactive text (content slot) of a lazy row. */
@@ -58,6 +68,37 @@ export interface LazyRowTextBinding {
58
68
  * (`LazyRowPlanData.textNeedsRead`).
59
69
  */
60
70
  readsOuter: boolean
71
+ /**
72
+ * Reads a `.map()` preamble local, so every apply body this binding is
73
+ * emitted into must re-run the preamble first (#2447 follow-up).
74
+ */
75
+ readsPreamble: boolean
76
+ }
77
+
78
+ /** One row conditional driven from the loop-level apply bodies (§9.5). */
79
+ export interface LazyRowConditionalBinding {
80
+ slotId: string
81
+ /** Already wrapped via `wrapLoopParamAsAccessor` — reads `<param>()`. */
82
+ wrappedCondition: string
83
+ whenTrueHtml: string
84
+ whenFalseHtml: string
85
+ /**
86
+ * Index into `entry.refs` holding the `[bf-c]` element. Always claimed ON
87
+ * DEMAND — `createRow` never seeds it, because the row it just cloned already
88
+ * rendered the correct arm and has no swap to perform.
89
+ */
90
+ refIndex: number
91
+ /** Index into `entry.last` holding this conditional's dedup boolean. */
92
+ ordinal: number
93
+ readsItem: boolean
94
+ readsOuter: boolean
95
+ /**
96
+ * Reads a `.map()` preamble local, so every apply body this binding is
97
+ * emitted into must re-run the preamble first (#2447 follow-up). Per
98
+ * binding, not per loop, so `applyOuter` does not pay for a preamble only
99
+ * `applyItem`'s bindings read.
100
+ */
101
+ readsPreamble: boolean
61
102
  }
62
103
 
63
104
  export interface LazyRowPlanData {
@@ -86,8 +127,35 @@ export interface LazyRowPlanData {
86
127
  * nothing per row).
87
128
  */
88
129
  outerPrimeGetters: readonly string[]
89
- /** True when at least one binding is outer-involving. */
90
- hasOuter: boolean
130
+ /**
131
+ * The row's `.map()` callback preamble as already-wrapped JS statements
132
+ * (`mapPreambleWrapped`), or `''` when the row has none — proven safe to run
133
+ * by `analyzeLazyPreamble` (§9.5 widening).
134
+ *
135
+ * `createRow` always runs it, and specifically before the clone: the
136
+ * non-hoisted per-row template can interpolate values the preamble declares.
137
+ *
138
+ * `applyItem` / `applyOuter` run it only when a binding they own reads a
139
+ * declared local (`itemNeedsPreamble` / `outerNeedsPreamble`) — which is
140
+ * possible as of the #2447 follow-up, where an attribute reading one became
141
+ * a reactive binding instead of frozen template text. Re-running is sound
142
+ * because `analyzeLazyPreamble` proved the statements are `const`
143
+ * declarations whose initializers only read the item and zero-arg signal
144
+ * getters, and the binding's DEPENDENCIES are the preamble's own free
145
+ * identifiers (substituted in `classifyLazyBinding`), so `applyOuter`
146
+ * primes what the preamble reads rather than the opaque local name.
147
+ */
148
+ preambleStatements: string
149
+ /** `applyItem` has a binding that reads a preamble local (#2447 follow-up). */
150
+ itemNeedsPreamble: boolean
151
+ /** `applyOuter` has one. Separate from the above so neither body pays for the other's. */
152
+ outerNeedsPreamble: boolean
153
+ /**
154
+ * Row conditionals driven from the apply bodies (§9.5, `lazy-conditional.ts`).
155
+ * Empty for most loops. Each arm is a static element, so the emitter hoists
156
+ * both parsed once per loop and clones on a flip.
157
+ */
158
+ conditionals: readonly LazyRowConditionalBinding[]
91
159
  }
92
160
 
93
161
  export interface BuildLazyRowArgs {
@@ -126,6 +194,28 @@ export function decideLazyRow(args: BuildLazyRowArgs): {
126
194
  const rowLocalNames = new Set<string>([loop.param])
127
195
  for (const b of loop.paramBindings ?? []) rowLocalNames.add(b.name)
128
196
 
197
+ // §9.5 preamble widening: prove the preamble is safe to re-run in the apply
198
+ // bodies BEFORE classifying, because a binding that reads a declared local
199
+ // inherits the preamble's dependencies rather than its own literal names.
200
+ const primableNames = new Set<string>([...scope.signals.keys(), ...scope.memos])
201
+ const preambleAnalysis = analyzeLazyPreamble(loop.preamble, args.indexParam, primableNames)
202
+
203
+ // §9.5 conditional widening: a row conditional whose arms are wiring-free
204
+ // static elements is driven from the apply bodies instead of a per-row
205
+ // `insert()` effect. The FIRST refusal wins, so the gate names one shape.
206
+ const rawConditionals = loop.bindings.conditionals ?? []
207
+ const condFacts: LazyConditionalFacts[] = []
208
+ let conditionalRefusal: string | null = null
209
+ for (const cond of rawConditionals) {
210
+ const verdict = analyzeLazyConditional(cond, args.indexParam, {
211
+ whenTrueHtml: addCondAttrToTemplate(wrap(cond.whenTrueHtml), cond.slotId),
212
+ whenFalseHtml: addCondAttrToTemplate(wrap(cond.whenFalseHtml), cond.slotId),
213
+ })
214
+ if (!verdict.lazySafe) { conditionalRefusal = verdict.reason; break }
215
+ condFacts.push(verdict.facts)
216
+ }
217
+ const preambleFacts = preambleAnalysis.lazySafe ? preambleAnalysis.facts : undefined
218
+
129
219
  // --- classify every binding -------------------------------------------
130
220
  const classified: ClassifiedLazyBinding[] = []
131
221
  const attrClass = new Map<number, ClassifiedLazyBinding>()
@@ -137,6 +227,7 @@ export function decideLazyRow(args: BuildLazyRowArgs): {
137
227
  rowLocalNames,
138
228
  indexParam: args.indexParam,
139
229
  scope,
230
+ preamble: preambleFacts,
140
231
  })
141
232
  attrClass.set(i, c)
142
233
  classified.push(c)
@@ -150,11 +241,30 @@ export function decideLazyRow(args: BuildLazyRowArgs): {
150
241
  rowLocalNames,
151
242
  indexParam: args.indexParam,
152
243
  scope,
244
+ preamble: preambleFacts,
153
245
  })
154
246
  textClass.set(i, c)
155
247
  classified.push(c)
156
248
  })
157
249
 
250
+ const condClass = new Map<number, ClassifiedLazyBinding>()
251
+ condFacts.forEach((c, i) => {
252
+ // Classified like any other binding: a condition reading an outer signal
253
+ // must land in `applyOuter` AND get that signal onto the prime list, or the
254
+ // loop-level effect would never subscribe to it.
255
+ const k = classifyLazyBinding({
256
+ kind: 'attr',
257
+ slotId: c.slotId,
258
+ free: rawConditionals[i].conditionFreeIdentifiers ?? null,
259
+ rowLocalNames,
260
+ indexParam: args.indexParam,
261
+ scope,
262
+ preamble: preambleFacts,
263
+ })
264
+ condClass.set(i, k)
265
+ classified.push(k)
266
+ })
267
+
158
268
  // --- §9.4 gate ---------------------------------------------------------
159
269
  const shape: LazyRowShapeFacts = {
160
270
  callSite: args.callSite,
@@ -162,12 +272,12 @@ export function decideLazyRow(args: BuildLazyRowArgs): {
162
272
  anchored: args.anchored,
163
273
  bodyIsMultiRoot: loop.bodyIsMultiRoot ?? false,
164
274
  hasExplicitKey: loop.key != null,
165
- conditionalCount: loop.bindings.conditionals?.length ?? 0,
275
+ conditionalRefusal,
166
276
  childRefCount: loop.bindings.refs?.length ?? 0,
167
277
  nestedComponentCount: loop.nestedComponents?.length ?? 0,
168
278
  innerLoopCount: loop.innerLoops?.length ?? 0,
169
279
  hasChildComponent: 'childComponent' in loop && loop.childComponent != null,
170
- hasMapPreamble: args.mapPreambleWrapped.length > 0,
280
+ mapPreambleRefusal: preambleAnalysis.lazySafe ? null : preambleAnalysis.reason,
171
281
  preambleRegionCount: args.preambleRegionCount,
172
282
  hasParamUnwrap: args.paramUnwrap.length > 0,
173
283
  }
@@ -198,6 +308,7 @@ export function decideLazyRow(args: BuildLazyRowArgs): {
198
308
  ordinal: ordinal++,
199
309
  readsItem: c.readsItem,
200
310
  readsOuter: c.readsOuter,
311
+ readsPreamble: c.readsPreamble,
201
312
  }
202
313
  })
203
314
  const texts: LazyRowTextBinding[] = loop.bindings.reactiveTexts.map((text, i) => {
@@ -212,6 +323,7 @@ export function decideLazyRow(args: BuildLazyRowArgs): {
212
323
  // a list keeps exactly the classifier's answer.
213
324
  readsItem: c.readsItem || !c.readsOuter,
214
325
  readsOuter: c.readsOuter,
326
+ readsPreamble: c.readsPreamble,
215
327
  }
216
328
  })
217
329
 
@@ -222,6 +334,29 @@ export function decideLazyRow(args: BuildLazyRowArgs): {
222
334
  }
223
335
  }
224
336
 
337
+ // Conditional refs live PAST the door, and are claimed on demand — the row
338
+ // `createRow` just cloned already rendered the correct arm, so there is
339
+ // nothing for it to seed or swap (see `LazyRowConditionalBinding.refIndex`).
340
+ const condRefBase = attrSlotIds.length + (texts.length > 0 ? 1 : 0)
341
+ const conditionals: LazyRowConditionalBinding[] = condFacts.map((c, i) => {
342
+ const klass = condClass.get(i)!
343
+ return {
344
+ slotId: c.slotId,
345
+ wrappedCondition: wrap(c.condition),
346
+ whenTrueHtml: c.whenTrueHtml,
347
+ whenFalseHtml: c.whenFalseHtml,
348
+ refIndex: condRefBase + i,
349
+ ordinal: ordinal++,
350
+ // A condition reading neither the item nor a reactive outer name still
351
+ // has to be applied somewhere; `applyItem` is the harmless choice (the
352
+ // dedup makes a repeat a no-op), matching how a text that classified as
353
+ // neither is handled above.
354
+ readsItem: klass.readsItem || !klass.readsOuter,
355
+ readsOuter: klass.readsOuter,
356
+ readsPreamble: klass.readsPreamble,
357
+ }
358
+ })
359
+
225
360
  return {
226
361
  plan: {
227
362
  attrSlotIds,
@@ -231,7 +366,14 @@ export function decideLazyRow(args: BuildLazyRowArgs): {
231
366
  textNeedsRead: texts.some(t => t.readsOuter),
232
367
  lastCount: ordinal,
233
368
  outerPrimeGetters,
234
- hasOuter: attrs.some(a => a.readsOuter) || texts.some(t => t.readsOuter),
369
+ preambleStatements: args.mapPreambleWrapped,
370
+ // Which apply bodies must re-run the preamble — computed from the FINAL
371
+ // binding lists, not the raw classification, so a text/conditional that
372
+ // classified as neither item- nor outer-driven (and was therefore forced
373
+ // into `applyItem` above) is counted in the body it actually lands in.
374
+ itemNeedsPreamble: [...attrs, ...texts, ...conditionals].some(b => b.readsItem && b.readsPreamble),
375
+ outerNeedsPreamble: [...attrs, ...texts, ...conditionals].some(b => b.readsOuter && b.readsPreamble),
376
+ conditionals,
235
377
  },
236
378
  decision,
237
379
  }
@@ -144,7 +144,7 @@ export function buildPlainLoopPlan(
144
144
  const mapPreambleWrapped = elem.preamble
145
145
  ? renderPreamble(elem.preamble, {
146
146
  transformJs: wrap,
147
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined, true),
147
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined),
148
148
  })
149
149
  : ''
150
150
  const preambleRegions = buildPreambleRegionPlans(elem.preambleRegions, elem.param, elem.paramBindings)
@@ -269,7 +269,7 @@ function buildStaticLoopMaterialize(
269
269
  itemTemplate: elem.staticItemTemplate,
270
270
  mapPreamble: elem.preamble
271
271
  ? renderPreamble(elem.preamble, {
272
- renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined, true),
272
+ renderLeaf: (ir) => irToHtmlTemplate(ir, undefined, 1, [{ param: elem.param, bindings: elem.paramBindings }], undefined),
273
273
  })
274
274
  : '',
275
275
  bodyIsMultiRoot: elem.bodyIsMultiRoot ?? false,
@@ -82,6 +82,7 @@ export function buildReactiveEffectsPlan(
82
82
  attrName: attr.attrName,
83
83
  wrappedExpression: wrap(attr.expression),
84
84
  meta: pickAttrMeta(attr),
85
+ ...(attr.readsPreamble && { readsPreamble: true }),
85
86
  })),
86
87
  })
87
88
  }