@barefootjs/jsx 0.26.2 → 0.26.4

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 (98) hide show
  1. package/dist/adapters/interface.d.ts +29 -0
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/jsx-adapter.d.ts +9 -0
  4. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  5. package/dist/adapters/loop-bound-names.d.ts.map +1 -1
  6. package/dist/adapters/parsed-expr-emitter.d.ts +0 -10
  7. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  8. package/dist/adapters/test-adapter.d.ts.map +1 -1
  9. package/dist/analyzer-context.d.ts +11 -1
  10. package/dist/analyzer-context.d.ts.map +1 -1
  11. package/dist/analyzer.d.ts +40 -1
  12. package/dist/analyzer.d.ts.map +1 -1
  13. package/dist/expression-parser.d.ts.map +1 -1
  14. package/dist/index.js +1086 -269
  15. package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +14 -1
  18. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +16 -1
  26. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +39 -0
  28. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/control-flow/shared.d.ts +12 -1
  30. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts +1 -1
  32. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +19 -0
  34. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/html-template.d.ts +67 -1
  37. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/imports.d.ts +2 -2
  39. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/plan/build-static-array-child-init.d.ts.map +1 -1
  41. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/types.d.ts +29 -4
  43. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  44. package/dist/jsx-to-ir.d.ts.map +1 -1
  45. package/dist/loop-destructure.d.ts.map +1 -1
  46. package/dist/strip-types.d.ts +18 -0
  47. package/dist/strip-types.d.ts.map +1 -1
  48. package/dist/types.d.ts +143 -32
  49. package/dist/types.d.ts.map +1 -1
  50. package/package.json +2 -2
  51. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +277 -33
  52. package/src/__tests__/client-js-generation.test.ts +18 -5
  53. package/src/__tests__/compiler-runtime-contract.test.ts +4 -4
  54. package/src/__tests__/compiler-stress-1244.test.ts +12 -1
  55. package/src/__tests__/delegated-handler-preamble.test.ts +153 -0
  56. package/src/__tests__/event-delegation-scope-2367.test.ts +108 -0
  57. package/src/__tests__/flatmap-segments.test.ts +262 -0
  58. package/src/__tests__/map-arbitrary-body.test.ts +226 -0
  59. package/src/__tests__/map-body-no-silent-divergence.test.ts +405 -0
  60. package/src/__tests__/map-multi-return-body.test.ts +172 -0
  61. package/src/__tests__/preamble-region-patch.test.ts +150 -0
  62. package/src/__tests__/static-loop-csr-materialize.test.ts +3 -2
  63. package/src/__tests__/unsupported-expression.test.ts +20 -2
  64. package/src/adapters/interface.ts +40 -0
  65. package/src/adapters/jsx-adapter.ts +10 -0
  66. package/src/adapters/loop-bound-names.ts +6 -2
  67. package/src/adapters/parsed-expr-emitter.ts +5 -10
  68. package/src/adapters/test-adapter.ts +10 -0
  69. package/src/analyzer-context.ts +35 -1
  70. package/src/analyzer.ts +162 -24
  71. package/src/compiler.ts +2 -2
  72. package/src/expression-parser.ts +27 -19
  73. package/src/ir-to-client-js/build-references.ts +19 -2
  74. package/src/ir-to-client-js/collect-elements.ts +69 -13
  75. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +14 -1
  76. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +18 -5
  77. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +4 -0
  78. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +15 -2
  79. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +32 -3
  80. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +26 -3
  81. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +53 -2
  82. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +16 -1
  83. package/src/ir-to-client-js/control-flow/plan/loop.ts +40 -0
  84. package/src/ir-to-client-js/control-flow/shared.ts +28 -2
  85. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +25 -3
  86. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +76 -21
  87. package/src/ir-to-client-js/control-flow/stringify/loop.ts +65 -1
  88. package/src/ir-to-client-js/control-flow.ts +11 -0
  89. package/src/ir-to-client-js/html-template.ts +244 -39
  90. package/src/ir-to-client-js/imports.ts +1 -1
  91. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +22 -10
  92. package/src/ir-to-client-js/reactivity.ts +6 -0
  93. package/src/ir-to-client-js/types.ts +28 -3
  94. package/src/jsx-to-ir.ts +1059 -167
  95. package/src/loop-destructure.ts +9 -5
  96. package/src/rich-type-refusal.ts +6 -2
  97. package/src/strip-types.ts +47 -0
  98. package/src/types.ts +159 -35
@@ -6,7 +6,7 @@
6
6
  * if (<container>) <container>.addEventListener('<eventName>', (__bfEvt) => {
7
7
  * const target = __bfEvt.target
8
8
  * const <slot>El = target.closest('[bf="<slotId>"]')
9
- * if (<slot>El) {
9
+ * if (<slot>El && <container>.contains(<slot>El)) {
10
10
  * <item-lookup specific>
11
11
  * <handlerCall>
12
12
  * return
@@ -74,6 +74,26 @@ function indexBindingLine(handler: string, indexParam: string | null, indexExpr:
74
74
  return `const ${indexParam} = ${indexExpr}`
75
75
  }
76
76
 
77
+ /**
78
+ * Splice-only-when-referenced (#3, BUG-3 fix part 2): a preamble is dead
79
+ * weight for an event whose handler never reads one of the names it
80
+ * declares — the common case (an unused array builder like `cells` in the
81
+ * BUG-3 repro). Only emit `mapPreamble` when `handler`'s free identifiers
82
+ * intersect `declaredNames`; otherwise the delegated handler pays nothing
83
+ * for a preamble it never uses. `declaredNames` is empty whenever
84
+ * `mapPreamble` is `null`, so the `!mapPreamble` short-circuit is mostly
85
+ * redundant but keeps this correct-by-construction if that ever changes.
86
+ */
87
+ function preambleLineForHandler(
88
+ mapPreamble: string | null,
89
+ declaredNames: readonly string[],
90
+ handler: string,
91
+ ): string | null {
92
+ if (!mapPreamble || declaredNames.length === 0) return null
93
+ const free = extractFreeIdentifiersFromText(handler)
94
+ return declaredNames.some((name) => free.has(name)) ? mapPreamble : null
95
+ }
96
+
77
97
  export function stringifyEventDelegation(lines: string[], plan: EventDelegationPlan): void {
78
98
  const { containerVar, events, itemLookup, profileComponentName } = plan
79
99
  const eventsByName = new Map<string, LoopChildEvent[]>()
@@ -94,8 +114,15 @@ export function stringifyEventDelegation(lines: string[], plan: EventDelegationP
94
114
  lines.push(` const target = __bfEvt.target`)
95
115
  for (const ev of evs) {
96
116
  const childVar = varSlotId(ev.childSlotId)
117
+ // Bound the slot match to the delegating container (#2367). `bf` ids are
118
+ // unique only *within* a component, so an unscoped
119
+ // `target.closest('[bf="sN"]')` can match a same-id element in an ancestor
120
+ // component and silently take the wrong branch. The container holds every
121
+ // slot it delegates on, so a same-id ancestor is never a descendant of it —
122
+ // the `container.contains(...)` guard rejects the foreign match and the
123
+ // handler falls through to the correct branch.
97
124
  lines.push(` const ${childVar}El = target.closest('[bf="${ev.childSlotId}"]')`)
98
- lines.push(` if (${childVar}El) {`)
125
+ lines.push(` if (${childVar}El && ${containerVar}.contains(${childVar}El)) {`)
99
126
  const handlerCall = withTurn(`(${ev.handler.trim()})(__bfEvt)`, profileComponentName, ev.childSlotId, ev.eventName)
100
127
  switch (itemLookup.kind) {
101
128
  case 'keyed':
@@ -126,7 +153,8 @@ function emitKeyedLookup(
126
153
  handlerCall: string,
127
154
  lookup: KeyedItemLookup,
128
155
  ): void {
129
- const { arrayExpr, param, keyWithItem, mapPreamble, hasBindings, indexParam } = lookup
156
+ const { arrayExpr, param, keyWithItem, mapPreamble, mapPreambleDeclaredNames, hasBindings, indexParam } = lookup
157
+ const preambleLine = preambleLineForHandler(mapPreamble, mapPreambleDeclaredNames, ev.handler)
130
158
 
131
159
  if (ev.nestedLoops.length === 0) {
132
160
  // Single-level keyed lookup.
@@ -139,15 +167,28 @@ function emitKeyedLookup(
139
167
  ls.push(` const __bfLoopItem = ${arrayExpr}.find(item => String(${keyWithItem}) === key)`)
140
168
  ls.push(` if (__bfLoopItem) {`)
141
169
  ls.push(` const ${param} = __bfLoopItem`)
142
- if (mapPreamble) ls.push(` ${mapPreamble}`)
170
+ if (preambleLine) ls.push(` ${preambleLine}`)
143
171
  if (idxLine) ls.push(` ${idxLine}`)
144
- ls.push(` ${handlerCall}`)
172
+ // Leading `;` (not just relying on the preceding line's own
173
+ // semicolon): `handlerCall` always starts with `(` — an ASI hazard
174
+ // pre-existing in this branch even with neither optional line above
175
+ // (`const ${param} = __bfLoopItem` has no trailing `;`), which glues
176
+ // the call onto it as `__bfLoopItem(...)` and throws
177
+ // `TypeError: __bfLoopItem is not a function`. Defend at the one
178
+ // emission point rather than chasing every preceding-line shape.
179
+ ls.push(` ;${handlerCall}`)
145
180
  ls.push(` }`)
146
181
  } else {
182
+ // The preamble (when referenced) and the handler call both run INSIDE
183
+ // the item null guard — a `.find()` miss (stale-DOM race, e.g. the
184
+ // clicked row's key no longer in the current array) must short-circuit
185
+ // before a preamble that dereferences the item ever runs (BUG-4).
147
186
  ls.push(` const ${param} = ${arrayExpr}.find(item => String(${keyWithItem}) === key)`)
148
- if (mapPreamble) ls.push(` ${mapPreamble}`)
149
- if (idxLine) ls.push(` if (${param}) { ${idxLine}; ${handlerCall} }`)
150
- else ls.push(` if (${param}) ${handlerCall}`)
187
+ ls.push(` if (${param}) {`)
188
+ if (preambleLine) ls.push(` ${preambleLine}`)
189
+ if (idxLine) ls.push(` ${idxLine}`)
190
+ ls.push(` ;${handlerCall}`)
191
+ ls.push(` }`)
151
192
  }
152
193
  ls.push(` }`)
153
194
  return
@@ -180,10 +221,14 @@ function emitKeyedLookup(
180
221
  }
181
222
  const outerGuard = hasBindings ? '__bfLoopItem' : param
182
223
  const allParams = [outerGuard, ...ev.nestedLoops.map(n => n.param)]
183
- if (mapPreamble) ls.push(` ${mapPreamble}`)
224
+ // Preamble and idx binding run INSIDE the combined item guard (BUG-4)
225
+ // a nested `.find()` miss must short-circuit before the preamble runs.
184
226
  const idxLine = indexBindingLine(ev.handler, indexParam, `${arrayExpr}.findIndex(item => String(${keyWithItem}) === outerKey)`)
185
- if (idxLine) ls.push(` if (${allParams.join(' && ')}) { ${idxLine}; ${handlerCall} }`)
186
- else ls.push(` if (${allParams.join(' && ')}) ${handlerCall}`)
227
+ ls.push(` if (${allParams.join(' && ')}) {`)
228
+ if (preambleLine) ls.push(` ${preambleLine}`)
229
+ if (idxLine) ls.push(` ${idxLine}`)
230
+ ls.push(` ;${handlerCall}`)
231
+ ls.push(` }`)
187
232
  }
188
233
 
189
234
  function emitDynamicIndexLookup(
@@ -192,7 +237,8 @@ function emitDynamicIndexLookup(
192
237
  handlerCall: string,
193
238
  lookup: DynamicIndexItemLookup,
194
239
  ): void {
195
- const { arrayExpr, param, mapPreamble, hasBindings, indexParam } = lookup
240
+ const { arrayExpr, param, mapPreamble, mapPreambleDeclaredNames, hasBindings, indexParam } = lookup
241
+ const preambleLine = preambleLineForHandler(mapPreamble, mapPreambleDeclaredNames, ev.handler)
196
242
  const idxLine = indexBindingLine(ev.handler, indexParam, 'idx')
197
243
  ls.push(` const li = ${varSlotId(ev.childSlotId)}El.closest('li, [bf-i]')`)
198
244
  ls.push(` if (li && li.parentElement) {`)
@@ -201,15 +247,19 @@ function emitDynamicIndexLookup(
201
247
  ls.push(` const __bfLoopItem = ${arrayExpr}[idx]`)
202
248
  ls.push(` if (__bfLoopItem) {`)
203
249
  ls.push(` const ${param} = __bfLoopItem`)
204
- if (mapPreamble) ls.push(` ${mapPreamble}`)
250
+ if (preambleLine) ls.push(` ${preambleLine}`)
205
251
  if (idxLine) ls.push(` ${idxLine}`)
206
- ls.push(` ${handlerCall}`)
252
+ ls.push(` ;${handlerCall}`)
207
253
  ls.push(` }`)
208
254
  } else {
255
+ // Preamble and idx binding run INSIDE the item null guard (BUG-4) — an
256
+ // out-of-range index (stale-DOM race) must short-circuit first.
209
257
  ls.push(` const ${param} = ${arrayExpr}[idx]`)
210
- if (mapPreamble) ls.push(` ${mapPreamble}`)
211
- if (idxLine) ls.push(` if (${param}) { ${idxLine}; ${handlerCall} }`)
212
- else ls.push(` if (${param}) ${handlerCall}`)
258
+ ls.push(` if (${param}) {`)
259
+ if (preambleLine) ls.push(` ${preambleLine}`)
260
+ if (idxLine) ls.push(` ${idxLine}`)
261
+ ls.push(` ;${handlerCall}`)
262
+ ls.push(` }`)
213
263
  }
214
264
  ls.push(` }`)
215
265
  }
@@ -221,7 +271,8 @@ function emitStaticIndexLookup(
221
271
  lookup: StaticIndexItemLookup,
222
272
  containerVar: string,
223
273
  ): void {
224
- const { arrayExpr, param, mapPreamble, offset, indexParam } = lookup
274
+ const { arrayExpr, param, mapPreamble, mapPreambleDeclaredNames, offset, indexParam } = lookup
275
+ const preambleLine = preambleLineForHandler(mapPreamble, mapPreambleDeclaredNames, ev.handler)
225
276
  const idxLine = indexBindingLine(ev.handler, indexParam, '__idx')
226
277
  ls.push(` let __el = ${varSlotId(ev.childSlotId)}El`)
227
278
  ls.push(` while (__el.parentElement && __el.parentElement !== ${containerVar}) __el = __el.parentElement`)
@@ -229,8 +280,12 @@ function emitStaticIndexLookup(
229
280
  const idxOffset = buildLoopChildIndexSubtraction(offset ?? undefined)
230
281
  ls.push(` const __idx = Array.from(${containerVar}.children).indexOf(__el)${idxOffset}`)
231
282
  ls.push(` const ${param} = ${arrayExpr}[__idx]`)
232
- if (mapPreamble) ls.push(` ${mapPreamble}`)
233
- if (idxLine) ls.push(` if (${param}) { ${idxLine}; ${handlerCall} }`)
234
- else ls.push(` if (${param}) ${handlerCall}`)
283
+ // Preamble and idx binding run INSIDE the item null guard (BUG-4) — an
284
+ // out-of-range index (stale-DOM race) must short-circuit first.
285
+ ls.push(` if (${param}) {`)
286
+ if (preambleLine) ls.push(` ${preambleLine}`)
287
+ if (idxLine) ls.push(` ${idxLine}`)
288
+ ls.push(` ;${handlerCall}`)
289
+ ls.push(` }`)
235
290
  ls.push(` }`)
236
291
  }
@@ -32,6 +32,7 @@ import { buildSkeletonPathPlan, type SkeletonPathPlan } from './skeleton-paths.t
32
32
  import { stringifyComponentLoop } from './component-loop.ts'
33
33
  import { stringifyCompositeLoop } from './composite-loop.ts'
34
34
  import type { LoopChildRefBinding, LoopPlan, PlainLoopPlan, StaticLoopPlan } from '../plan/types.ts'
35
+ import type { PreambleRegionPlan } from '../plan/loop.ts'
35
36
 
36
37
  /**
37
38
  * Emit `(callback)(__rf)` for each ref on a per-item slot, looking up the
@@ -64,6 +65,40 @@ export function emitLoopChildRefs(
64
65
  }
65
66
  }
66
67
 
68
+ /**
69
+ * Emit the region-patch effect for each preamble-patched region (#2389 —
70
+ * `arr.map(t => { const cells = []; ...; return <tr>{cells}<td>{t.name}</td></tr> })`).
71
+ * The effect re-runs the (loop-param-accessor-wrapped) preamble on every
72
+ * reactive tick so it re-reads the current per-item signal, recomputes the
73
+ * region's value, and — past the FIRST run (which only records, trusting
74
+ * the SSR/CSR mount-time content already in the DOM) — patches the DOM
75
+ * range via `patchSlotRange(__el, 'sN', html)`. The marker lookup lives
76
+ * inside `patchSlotRange`, so a row that never changes pays zero lookup
77
+ * cost at mount/adoption.
78
+ *
79
+ * Non-empty `regions` forces the caller's multi-line renderItem layout (the
80
+ * effect needs `__el` as a query root), mirroring `emitLoopChildRefs`.
81
+ */
82
+ export function emitPreambleRegionEffects(
83
+ lines: string[],
84
+ regions: readonly PreambleRegionPlan[],
85
+ mapPreambleWrapped: string,
86
+ opts: { indent: string; elVar: string },
87
+ ): void {
88
+ if (regions.length === 0) return
89
+ const { indent, elVar } = opts
90
+ for (const region of regions) {
91
+ const v = varSlotId(region.slotId)
92
+ lines.push(`${indent}{ let __last_${v}`)
93
+ lines.push(`${indent}createEffect(() => {`)
94
+ if (mapPreambleWrapped) lines.push(`${indent} ${mapPreambleWrapped}`)
95
+ lines.push(`${indent} const __html_${v} = ${region.valueExpr}`)
96
+ lines.push(`${indent} if (__last_${v} === undefined) { __last_${v} = __html_${v}; return }`)
97
+ lines.push(`${indent} if (__html_${v} !== __last_${v}) { __last_${v} = __html_${v}; patchSlotRange(${elVar}, '${region.slotId}', __html_${v}) }`)
98
+ lines.push(`${indent}}) }`)
99
+ }
100
+ }
101
+
67
102
  /**
68
103
  * Single dispatch over `LoopPlan` (#1253). Narrows on `plan.kind` and
69
104
  * delegates to the per-variant stringifier. Callers should consume this
@@ -128,8 +163,33 @@ export function stringifyPlainLoop(
128
163
  bodyIsMultiRoot,
129
164
  anchored,
130
165
  anchorKeyExpr,
166
+ preambleRegions,
131
167
  } = plan
132
168
 
169
+ // flatMap descriptor mode: the accessor flattens the source through the
170
+ // callback body (each leaf a `({ k, h })` descriptor), mapArray keys on
171
+ // `d.k`, and each item element is built from — and patched against — its
172
+ // rendered HTML. Hydration adopts the SSR leaf in place (`__last` seeds
173
+ // undefined so the first effect run records without patching — trust SSR,
174
+ // same contract as plain rows); a later same-key HTML change patches the
175
+ // element wholesale via `patchLeaf` (flatMap leaves carry no per-slot
176
+ // wiring by construction — the compiler refuses leaves that would).
177
+ if (plan.flatMapLeafItem) {
178
+ const loopBfIdArg = plan.profileLoopId ? `, ${JSON.stringify(plan.profileLoopId)}` : ''
179
+ lines.push(`${topIndent}mapArray(() => ${arrayExpr}, ${containerVar}, ${keyFn}, (__bfD, ${indexParam}, __existing) => {`)
180
+ lines.push(`${topIndent} let __el = __existing`)
181
+ lines.push(`${topIndent} if (!__el) { const __tpl = document.createElement('template'); __tpl.innerHTML = __bfD().h; __el = __tpl.content.firstElementChild }`)
182
+ lines.push(`${topIndent} let __last = __existing ? undefined : __bfD().h`)
183
+ lines.push(`${topIndent} createEffect(() => {`)
184
+ lines.push(`${topIndent} const __html = __bfD().h`)
185
+ lines.push(`${topIndent} if (__last === undefined) { __last = __html; return }`)
186
+ lines.push(`${topIndent} if (__html !== __last) { __last = __html; patchLeaf(__el, __html) }`)
187
+ lines.push(`${topIndent} })`)
188
+ lines.push(`${topIndent} return __el`)
189
+ lines.push(`${topIndent}}, '${markerId}'${loopBfIdArg})`)
190
+ return
191
+ }
192
+
133
193
  // Whole-item conditional loops (#1665) render 0-or-1 element per item, so
134
194
  // they route through `mapArrayAnchored`. The renderItem returns a fragment
135
195
  // headed by a `<!--bf-loop-i:KEY-->` anchor and seeded with the
@@ -158,7 +218,10 @@ export function stringifyPlainLoop(
158
218
  // the factory, so non-empty refs force the multi-line layout the same way
159
219
  // reactive effects do (#1244).
160
220
  const loopBfId = plan.profileLoopId ? `, ${JSON.stringify(plan.profileLoopId)}` : ''
161
- if (reactiveEffects === null && !bodyIsMultiRoot && childRefs.length === 0) {
221
+ // Preamble-patched regions (#2389) need `__el` as a query root for their
222
+ // effect, so non-empty regions force the multi-line layout — same
223
+ // precedent as `childRefs` above.
224
+ if (reactiveEffects === null && !bodyIsMultiRoot && childRefs.length === 0 && preambleRegions.length === 0) {
162
225
  // Single-line renderItem (no reactive effects, single root, no refs).
163
226
  const unwrapInline = paramUnwrap ? `${paramUnwrap} ` : ''
164
227
  const preamble = mapPreambleWrapped ? `${mapPreambleWrapped}; ` : ''
@@ -217,6 +280,7 @@ export function stringifyPlainLoop(
217
280
  })
218
281
  }
219
282
  emitLoopChildRefs(lines, childRefs, { indent: bodyIndent, elVar: '__el', bodyIsMultiRoot, elementIndexBySlot: pathPlan?.elementIndexBySlot })
283
+ emitPreambleRegionEffects(lines, preambleRegions, mapPreambleWrapped, { indent: bodyIndent, elVar: '__el' })
220
284
  lines.push(`${bodyIndent}return __el`)
221
285
  lines.push(`${topIndent}}, '${markerId}'${loopBfId})`)
222
286
  }
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import type { ClientJsContext, TopLevelLoop } from './types.ts'
24
+ import { internalInvariant } from '../errors.ts'
24
25
  import { buildInsertPlan } from './control-flow/plan/build-insert.ts'
25
26
  import { stringifyInsert } from './control-flow/stringify/insert.ts'
26
27
  import { buildLoopPlan } from './control-flow/plan/build-loop.ts'
@@ -70,6 +71,16 @@ export function emitLoopUpdates(lines: string[], ctx: ClientJsContext, unsafeLoc
70
71
  unsafeLocalNames,
71
72
  profileComponentName: ctx.profile ? ctx.componentName : undefined,
72
73
  })
74
+ // Stage 3 root cure — a JSX-bearing preamble can only be spliced into a
75
+ // string-templated row (renderPreamble). Every shape that reaches a
76
+ // 'dom-ops' variant today is already refused in Phase 1 with a proper
77
+ // source location; this backstop exists for FUTURE variants, so a new
78
+ // plan kind that declares 'dom-ops' cannot silently drop the preamble —
79
+ // it fails the build here instead.
80
+ internalInvariant(
81
+ !(elem.preamble && elem.preamble.builderNames.length > 0 && plan.rowConstruction === 'dom-ops'),
82
+ `loop variant '${plan.kind}' declares dom-ops row construction but received a JSX-bearing preamble — add a Phase-1 refusal (or wire renderPreamble support) for this shape`,
83
+ )
73
84
  stringifyLoop(lines, plan)
74
85
  emitLoopEventDelegation(lines, elem, plan.kind, ctx.profile ? ctx.componentName : undefined)
75
86
  }