@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
@@ -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
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Is a row's reactive conditional drivable from the LOOP-level apply bodies? —
3
+ * `spec/slot-unification.md` §9.5, the "row contains a reactive conditional"
4
+ * widening.
5
+ *
6
+ * ## Why it was refused wholesale
7
+ *
8
+ * The eager emission calls `insert(__el, 'sN', () => cond, trueArm, falseArm)`
9
+ * once per row. `insert` creates **one `createEffect` per call**, plus it probes
10
+ * both branch templates at runtime to decide element-vs-fragment form, resolves
11
+ * a search region, and manages per-branch `bindEvents` cleanup. Calling it from
12
+ * a lazy row would reinstate exactly the per-row reactive resource the lazy row
13
+ * graph exists to remove — so the gate refused any row with a conditional at
14
+ * all.
15
+ *
16
+ * ## What this accepts, and why that is the whole job
17
+ *
18
+ * For the narrow case where **both arms are wiring-free static elements**,
19
+ * everything `insert` does collapses to one operation: replace the
20
+ * `[bf-c="sN"]` element with the other arm's markup when the condition flips.
21
+ * No `bindEvents`, no branch cleanup, no auto-focus, no `__bfSlot` live-node
22
+ * splicing — those all exist for arms that own something, and these arms own
23
+ * nothing.
24
+ *
25
+ * That case needs no runtime helper. Both arms are compile-time constants, so
26
+ * each is parsed ONCE per loop into a hoisted `<template>` and cloned per swap,
27
+ * the same trick the row template already uses. What is left per row is a
28
+ * boolean, a dedup slot, and a `replaceWith` — which the existing
29
+ * `applyItem`/`applyOuter` bodies can carry directly.
30
+ *
31
+ * ## The four things that must be proven
32
+ *
33
+ * 1. **Both arms are wiring-free.** Every `LoopChildBranchSummary` collection
34
+ * must be empty. A single reactive text inside an arm means the arm's
35
+ * content has to track the item, which needs wiring this shape has no place
36
+ * for.
37
+ * 2. **Both arms are ELEMENT conditionals.** The caller passes each arm through
38
+ * `addCondAttrToTemplate` — the same door the eager emission uses — which
39
+ * injects `bf-c="<slotId>"` on a single root element and otherwise wraps the
40
+ * arm in `<!--bf-cond-start:id-->` markers. So the two forms are told apart
41
+ * by reading its output, not by re-deciding here. A FRAGMENT conditional
42
+ * spans a sibling range with no single node to replace, which is why
43
+ * `insert` carries a second code path for it.
44
+ * 3. **Both arms' HTML is static.** The arm strings are emitted inside a
45
+ * template literal, so an interpolation survives as `${…}`. Anything
46
+ * containing one is refused — it would have to be re-evaluated per row,
47
+ * defeating the hoist. (A literal `${` in authored TEXT would also refuse
48
+ * here; that is the safe direction — a false refusal costs the eager
49
+ * fallback, a false accept would ship a frozen arm.)
50
+ * 4. **The condition does not read the loop INDEX.** `applyItem` and
51
+ * `applyOuter` have no index parameter, the same reason
52
+ * `ClassifiedLazyBinding.referencesIndex` refuses a binding.
53
+ *
54
+ * Everything refused carries a specific reason, which `lazyRowEligibility`
55
+ * passes through unchanged.
56
+ */
57
+
58
+ import type { LoopChildBranchSummary, LoopChildConditional } from '../../types.ts'
59
+
60
+ /** The prepared arm markup the caller hands in, and this module vets. */
61
+ export interface PreparedArms {
62
+ /**
63
+ * Loop-param wrapped and passed through `addCondAttrToTemplate`, i.e. exactly
64
+ * the strings the eager path would emit. Vetting the PREPARED form is the
65
+ * point: the raw `IRLoop` arm HTML carries no `bf-c` at all, so an
66
+ * element-vs-fragment decision made on it would be guesswork.
67
+ */
68
+ whenTrueHtml: string
69
+ whenFalseHtml: string
70
+ }
71
+
72
+ /** A row conditional the loop-level apply bodies can drive. */
73
+ export interface LazyConditionalFacts {
74
+ slotId: string
75
+ /** Condition expression, NOT yet loop-param wrapped (the caller wraps). */
76
+ condition: string
77
+ /** Arm markup, static by construction — hoisted and cloned per swap. */
78
+ whenTrueHtml: string
79
+ whenFalseHtml: string
80
+ }
81
+
82
+ export type LazyConditionalAnalysis =
83
+ | { lazySafe: true; facts: LazyConditionalFacts }
84
+ | { lazySafe: false; reason: string }
85
+
86
+ const NO = (reason: string): LazyConditionalAnalysis => ({ lazySafe: false, reason })
87
+
88
+ /** Names every non-empty collection on a branch summary, for the reason text. */
89
+ function wiringOn(branch: LoopChildBranchSummary): string[] {
90
+ const found: string[] = []
91
+ if (branch.childComponents.length > 0) found.push('child components')
92
+ if (branch.innerLoops && branch.innerLoops.length > 0) found.push('an inner loop')
93
+ if (branch.conditionals && branch.conditionals.length > 0) found.push('a nested conditional')
94
+ if (branch.events && branch.events.length > 0) found.push('events')
95
+ if (branch.reactiveAttrs && branch.reactiveAttrs.length > 0) found.push('reactive attrs')
96
+ if (branch.reactiveTexts && branch.reactiveTexts.length > 0) found.push('reactive text')
97
+ return found
98
+ }
99
+
100
+ /**
101
+ * Decide whether `cond` can be driven from the loop-level apply bodies.
102
+ *
103
+ * `indexParam` is the loop's index parameter name as the emitter uses it
104
+ * (`elem.index || '__idx'`); a condition reading it is refused.
105
+ */
106
+ export function analyzeLazyConditional(
107
+ cond: LoopChildConditional,
108
+ indexParam: string,
109
+ arms: PreparedArms,
110
+ ): LazyConditionalAnalysis {
111
+ for (const [label, branch] of [['true', cond.whenTrue], ['false', cond.whenFalse]] as const) {
112
+ const wiring = wiringOn(branch)
113
+ if (wiring.length > 0) {
114
+ return NO(`conditional on slot ${cond.slotId}: its ${label} arm owns ${wiring.join(' + ')}`)
115
+ }
116
+ }
117
+
118
+ for (const [label, html] of [['true', arms.whenTrueHtml], ['false', arms.whenFalseHtml]] as const) {
119
+ if (html.includes('bf-cond-start:')) {
120
+ return NO(`conditional on slot ${cond.slotId}: its ${label} arm is a fragment conditional`)
121
+ }
122
+ if (!html.includes(`bf-c="${cond.slotId}"`)) {
123
+ return NO(`conditional on slot ${cond.slotId}: its ${label} arm has no single bf-c root`)
124
+ }
125
+ // The arm is emitted inside a template literal; a surviving `${` means the
126
+ // markup depends on the item and cannot be hoisted once per loop.
127
+ if (html.includes('${')) {
128
+ return NO(`conditional on slot ${cond.slotId}: its ${label} arm interpolates a value`)
129
+ }
130
+ }
131
+
132
+ // `conditionFreeIdentifiers` is pre-computed by the analyzer (#1267). Absent
133
+ // means unprovable, which is a refusal rather than an assumption — the same
134
+ // stance the binding gate takes for a missing identifier set.
135
+ if (!cond.conditionFreeIdentifiers) {
136
+ return NO(`conditional on slot ${cond.slotId}: condition has no analyzable identifier set`)
137
+ }
138
+ if (cond.conditionFreeIdentifiers.has(indexParam)) {
139
+ return NO(`conditional on slot ${cond.slotId}: condition reads the loop index parameter '${indexParam}'`)
140
+ }
141
+
142
+ return {
143
+ lazySafe: true,
144
+ facts: {
145
+ slotId: cond.slotId,
146
+ condition: cond.condition,
147
+ whenTrueHtml: arms.whenTrueHtml,
148
+ whenFalseHtml: arms.whenFalseHtml,
149
+ },
150
+ }
151
+ }