@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
@@ -70,7 +70,12 @@ import {
70
70
  emitTemplateCloneInline,
71
71
  hoistedCloneExpr,
72
72
  } from './template-parse.ts'
73
- import type { LazyRowAttrBinding, LazyRowPlanData, LazyRowTextBinding } from '../plan/build-lazy-row.ts'
73
+ import type {
74
+ LazyRowAttrBinding,
75
+ LazyRowConditionalBinding,
76
+ LazyRowPlanData,
77
+ LazyRowTextBinding,
78
+ } from '../plan/build-lazy-row.ts'
74
79
 
75
80
  export interface StringifyLazyRowOptions {
76
81
  /** Indent of the `mapArrayLazy(` line itself. */
@@ -94,9 +99,11 @@ export function stringifyLazyRowLoop(lines: string[], o: StringifyLazyRowOptions
94
99
  const { indent, lazyRow, paramHead } = o
95
100
  const mid = o.markerId.replace(/[^A-Za-z0-9_$]/g, '_')
96
101
  const tplVar = `__tpl_${mid}`
97
- const claimVar = `__lzc_${mid}`
98
102
  const hasRefs = lazyRow.attrSlotIds.length > 0 || lazyRow.texts.length > 0
99
- const hasBindings = lazyRow.attrs.length > 0 || lazyRow.texts.length > 0
103
+ // Conditionals count: `createRow` seeds their dedup boolean into `__l`, so a
104
+ // row whose ONLY reactive thing is a conditional still needs `__l` declared.
105
+ const hasBindings =
106
+ lazyRow.attrs.length > 0 || lazyRow.texts.length > 0 || lazyRow.conditionals.length > 0
100
107
  // ONE per-loop door decision (see `refParts`): the read-capable claim, or
101
108
  // today's write-only writer. Never decided per binding — the door is
102
109
  // allocated once per ROW.
@@ -165,28 +172,17 @@ export function stringifyLazyRowLoop(lines: string[], o: StringifyLazyRowOptions
165
172
  }
166
173
  }
167
174
 
168
- // Lazy ref claim for ADOPTED (SSR) rows: one scan inside that row, cached
169
- // on `entry.refs`. Shared by applyItem and applyOuter so a row claims once.
170
- //
171
- // The text door slot is left EMPTY here and filled by the first content
172
- // write (`doorAccess`). An adopted row is claimed by whichever of
173
- // applyItem/applyOuter touches it first, and an `applyOuter` that only
174
- // drives ATTRIBUTES never reads or writes a content slot so building the
175
- // door in this function spends one closure per row of the list on something
176
- // that stays unused until (and unless) that row's item changes. Deferring
177
- // is confined to the adopted path on purpose: `createRow` writes every text
178
- // immediately, so its door is used on the same tick it is built and there
179
- // is nothing to defer (see the `deferDoor` note on `refParts`).
180
- if (hasRefs) {
181
- // With the door deferred, the ELEMENT refs are the only parts that read the
182
- // row root — so a text-only row (no reactive-attr slot) claims to a bare
183
- // `[null]` and the `__el` binding would be dead. `parts` decides, rather
184
- // than a second predicate that could drift from `refParts`.
185
- const parts = refParts(lazyRow, '__el', null, adoptedPlanVar, true)
186
- lines.push(`${indent}const ${claimVar} = (__e) => {`)
187
- if (parts.some(p => p.includes('__el'))) lines.push(`${indent} const __el = __e.primaryEl`)
188
- lines.push(`${indent} return [${parts.join(', ')}]`)
189
- lines.push(`${indent}}`)
175
+ // Hoisted arm templates, one pair per conditional. Both arms are static by
176
+ // construction (`analyzeLazyConditional`), so each is parsed ONCE per loop and
177
+ // cloned on a flip — a per-row `innerHTML` parse would undo the point of
178
+ // driving the swap from the loop level. The comparison the seed run makes is
179
+ // against `content.firstElementChild.outerHTML`, i.e. the browser's OWN
180
+ // serialization of the same markup, so it cannot false-mismatch on attribute
181
+ // order or quoting the way comparing against the authored string would.
182
+ for (const c of lazyRow.conditionals) {
183
+ const v = condVars(mid, c.slotId)
184
+ emitHoistedTemplateDecl(lines, indent, v.trueTpl, c.whenTrueHtml)
185
+ emitHoistedTemplateDecl(lines, indent, v.falseTpl, c.whenFalseHtml)
190
186
  }
191
187
 
192
188
  const call = `mapArrayLazy(() => ${o.arrayExpr}, ${o.containerVar}, ${o.keyFn}, {`
@@ -200,6 +196,9 @@ export function stringifyLazyRowLoop(lines: string[], o: StringifyLazyRowOptions
200
196
  // interpolates the per-row template, which reads the param for at least
201
197
  // the `data-key` attribute.
202
198
  lines.push(`${b2}const ${paramHead} = () => __e.item`)
199
+ // The row's `.map()` callback preamble, before the clone: a non-hoisted
200
+ // per-row template interpolates values the preamble declares.
201
+ if (lazyRow.preambleStatements) lines.push(`${b2}${lazyRow.preambleStatements}`)
203
202
  const cloneExpr = useHoisted
204
203
  ? hoistedCloneExpr(tplVar, o.skeletonTemplate!)
205
204
  : `(() => { ${emitTemplateCloneInline(o.template)} })()`
@@ -213,31 +212,42 @@ export function stringifyLazyRowLoop(lines: string[], o: StringifyLazyRowOptions
213
212
  const createDoor = `__r[${lazyRow.writerIndex}]`
214
213
  for (const t of lazyRow.texts) emitTextBinding(lines, b2, t, createDoor, 'create', rwDoor)
215
214
  }
215
+ for (const c of lazyRow.conditionals) emitConditional(lines, b2, mid, c, 'create')
216
216
  lines.push(`${b2}return __el`)
217
217
  lines.push(`${b1}},`)
218
218
 
219
219
  // --- applyItem ---------------------------------------------------------
220
220
  const itemAttrs = lazyRow.attrs.filter(a => a.readsItem)
221
221
  const itemTexts = lazyRow.texts.filter(t => t.readsItem)
222
- if (itemAttrs.length === 0 && itemTexts.length === 0) {
222
+ const itemConds = lazyRow.conditionals.filter(c => c.readsItem)
223
+ if (itemAttrs.length === 0 && itemTexts.length === 0 && itemConds.length === 0) {
223
224
  lines.push(`${b1}applyItem: () => {},`)
224
225
  } else {
225
226
  lines.push(`${b1}applyItem: (__e) => {`)
226
227
  lines.push(`${b2}const ${paramHead} = () => __e.item`)
227
- lines.push(`${b2}const __r = __e.refs ?? (__e.refs = ${claimVar}(__e))`)
228
+ lines.push(`${b2}const __r = __e.refs ?? (__e.refs = [])`)
228
229
  lines.push(`${b2}const __l = __e.last ?? (__e.last = [])`)
230
+ // Re-run the preamble only when a binding in THIS body reads a local it
231
+ // declares (#2447 follow-up). `mapArrayLazy` wraps `applyItem` in
232
+ // `untrack()`, so a signal read inside the preamble subscribes nothing
233
+ // here — the subscription is `applyOuter`'s job.
234
+ if (lazyRow.itemNeedsPreamble && lazyRow.preambleStatements) {
235
+ lines.push(`${b2}${lazyRow.preambleStatements}`)
236
+ }
229
237
  for (const a of itemAttrs) emitAttrBinding(lines, b2, a, 'item')
230
238
  if (itemTexts.length > 0) {
231
239
  lines.push(`${b2}const __d = ${doorAccess(lazyRow, lazyRow.writerIndex, adoptedPlanVar)}`)
232
240
  for (const t of itemTexts) emitTextBinding(lines, b2, t, '__d', 'item', rwDoor)
233
241
  }
242
+ for (const c of itemConds) emitConditional(lines, b2, mid, c, 'item')
234
243
  lines.push(`${b1}},`)
235
244
  }
236
245
 
237
246
  // --- applyOuter (only when some binding is outer-involving) -------------
238
247
  const outerAttrs = lazyRow.attrs.filter(a => a.readsOuter)
239
248
  const outerTexts = lazyRow.texts.filter(t => t.readsOuter)
240
- if (outerAttrs.length > 0 || outerTexts.length > 0) {
249
+ const outerConds = lazyRow.conditionals.filter(c => c.readsOuter)
250
+ if (outerAttrs.length > 0 || outerTexts.length > 0 || outerConds.length > 0) {
241
251
  const b3 = `${indent} `
242
252
  lines.push(`${b1}applyOuter: (__es, __seed) => {`)
243
253
  // Prime the outer reads so this ONE loop-level effect subscribes even
@@ -245,13 +255,20 @@ export function stringifyLazyRowLoop(lines: string[], o: StringifyLazyRowOptions
245
255
  for (const g of lazyRow.outerPrimeGetters) lines.push(`${b2}${g}()`)
246
256
  lines.push(`${b2}for (const __e of __es) {`)
247
257
  lines.push(`${b3}const ${paramHead} = () => __e.item`)
248
- lines.push(`${b3}const __r = __e.refs ?? (__e.refs = ${claimVar}(__e))`)
258
+ lines.push(`${b3}const __r = __e.refs ?? (__e.refs = [])`)
249
259
  lines.push(`${b3}const __l = __e.last ?? (__e.last = [])`)
260
+ // Per ENTRY, not once for the batch: the preamble reads the item, so its
261
+ // locals differ per row. The outer signals it reads are already primed
262
+ // above, so this effect subscribes even when `__es` is empty.
263
+ if (lazyRow.outerNeedsPreamble && lazyRow.preambleStatements) {
264
+ lines.push(`${b3}${lazyRow.preambleStatements}`)
265
+ }
250
266
  for (const a of outerAttrs) emitAttrBinding(lines, b3, a, 'outer')
251
267
  if (outerTexts.length > 0) {
252
268
  lines.push(`${b3}const __d = ${doorAccess(lazyRow, lazyRow.writerIndex, adoptedPlanVar)}`)
253
269
  for (const t of outerTexts) emitTextBinding(lines, b3, t, '__d', 'outer', rwDoor)
254
270
  }
271
+ for (const c of outerConds) emitConditional(lines, b3, mid, c, 'outer')
255
272
  lines.push(`${b2}}`)
256
273
  lines.push(`${b1}},`)
257
274
  }
@@ -260,9 +277,10 @@ export function stringifyLazyRowLoop(lines: string[], o: StringifyLazyRowOptions
260
277
  }
261
278
 
262
279
  /**
263
- * The `entry.refs` array contents: one element ref per reactive-attr slot
264
- * (in `attrSlotIds` order), then — when the row has text slots — the
265
- * claimed-slot door at `writerIndex`.
280
+ * The `entry.refs` array `createRow` seeds: one element ref per reactive-attr
281
+ * slot (in `attrSlotIds` order), then — when the row has text slots — the
282
+ * claimed-slot door at `writerIndex`. Fresh-clone context only; an ADOPTED row
283
+ * starts empty and fills each slot on demand (`elementAccess` / `doorAccess`).
266
284
  *
267
285
  * **Which door (per LOOP, never per binding).** `lazySlots` returns a bare
268
286
  * write function; `lazyClaimSlots` returns the `{ write, read }` pair over
@@ -312,7 +330,6 @@ function refParts(
312
330
  elVar: string,
313
331
  skeletonPaths: SkeletonSlotPaths | null,
314
332
  planVar: string | null,
315
- deferDoor = false,
316
333
  ): string[] {
317
334
  const parts: string[] = []
318
335
  for (const slotId of lazyRow.attrSlotIds) {
@@ -320,11 +337,31 @@ function refParts(
320
337
  parts.push(path ? pathExpr(elVar, path) : `qsa(${elVar}, '[bf="${slotId}"]')`)
321
338
  }
322
339
  if (lazyRow.texts.length > 0) {
323
- parts.push(deferDoor ? 'null' : `${doorCtor(lazyRow)}(${elVar}, ${planVar})`)
340
+ parts.push(`${doorCtor(lazyRow)}(${elVar}, ${planVar})`)
324
341
  }
325
342
  return parts
326
343
  }
327
344
 
345
+ /**
346
+ * How an ADOPTED row's binding reaches its element ref: claim THIS slot, and
347
+ * only this slot, on first use.
348
+ *
349
+ * The previous shape claimed the whole row in one closure (`__lzc_<mid>`),
350
+ * which meant an `applyOuter` driving ONE attribute still ran a `qsa` scan for
351
+ * every OTHER reactive-attr slot in the row — on every row, at hydration. The
352
+ * `in` test (not `??`) is what makes the cache honest: `qsa` answers `null` for
353
+ * a slot it cannot find, and `null` is a real cached answer — the `in` test
354
+ * treats it as one and never scans again, where a `??`-guarded cache would
355
+ * re-scan that slot on every tick forever.
356
+ *
357
+ * This is the element-ref twin of `doorAccess`, which already deferred the
358
+ * content door for exactly the same reason.
359
+ */
360
+ function elementAccess(a: LazyRowAttrBinding): string {
361
+ const slot = `__r[${a.refIndex}]`
362
+ return `${a.refIndex} in __r ? ${slot} : (${slot} = qsa(__e.primaryEl, '[bf="${a.slotId}"]'))`
363
+ }
364
+
328
365
  /** The door constructor for this loop — see `refParts`'s "which door" note. */
329
366
  function doorCtor(lazyRow: LazyRowPlanData): string {
330
367
  return lazyRow.textNeedsRead ? 'lazyClaimSlots' : 'lazySlots'
@@ -354,6 +391,63 @@ function doorAccess(
354
391
  return `${slot} ?? (${slot} = ${doorCtor(lazyRow)}(__e.primaryEl, ${adoptedPlanVar}))`
355
392
  }
356
393
 
394
+ /** Hoisted arm-template variable names for one conditional of one loop. */
395
+ function condVars(mid: string, slotId: string): { trueTpl: string; falseTpl: string } {
396
+ const key = `${mid}_${slotId.replace(/[^A-Za-z0-9_$]/g, '_')}`
397
+ return { trueTpl: `__cbt_${key}`, falseTpl: `__cbf_${key}` }
398
+ }
399
+
400
+ /**
401
+ * A row conditional, in the same three modes as {@link emitAttrBinding}.
402
+ *
403
+ * The whole swap is `replaceWith` on the `[bf-c]` element, because
404
+ * `analyzeLazyConditional` has already established that the arms own nothing —
405
+ * no events, no children to init, no live nodes to splice. What `insert()` does
406
+ * per row with an effect of its own, this does with a boolean and a dedup slot.
407
+ *
408
+ * **`'create'` writes no DOM.** The row `createRow` just cloned came from the
409
+ * per-row template, which interpolated `cond ? trueHtml : falseHtml` itself — so
410
+ * the correct arm is already in place and the only thing missing is the dedup
411
+ * record. Swapping here would replace an element with an identical one.
412
+ *
413
+ * **`'outer'` seeds by read-compare-write (§9.3(1))** against
414
+ * `content.firstElementChild.outerHTML` — the browser's serialization of the arm
415
+ * we would install, compared with the serialization of what is there. Both sides
416
+ * go through the DOM, so a match means the server already rendered this arm and
417
+ * no write happens.
418
+ *
419
+ * The ref is REASSIGNED after a swap (`__r[i] = __n`). Without that, the next
420
+ * apply would hold the detached node and write into nothing.
421
+ */
422
+ function emitConditional(
423
+ lines: string[],
424
+ ind: string,
425
+ mid: string,
426
+ c: LazyRowConditionalBinding,
427
+ mode: 'create' | 'item' | 'outer',
428
+ ): void {
429
+ const v = condVars(mid, c.slotId)
430
+ if (mode === 'create') {
431
+ lines.push(`${ind}__l[${c.ordinal}] = !!(${c.wrappedCondition})`)
432
+ return
433
+ }
434
+ const slot = `__r[${c.refIndex}]`
435
+ lines.push(`${ind}{ const __c = ${c.refIndex} in __r ? ${slot} : (${slot} = qsa(__e.primaryEl, '[bf-c="${c.slotId}"]'))`)
436
+ lines.push(`${ind}if (__c) {`)
437
+ lines.push(`${ind} const __x = !!(${c.wrappedCondition})`)
438
+ lines.push(`${ind} const __w = (__x ? ${v.trueTpl} : ${v.falseTpl}).content.firstElementChild`)
439
+ const guard = mode === 'item'
440
+ ? dedupGuard(c.ordinal)
441
+ : `__seed ? (__c.outerHTML !== __w.outerHTML) : (${dedupGuard(c.ordinal)})`
442
+ lines.push(`${ind} if (${guard}) {`)
443
+ lines.push(`${ind} const __n = __w.cloneNode(true)`)
444
+ lines.push(`${ind} __c.replaceWith(__n)`)
445
+ lines.push(`${ind} ${slot} = __n`)
446
+ lines.push(`${ind} }`)
447
+ lines.push(`${ind} __l[${c.ordinal}] = __x`)
448
+ lines.push(`${ind}} }`)
449
+ }
450
+
357
451
  /** `entry.last`-backed dedup test. `in` (not a truthiness check) so a
358
452
  * legitimately `undefined` value still records and still writes once. */
359
453
  function dedupGuard(ordinal: number): string {
@@ -366,7 +460,11 @@ function emitAttrBinding(
366
460
  a: LazyRowAttrBinding,
367
461
  mode: 'create' | 'item' | 'outer',
368
462
  ): void {
369
- lines.push(`${ind}{ const __t = __r[${a.refIndex}]`)
463
+ // `createRow` seeded every ref from a known clone path, so there it is a
464
+ // plain read. An ADOPTED row starts with an EMPTY `refs` array and each
465
+ // binding claims its OWN slot on first use — see `elementAccess`.
466
+ const target = mode === 'create' ? `__r[${a.refIndex}]` : elementAccess(a)
467
+ lines.push(`${ind}{ const __t = ${target}`)
370
468
  lines.push(`${ind}if (__t) {`)
371
469
  lines.push(`${ind} const __x = ${a.wrappedExpression}`)
372
470
  const guard = mode === 'create'
@@ -124,7 +124,7 @@ export function stringifyReactiveEffects(
124
124
  if (pc) {
125
125
  // Profile mode: preserve the granular per-slot/per-attr effect shape so
126
126
  // every binding keeps its own bfId (see module docstring).
127
- emitAttrSlotsGranular(lines, indent, elVar, lookup, attrSlots, elementIndexBySlot, bindingBfId)
127
+ emitAttrSlotsGranular(lines, indent, elVar, lookup, attrSlots, elementIndexBySlot, bindingBfId, mapPreambleWrapped)
128
128
  emitOuterTexts(lines, indent, elVar, outerTexts, bindingBfId, textClaimPathExprs)
129
129
  emitPreambleRegionsEffect(lines, indent, elVar, preambleRegions, mapPreambleWrapped)
130
130
  } else {
@@ -154,6 +154,7 @@ function emitAttrSlotsGranular(
154
154
  attrSlots: readonly ReactiveAttrSlot[],
155
155
  elementIndexBySlot: ReadonlyMap<string, number> | undefined,
156
156
  bindingBfId: (slotId: string) => string,
157
+ mapPreambleWrapped: string | undefined,
157
158
  ): void {
158
159
  for (const slot of attrSlots) {
159
160
  const varName = `__ra_${varSlotId(slot.slotId)}`
@@ -162,6 +163,14 @@ function emitAttrSlotsGranular(
162
163
  lines.push(`${indent}if (${varName}) {`)
163
164
  for (const attr of slot.attrs) {
164
165
  lines.push(`${indent} createEffect(() => {`)
166
+ // Profile mode keeps one effect per attr, so an attr reading a preamble
167
+ // local needs the declarations inside ITS OWN effect — there is no
168
+ // shared row-effect scope to hoist them into here (#2447 follow-up).
169
+ // Re-running per attr is the price of the per-binding bfId this mode
170
+ // exists to produce; `bf debug profile` is off the build hot path.
171
+ if (attr.readsPreamble && mapPreambleWrapped) {
172
+ lines.push(`${indent} ${mapPreambleWrapped}`)
173
+ }
165
174
  for (const stmt of emitAttrUpdate(varName, attr.attrName, attr.wrappedExpression, attr.meta)) {
166
175
  lines.push(`${indent} ${stmt}`)
167
176
  }
@@ -193,6 +202,11 @@ function emitPreambleRegionsEffect(
193
202
  lines.push(`${indent}})`)
194
203
  }
195
204
 
205
+ /** Does any attr in these slots read a `.map()` preamble local (#2447)? */
206
+ function attrsReadPreamble(attrSlots: readonly ReactiveAttrSlot[]): boolean {
207
+ return attrSlots.some(slot => slot.attrs.some(a => a.readsPreamble))
208
+ }
209
+
196
210
  function attrLookupExpr(
197
211
  slotId: string,
198
212
  varName: string,
@@ -270,6 +284,15 @@ function emitConsolidatedRowEffect(
270
284
  return
271
285
  }
272
286
  lines.push(`${indent}createEffect(() => {`)
287
+ // The preamble re-run leads the body, not trails it (#2447 follow-up).
288
+ // It used to sit between the attr writes and the region writes, because
289
+ // regions were its only readers. An attribute can read a preamble local
290
+ // now (`class={cls}`), and it reads it from THIS scope — so the
291
+ // declarations have to exist before the first write, whichever kind that
292
+ // is. Regions are unaffected by the move: they were already downstream.
293
+ if (mapPreambleWrapped && (preambleRegions.length > 0 || attrsReadPreamble(attrSlots))) {
294
+ lines.push(`${indent} ${mapPreambleWrapped}`)
295
+ }
273
296
  for (const slot of attrSlots) {
274
297
  const varName = `__ra_${varSlotId(slot.slotId)}`
275
298
  lines.push(`${indent} if (${varName}) {`)
@@ -282,9 +305,6 @@ function emitConsolidatedRowEffect(
282
305
  }
283
306
  lines.push(`${indent} }`)
284
307
  }
285
- if (preambleRegions.length > 0 && mapPreambleWrapped) {
286
- lines.push(`${indent} ${mapPreambleWrapped}`)
287
- }
288
308
  for (const text of outerTexts) {
289
309
  lines.push(`${indent} ${writer}('${text.slotId}', String(${text.wrappedExpression}))`)
290
310
  }
@@ -251,9 +251,23 @@ export function emitLoopItemElementSetup(
251
251
  indent: string
252
252
  /** Single-root layout: 'inline' (plain / branch-plain) or 'multiline' (composite). */
253
253
  singleRootLayout: 'inline' | 'multiline'
254
+ /**
255
+ * Emit `mountRowRoot(__el)` on the FRESH branch, connecting the row at the
256
+ * mount point `mapArray` handed down before the body's tail runs.
257
+ *
258
+ * Only bodies that initialise something inside the row need it — the tail
259
+ * is where `useContext` would otherwise resolve against a detached element
260
+ * and fall through to the global last-writer-wins store. A row with no
261
+ * nested init has nothing to resolve, so plain loops leave this off and
262
+ * their emission (and the `mapArrayLazy` measurements) are untouched.
263
+ *
264
+ * Never on the hydration branch: that row came from SSR markup and is in
265
+ * the document already.
266
+ */
267
+ mountRow?: boolean
254
268
  },
255
269
  ): void {
256
- const { template, bodyIsMultiRoot, indent, singleRootLayout } = opts
270
+ const { template, bodyIsMultiRoot, indent, singleRootLayout, mountRow } = opts
257
271
  const innerIndent = indent + ' '
258
272
  if (bodyIsMultiRoot) {
259
273
  lines.push(`${indent}let __el, __extras`)
@@ -264,17 +278,22 @@ export function emitLoopItemElementSetup(
264
278
  lines.push(ln)
265
279
  }
266
280
  lines.push(`${innerIndent}__el.__bfExtras = __extras`)
281
+ // After the stash: `mountRowRoot` attaches the primary, and an attached
282
+ // primary makes `itemRootElements`' sibling walk the first thing a lookup
283
+ // sees — it must find the stash already in place behind it.
284
+ if (mountRow) lines.push(`${innerIndent}mountRowRoot(__el)`)
267
285
  lines.push(`${indent}}`)
268
286
  return
269
287
  }
270
288
  if (singleRootLayout === 'inline') {
271
289
  const cloneExpr = emitTemplateCloneInline(template)
272
- lines.push(`${indent}const __el = __existing ?? (() => { ${cloneExpr} })()`)
290
+ const clone = `__existing ?? (() => { ${cloneExpr} })()`
291
+ lines.push(`${indent}const __el = ${mountRow ? `__existing ?? mountRowRoot((() => { ${cloneExpr} })())` : clone}`)
273
292
  return
274
293
  }
275
- lines.push(`${indent}const __el = __existing ?? (() => {`)
294
+ lines.push(`${indent}const __el = __existing ?? ${mountRow ? 'mountRowRoot(' : ''}(() => {`)
276
295
  for (const ln of emitTemplateCloneLines(template, innerIndent)) lines.push(ln)
277
- lines.push(`${indent}})()`)
296
+ lines.push(`${indent}})()${mountRow ? ')' : ''}`)
278
297
  }
279
298
 
280
299
  /**
@@ -447,7 +447,12 @@ export function buildSignalMemoEnv(
447
447
  for (const m of memos) {
448
448
  substitutions.set(m.name, {
449
449
  kind: 'call',
450
- replacement: extractMemoBodyExpr(m.computation),
450
+ // Destructured mode (#2468): `templateComputation` (when present) has
451
+ // bare destructured prop refs already rewritten to `_p.X` — the
452
+ // spliced body lands in the module-scope template arrow, which is not
453
+ // a closure over init's `const value = _p.value` extraction. Mirrors
454
+ // the signal `templateInitialValue` handling above (#2265).
455
+ replacement: extractMemoBodyExpr(m.templateComputation ?? m.computation),
451
456
  freeIdentifiers: m.computationFreeIdentifiers ?? new Set(),
452
457
  })
453
458
  }
@@ -170,7 +170,7 @@ export function emitRegistrationAndHydration(
170
170
  // transformation runs at this layer (#1277).
171
171
  const csrInlinableConstants = csrInlinableConstantsFromCtx(ctx)
172
172
  const templateHtml = generateCsrTemplate(
173
- _ir.root, csrInlinableConstants, ctx, undefined, restSpreadNames, ctx.propsObjectName, unsafeLocalNames, ctx.deferredChildSlots
173
+ _ir.root, csrInlinableConstants, ctx, restSpreadNames, ctx.propsObjectName, unsafeLocalNames, ctx.deferredChildSlots
174
174
  )
175
175
  if (templateHtml) {
176
176
  defParts.push(`template: (${PROPS_PARAM}) => \`${templateHtml}\``)