@barefootjs/jsx 0.26.4 → 0.28.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 (106) hide show
  1. package/dist/compiler.d.ts.map +1 -1
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +941 -130
  5. package/dist/ir-to-client-js/client-only-elision.d.ts +103 -0
  6. package/dist/ir-to-client-js/client-only-elision.d.ts.map +1 -0
  7. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +6 -0
  9. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts +2 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts +7 -0
  13. package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +107 -0
  15. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -0
  16. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts +9 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +203 -0
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -0
  20. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -0
  21. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/shared.d.ts +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/branch-loop.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts +54 -0
  25. package/dist/ir-to-client-js/control-flow/stringify/claim-plan.d.ts.map +1 -0
  26. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/control-flow/stringify/insert.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts +98 -0
  29. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -0
  30. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +5 -0
  31. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  32. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +17 -20
  33. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  34. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +63 -4
  35. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/control-flow.d.ts +1 -1
  37. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  38. package/dist/ir-to-client-js/element-refs.d.ts +8 -3
  39. package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
  40. package/dist/ir-to-client-js/emit-reactive.d.ts +8 -1
  41. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  42. package/dist/ir-to-client-js/html-template.d.ts +24 -0
  43. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  44. package/dist/ir-to-client-js/imports.d.ts +2 -2
  45. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  46. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  47. package/dist/ir-to-client-js/types.d.ts +9 -1
  48. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  49. package/dist/ir-to-client-js/utils.d.ts +1 -1
  50. package/dist/types.d.ts +25 -2
  51. package/dist/types.d.ts.map +1 -1
  52. package/package.json +2 -2
  53. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +542 -689
  54. package/src/__tests__/child-components-in-map.test.ts +4 -4
  55. package/src/__tests__/client-js-generation.test.ts +23 -12
  56. package/src/__tests__/composite-branch-loop.test.ts +14 -5
  57. package/src/__tests__/conditional-mapArray-key.test.ts +7 -1
  58. package/src/__tests__/create-selector.test.ts +21 -7
  59. package/src/__tests__/cross-file-client-signal.test.ts +5 -1
  60. package/src/__tests__/early-return-scope-var-ref.test.ts +10 -11
  61. package/src/__tests__/inline-jsx-callback.test.ts +6 -4
  62. package/src/__tests__/ir-jsx-props.test.ts +6 -5
  63. package/src/__tests__/lazy-row-eligibility.test.ts +692 -0
  64. package/src/__tests__/loop-branch-bare-expression-reactive-text.test.ts +98 -0
  65. package/src/__tests__/loop-fallback-wrap.test.ts +34 -16
  66. package/src/__tests__/loop-hoisted-template.test.ts +10 -6
  67. package/src/__tests__/module-level-signal.audit.test.ts +7 -2
  68. package/src/__tests__/nested-loop-conditional.test.ts +9 -7
  69. package/src/__tests__/nested-loop-reactive-attrs.test.ts +2 -2
  70. package/src/__tests__/preamble-region-patch.test.ts +18 -12
  71. package/src/__tests__/profile-cond-binding-ids.test.ts +3 -2
  72. package/src/__tests__/profile-loop-binding-ids.test.ts +4 -3
  73. package/src/__tests__/profile-nested-binding-ids.test.ts +11 -6
  74. package/src/__tests__/reactive-attrs-in-map.test.ts +6 -4
  75. package/src/__tests__/static-loop-csr-materialize.test.ts +6 -1
  76. package/src/__tests__/template-closure.test.ts +1 -1
  77. package/src/compiler.ts +11 -0
  78. package/src/index.ts +1 -0
  79. package/src/ir-to-client-js/client-only-elision.ts +273 -0
  80. package/src/ir-to-client-js/collect-elements.ts +56 -17
  81. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +6 -0
  82. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +35 -12
  83. package/src/ir-to-client-js/control-flow/plan/build-insert.ts +9 -2
  84. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +305 -0
  85. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +44 -12
  86. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +440 -0
  87. package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -0
  88. package/src/ir-to-client-js/control-flow/shared.ts +1 -1
  89. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +34 -4
  90. package/src/ir-to-client-js/control-flow/stringify/claim-plan.ts +68 -0
  91. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +18 -9
  92. package/src/ir-to-client-js/control-flow/stringify/insert.ts +17 -11
  93. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +478 -0
  94. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +41 -17
  95. package/src/ir-to-client-js/control-flow/stringify/loop.ts +71 -46
  96. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +241 -31
  97. package/src/ir-to-client-js/control-flow.ts +8 -3
  98. package/src/ir-to-client-js/element-refs.ts +9 -11
  99. package/src/ir-to-client-js/emit-reactive.ts +50 -14
  100. package/src/ir-to-client-js/html-template.ts +29 -9
  101. package/src/ir-to-client-js/imports.ts +13 -4
  102. package/src/ir-to-client-js/reactivity.ts +3 -2
  103. package/src/ir-to-client-js/types.ts +10 -2
  104. package/src/ir-to-client-js/utils.ts +1 -1
  105. package/src/jsx-to-ir.ts +26 -8
  106. package/src/types.ts +25 -2
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Stringify LoopPlan variants to source lines.
3
3
  *
4
- * Output shapes (preserved byte-identical from the legacy emitter):
4
+ * Output shapes:
5
5
  *
6
6
  * PlainLoopPlan, no reactive effects (single-line renderItem):
7
7
  * <indent>mapArray(() => <arr>, <container>, <keyFn>, (<head>, <idx>, __existing) =>
@@ -12,13 +12,18 @@
12
12
  * <indent> <unwrap?>
13
13
  * <indent> <preamble?>
14
14
  * <indent> const __el = __existing ?? (() => { ... })()
15
- * <indent> <reactive effects via stringifyReactiveEffects>
15
+ * <indent> <reactive effects + preamble regions via stringifyReactiveEffects
16
+ * <indent> row-granularity (spec/slot-unification.md §3(c)): attrs, outer
17
+ * <indent> texts, and preamble regions merge into ONE createEffect per row
18
+ * <indent> in normal builds; profile-mode builds keep the legacy
19
+ * <indent> one-effect-per-slot shape (see that module's docstring)>
16
20
  * <indent> return __el
17
21
  * <indent>})
18
22
  *
19
23
  * StaticLoopPlan: two parallel forEach blocks (attrs / texts) — see
20
24
  * emitStaticLoop. The forEach-duplication noted in observation O-4 is
21
- * preserved bug-for-bug; PR 5+ collapses them.
25
+ * preserved bug-for-bug; PR 5+ collapses them. Left untouched by the
26
+ * row-granularity pass above — a different (forEach, not mapArray) shape.
22
27
  *
23
28
  * Indent convention for plain loops: top-level emission uses `' '` (2 sp);
24
29
  * passed in via `topIndent`.
@@ -31,8 +36,9 @@ import { emitTemplateCloneInline, emitLoopItemElementSetup, emitHoistedTemplateD
31
36
  import { buildSkeletonPathPlan, type SkeletonPathPlan } from './skeleton-paths.ts'
32
37
  import { stringifyComponentLoop } from './component-loop.ts'
33
38
  import { stringifyCompositeLoop } from './composite-loop.ts'
39
+ import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
40
+ import { stringifyLazyRowLoop } from './lazy-row.ts'
34
41
  import type { LoopChildRefBinding, LoopPlan, PlainLoopPlan, StaticLoopPlan } from '../plan/types.ts'
35
- import type { PreambleRegionPlan } from '../plan/loop.ts'
36
42
 
37
43
  /**
38
44
  * Emit `(callback)(__rf)` for each ref on a per-item slot, looking up the
@@ -66,38 +72,16 @@ export function emitLoopChildRefs(
66
72
  }
67
73
 
68
74
  /**
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`.
75
+ * Preamble-patched regions (#2389 `arr.map(t => { const cells = []; ...;
76
+ * return <tr>{cells}<td>{t.name}</td></tr> })`) used to get their own
77
+ * per-region `createEffect` here. Row-granularity effects (perf,
78
+ * spec/slot-unification.md §3(c)/§8) merged that into the SAME single row
79
+ * effect `stringifyReactiveEffects` now emits for attrs/outer texts see
80
+ * that module's docstring. Both plain-loop-row call sites
81
+ * (`stringifyPlainLoop` below, `stringify/branch-loop.ts`'s `emitPlain`) pass
82
+ * `preambleRegions`/`mapPreambleWrapped` straight into
83
+ * `stringifyReactiveEffects` instead of calling a separate emitter.
81
84
  */
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
85
 
102
86
  /**
103
87
  * Single dispatch over `LoopPlan` (#1253). Narrows on `plan.kind` and
@@ -199,6 +183,28 @@ export function stringifyPlainLoop(
199
183
  return
200
184
  }
201
185
 
186
+ // Lazy row graph (spec/slot-unification.md §9, L3): rows carry NO per-row
187
+ // reactive resources. `plan.lazyRow` is set only when
188
+ // `lazyRowEligibility` accepted this loop; every other loop falls through
189
+ // to the eager emission below, byte-for-byte unchanged.
190
+ if (plan.lazyRow) {
191
+ stringifyLazyRowLoop(lines, {
192
+ indent: topIndent,
193
+ containerVar,
194
+ guardContainer: false,
195
+ markerId,
196
+ arrayExpr,
197
+ keyFn,
198
+ paramHead,
199
+ indexParam,
200
+ template,
201
+ skeletonTemplate,
202
+ skeletonPaths: plan.skeletonPaths,
203
+ lazyRow: plan.lazyRow,
204
+ })
205
+ return
206
+ }
207
+
202
208
  // Hoisted shared-template fast path (perf, see `buildLoopSkeletonTemplate`):
203
209
  // declare the once-per-loop template BEFORE the `mapArray` call so every
204
210
  // row clones from an already-parsed node instead of re-running
@@ -254,33 +260,49 @@ export function stringifyPlainLoop(
254
260
  // `reactiveEffects.conditionals` is always empty here and needs no path
255
261
  // handling. `__p` is `null` on the hydration branch (`__existing` truthy)
256
262
  // since the skeleton's empty markers/omitted attrs don't describe the
257
- // SSR-rendered tree; every resolution falls back to qsa/$t there.
263
+ // SSR-rendered tree; every resolution falls back to qsa/$t there. Attr/ref
264
+ // slots still resolve through `__p` (unchanged by slot unification A3 —
265
+ // only CONTENT slots migrate to claim plans); text slots below embed the
266
+ // same skeleton path directly into their claim-plan entry, guarded by the
267
+ // same `__existing ? [] : […]` ternary so a hydrated row never tries a
268
+ // path the skeleton doesn't actually describe.
258
269
  let pathPlan: SkeletonPathPlan | null = null
259
270
  if (hoistedTpl && plan.skeletonPaths) {
260
271
  const elementSlotIds = [
261
272
  ...(reactiveEffects?.attrSlots.map(s => s.slotId) ?? []),
262
273
  ...childRefs.map(r => r.childSlotId),
263
274
  ]
264
- const textSlotIds = reactiveEffects?.outerTexts.map(t => t.slotId) ?? []
265
- if (elementSlotIds.length > 0 || textSlotIds.length > 0) {
266
- const built = buildSkeletonPathPlan(plan.skeletonPaths, '__el', { elementSlotIds, textSlotIds })
275
+ if (elementSlotIds.length > 0) {
276
+ const built = buildSkeletonPathPlan(plan.skeletonPaths, '__el', { elementSlotIds, textSlotIds: [] })
267
277
  if (built.arrayElems.length > 0) {
268
278
  pathPlan = built
269
279
  lines.push(`${bodyIndent}const __p = __existing ? null : [${built.arrayElems.join(', ')}]`)
270
280
  }
271
281
  }
272
282
  }
273
- if (reactiveEffects !== null) {
283
+ const textClaimPathExprs = new Map<string, string>()
284
+ if (hoistedTpl && plan.skeletonPaths) {
285
+ for (const text of reactiveEffects?.outerTexts ?? []) {
286
+ const path = plan.skeletonPaths.textMarkerPaths.get(text.slotId)
287
+ if (path) textClaimPathExprs.set(text.slotId, `__existing ? [] : [${path.join(', ')}]`)
288
+ }
289
+ }
290
+ if (reactiveEffects !== null || preambleRegions.length > 0) {
291
+ // Row-granularity effects (§3(c)): attrs, outer texts, AND preamble
292
+ // regions all merge into the SAME single row `createEffect` — pass
293
+ // `preambleRegions`/`mapPreambleWrapped` straight through instead of a
294
+ // separate emitter call (see `stringifyReactiveEffects`'s docstring).
274
295
  stringifyReactiveEffects(lines, reactiveEffects, {
275
296
  indent: bodyIndent,
276
297
  elVar: '__el',
277
298
  bodyIsMultiRoot,
278
299
  elementIndexBySlot: pathPlan?.elementIndexBySlot,
279
- textIndexBySlot: pathPlan?.textIndexBySlot,
300
+ textClaimPathExprs,
301
+ preambleRegions,
302
+ mapPreambleWrapped,
280
303
  })
281
304
  }
282
305
  emitLoopChildRefs(lines, childRefs, { indent: bodyIndent, elVar: '__el', bodyIsMultiRoot, elementIndexBySlot: pathPlan?.elementIndexBySlot })
283
- emitPreambleRegionEffects(lines, preambleRegions, mapPreambleWrapped, { indent: bodyIndent, elVar: '__el' })
284
306
  lines.push(`${bodyIndent}return __el`)
285
307
  lines.push(`${topIndent}}, '${markerId}'${loopBfId})`)
286
308
  }
@@ -433,10 +455,13 @@ export function stringifyStaticLoop(lines: string[], plan: StaticLoopPlan): void
433
455
  }
434
456
  lines.push(` }`)
435
457
  }
436
- for (const text of texts) {
437
- const vn = `__rt_${varSlotId(text.slotId)}`
438
- lines.push(` { const [${vn}] = $t(__iterEl, '${text.slotId}')`)
439
- lines.push(` if (${vn}) createEffect(() => { ${vn}.textContent = String(${text.expression}) }${profileBindingId(pc, text.slotId)}) }`)
458
+ if (texts.length > 0) {
459
+ const slots: ClaimSlotSpec[] = texts.map(t => ({ id: t.slotId, kind: 'text', path: [] }))
460
+ const writer = claimWriterVarName(slots, varSlotId)
461
+ lines.push(` const ${writer} = lazySlots(__iterEl, ${claimPlanLiteral(slots)})`)
462
+ for (const text of texts) {
463
+ lines.push(` createEffect(() => { ${writer}('${text.slotId}', String(${text.expression})) }${profileBindingId(pc, text.slotId)})`)
464
+ }
440
465
  }
441
466
  // Ref callbacks fire on every forEach iteration — initial mount and any
442
467
  // future array-change-driven re-iteration (#1244). For static arrays the
@@ -6,24 +6,58 @@
6
6
  * bodies (events, child component inits, inner loops, nested conditionals,
7
7
  * branch-scoped texts) flow through the per-arm stringifiers in
8
8
  * `loop-child-arm.ts` — no legacy passthrough remains.
9
+ *
10
+ * Row-granularity effects (perf, spec/slot-unification.md §3(c)/§8, the
11
+ * deferred A3b): for the "plain loop row, top-level or branch-scoped" shape
12
+ * — the only shape where `stringifyReactiveEffects` is called alongside
13
+ * `PreambleRegionPlan`s from the SAME renderItem scope (`stringify/loop.ts`'s
14
+ * `stringifyPlainLoop`, `stringify/branch-loop.ts`'s `emitPlain`) — reactive
15
+ * attrs, outer texts, and preamble regions all read the SAME per-item signal
16
+ * dependency, so consolidating them into ONE `createEffect` per row removes
17
+ * N-1 effect objects and subscription-list entries per row without changing
18
+ * the firing profile. Composite loops, component loops, and the anchored
19
+ * (whole-item-conditional) loop shape keep the legacy one-effect-per-slot
20
+ * emission — their reactiveEffects never carry preamble regions, and
21
+ * consolidating them wasn't part of this pass's mechanically-verified scope
22
+ * (see the call sites' docstrings).
23
+ *
24
+ * Profile mode (#1690) carries a `<Component>#binding:<slotId>` id per
25
+ * effect so the profiler attributes a re-run to its source binding — merging
26
+ * bindings into one effect would make every binding on the row share one id.
27
+ * Resolution: profile mode (`plan.profileComponentName` set) keeps the
28
+ * granular per-slot/per-attr effect emission (`emitAttrSlotsGranular` /
29
+ * `emitOuterTexts` below); normal builds get the consolidated row effect.
30
+ * Preamble regions never carried a per-region bfId even before this pass, so
31
+ * they always consolidate into one effect regardless of profile mode.
9
32
  */
10
33
 
11
34
  import { varSlotId, profileBindingId } from '../../utils.ts'
12
35
  import { emitAttrUpdate } from '../../emit-reactive.ts'
13
36
  import { stringifyLoopChildArm } from './loop-child-arm.ts'
37
+ import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
14
38
  import type {
15
39
  NestedConditionalPlan,
40
+ ReactiveAttrSlot,
16
41
  ReactiveEffectsPlan,
17
42
  ReactiveTextEffect,
18
43
  } from '../plan/reactive-effects.ts'
19
44
 
45
+ /** A resolved preamble-patched region (#2389) ready to merge into the row
46
+ * effect — same shape as `plan/loop.ts`'s `PreambleRegionPlan`, restated
47
+ * here so this module doesn't need a cross-import into the loop plan file
48
+ * for one two-field shape. */
49
+ export interface RowPreambleRegion {
50
+ slotId: string
51
+ valueExpr: string
52
+ }
53
+
20
54
  export interface StringifyReactiveEffectsOptions {
21
55
  /** Indent prefix for every emitted line. */
22
56
  indent: string
23
57
  /**
24
58
  * Element variable to attach effects to (e.g., `__el`, `__existing`,
25
59
  * `__csrEl`). The stringifier never inspects it — it is simply substituted
26
- * into the qsa() / $t() / insert() call shapes.
60
+ * into the qsa() / lazySlots() / insert() call shapes.
27
61
  */
28
62
  elVar: string
29
63
  /**
@@ -42,27 +76,88 @@ export interface StringifyReactiveEffectsOptions {
42
76
  * still resolves through the battle-tested runtime lookup.
43
77
  */
44
78
  elementIndexBySlot?: ReadonlyMap<string, number>
45
- /** Same as `elementIndexBySlot`, for text-marker slots (`$t`). */
46
- textIndexBySlot?: ReadonlyMap<string, number>
79
+ /**
80
+ * Rendered path EXPRESSION source (not a plain array — see
81
+ * `ClaimSlotSpec.pathExpr`) for outer text slots, keyed by slotId: perf,
82
+ * #2143's hoisted single-root loop skeleton
83
+ * (`SkeletonSlotPaths.textMarkerPaths`), guarded by `__existing` the same
84
+ * way `__p` is nulled on the hydration branch (the skeleton's simplified
85
+ * markup doesn't describe the real SSR-rendered tree). Only
86
+ * `stringifyPlainLoop`'s hoisted-skeleton path supplies these; every other
87
+ * caller omits it and each text slot's claim-plan entry gets `path: []`
88
+ * — A2's marker-scan fallback resolves it at claim time (sound, just
89
+ * slower), per `spec/slot-unification.md` §5-A3's explicit "cannot be
90
+ * statically pathed" allowance.
91
+ */
92
+ textClaimPathExprs?: ReadonlyMap<string, string>
93
+ /**
94
+ * Preamble-patched regions (#2389) to merge into the SAME row effect as
95
+ * this scope's reactive attrs/outer texts (row-granularity effects,
96
+ * §3(c)) — only supplied by the plain-loop-row call sites
97
+ * (`stringify/loop.ts`, `stringify/branch-loop.ts`) where
98
+ * `PlainLoopVariant.preambleRegions` exists on the plan. Every other
99
+ * caller omits this and gets the legacy per-slot emission untouched.
100
+ */
101
+ preambleRegions?: readonly RowPreambleRegion[]
102
+ /**
103
+ * The loop callback's pre-return preamble (already wrapped with the loop
104
+ * param accessor), re-run once per row-effect tick — BEFORE any preamble
105
+ * region read — so `preambleRegions`' `valueExpr`s see freshly recomputed
106
+ * locals. Only meaningful alongside `preambleRegions`.
107
+ */
108
+ mapPreambleWrapped?: string
47
109
  }
48
110
 
49
111
  export function stringifyReactiveEffects(
50
112
  lines: string[],
51
- plan: ReactiveEffectsPlan,
113
+ plan: ReactiveEffectsPlan | null,
52
114
  opts: StringifyReactiveEffectsOptions,
53
115
  ): void {
54
- const { indent, elVar, bodyIsMultiRoot, elementIndexBySlot, textIndexBySlot } = opts
116
+ const { indent, elVar, bodyIsMultiRoot, elementIndexBySlot, textClaimPathExprs, preambleRegions = [], mapPreambleWrapped } = opts
55
117
  const lookup = bodyIsMultiRoot ? 'qsaItem' : 'qsa'
56
- const pc = plan.profileComponentName
118
+ const pc = plan?.profileComponentName
57
119
  const bindingBfId = (slotId: string): string => profileBindingId(pc, slotId)
120
+ const attrSlots = plan?.attrSlots ?? []
121
+ const outerTexts = plan?.outerTexts ?? []
122
+ const conditionals = plan?.conditionals ?? []
123
+
124
+ if (pc) {
125
+ // Profile mode: preserve the granular per-slot/per-attr effect shape so
126
+ // every binding keeps its own bfId (see module docstring).
127
+ emitAttrSlotsGranular(lines, indent, elVar, lookup, attrSlots, elementIndexBySlot, bindingBfId)
128
+ emitOuterTexts(lines, indent, elVar, outerTexts, bindingBfId, textClaimPathExprs)
129
+ emitPreambleRegionsEffect(lines, indent, elVar, preambleRegions, mapPreambleWrapped)
130
+ } else {
131
+ emitConsolidatedRowEffect(
132
+ lines, indent, elVar, lookup,
133
+ attrSlots, outerTexts, elementIndexBySlot, textClaimPathExprs,
134
+ preambleRegions, mapPreambleWrapped,
135
+ )
136
+ }
137
+
138
+ // Reactive conditionals — each emits an insert(...) over `elVar` whose arm
139
+ // bodies dispatch through the per-arm stringifiers. Structurally distinct
140
+ // from the attrs/texts/regions merge above (its own per-branch disposable
141
+ // effects), so it's untouched by row-granularity consolidation.
142
+ for (const cond of conditionals) {
143
+ emitOuterConditional(lines, indent, elVar, cond, pc)
144
+ }
145
+ }
146
+
147
+ // --- profile-mode / legacy granular emission (one createEffect per slot-attr) ---
58
148
 
59
- // 1. Reactive attribute effects (one qsa per slot, then per-attr createEffect).
60
- for (const slot of plan.attrSlots) {
149
+ function emitAttrSlotsGranular(
150
+ lines: string[],
151
+ indent: string,
152
+ elVar: string,
153
+ lookup: string,
154
+ attrSlots: readonly ReactiveAttrSlot[],
155
+ elementIndexBySlot: ReadonlyMap<string, number> | undefined,
156
+ bindingBfId: (slotId: string) => string,
157
+ ): void {
158
+ for (const slot of attrSlots) {
61
159
  const varName = `__ra_${varSlotId(slot.slotId)}`
62
- const pIdx = elementIndexBySlot?.get(slot.slotId)
63
- const lookupExpr = pIdx !== undefined
64
- ? `__p ? __p[${pIdx}] : ${lookup}(${elVar}, '[bf="${slot.slotId}"]')`
65
- : `${lookup}(${elVar}, '[bf="${slot.slotId}"]')`
160
+ const lookupExpr = attrLookupExpr(slot.slotId, varName, elVar, lookup, elementIndexBySlot)
66
161
  lines.push(`${indent}{ const ${varName} = ${lookupExpr}`)
67
162
  lines.push(`${indent}if (${varName}) {`)
68
163
  for (const attr of slot.attrs) {
@@ -74,36 +169,151 @@ export function stringifyReactiveEffects(
74
169
  }
75
170
  lines.push(`${indent}} }`)
76
171
  }
172
+ }
173
+
174
+ /** ONE createEffect covering every preamble region — never carried a bfId
175
+ * before this pass (regions were never profiled per-binding), so this
176
+ * shape applies in both profile and non-profile mode. */
177
+ function emitPreambleRegionsEffect(
178
+ lines: string[],
179
+ indent: string,
180
+ elVar: string,
181
+ preambleRegions: readonly RowPreambleRegion[],
182
+ mapPreambleWrapped: string | undefined,
183
+ ): void {
184
+ if (preambleRegions.length === 0) return
185
+ const slots: ClaimSlotSpec[] = preambleRegions.map(r => ({ id: r.slotId, kind: 'markup', path: [] }))
186
+ const writer = claimWriterVarName(slots, varSlotId)
187
+ lines.push(`${indent}const ${writer} = lazySlots(${elVar}, ${claimPlanLiteral(slots)})`)
188
+ lines.push(`${indent}createEffect(() => {`)
189
+ if (mapPreambleWrapped) lines.push(`${indent} ${mapPreambleWrapped}`)
190
+ for (const region of preambleRegions) {
191
+ lines.push(`${indent} ${writer}('${region.slotId}', ${region.valueExpr})`)
192
+ }
193
+ lines.push(`${indent}})`)
194
+ }
195
+
196
+ function attrLookupExpr(
197
+ slotId: string,
198
+ varName: string,
199
+ elVar: string,
200
+ lookup: string,
201
+ elementIndexBySlot: ReadonlyMap<string, number> | undefined,
202
+ ): string {
203
+ const pIdx = elementIndexBySlot?.get(slotId)
204
+ return pIdx !== undefined
205
+ ? `__p ? __p[${pIdx}] : ${lookup}(${elVar}, '[bf="${slotId}"]')`
206
+ : `${lookup}(${elVar}, '[bf="${slotId}"]')`
207
+ }
208
+
209
+ // --- row-granularity consolidated emission (ONE createEffect per row) ---
210
+
211
+ /**
212
+ * Emit ONE `createEffect` covering: reactive attr writes (for every slot in
213
+ * `attrSlots`), outer text writes, and preamble-region writes — in that
214
+ * order, mirroring the legacy relative ordering (attrs, then texts, then
215
+ * regions). Attr-slot element lookups are resolved ONCE, outside the
216
+ * effect (identical to the legacy shape — only the WRITE moves inside the
217
+ * shared effect). Outer texts and preamble regions share ONE claimed-slot
218
+ * writer (mixed 'text'/'markup' kinds — `claim-slots.ts`'s `write()` already
219
+ * dispatches per-slot on `kind`), so a row pays for at most one `lazySlots`
220
+ * closure + one claim `Map` instead of one per category.
221
+ *
222
+ * Every attr's write statements are wrapped in their own `{ }` block (not
223
+ * just each slot's) — `emitAttrUpdate`'s 'value'-attr shape emits a bare
224
+ * `const __val = …` with no block of its own, and merging multiple attrs
225
+ * into one function body (previously each had its own arrow-function scope)
226
+ * would let two 'value' attrs in the same row effect collide on `__val`.
227
+ */
228
+ function emitConsolidatedRowEffect(
229
+ lines: string[],
230
+ indent: string,
231
+ elVar: string,
232
+ lookup: string,
233
+ attrSlots: readonly ReactiveAttrSlot[],
234
+ outerTexts: readonly ReactiveTextEffect[],
235
+ elementIndexBySlot: ReadonlyMap<string, number> | undefined,
236
+ textClaimPathExprs: ReadonlyMap<string, string> | undefined,
237
+ preambleRegions: readonly RowPreambleRegion[],
238
+ mapPreambleWrapped: string | undefined,
239
+ ): void {
240
+ if (attrSlots.length === 0 && outerTexts.length === 0 && preambleRegions.length === 0) return
241
+
242
+ // 1. Resolve every attr slot's element reference once, outside the effect.
243
+ for (const slot of attrSlots) {
244
+ const varName = `__ra_${varSlotId(slot.slotId)}`
245
+ lines.push(`${indent}const ${varName} = ${attrLookupExpr(slot.slotId, varName, elVar, lookup, elementIndexBySlot)}`)
246
+ }
77
247
 
78
- // 2. Outer text effects (slots NOT inside any conditional branch).
79
- for (const text of plan.outerTexts) {
80
- emitOuterText(lines, indent, elVar, text, bindingBfId(text.slotId), textIndexBySlot?.get(text.slotId))
248
+ // 2. ONE claimed-slot writer covering outer texts (kind 'text') and
249
+ // preamble regions (kind 'markup') mixed-kind plans are supported by
250
+ // claim-slots.ts's per-slot `kind` dispatch.
251
+ const claimSlots: ClaimSlotSpec[] = [
252
+ ...outerTexts.map((t): ClaimSlotSpec => ({ id: t.slotId, kind: 'text', path: [], pathExpr: textClaimPathExprs?.get(t.slotId) })),
253
+ ...preambleRegions.map((r): ClaimSlotSpec => ({ id: r.slotId, kind: 'markup', path: [] })),
254
+ ]
255
+ const writer = claimSlots.length > 0 ? claimWriterVarName(claimSlots, varSlotId) : null
256
+ if (writer) {
257
+ lines.push(`${indent}const ${writer} = lazySlots(${elVar}, ${claimPlanLiteral(claimSlots)})`)
81
258
  }
82
259
 
83
- // 3. Reactive conditionals each emits an insert(...) over `elVar` whose
84
- // arm bodies dispatch through the per-arm stringifiers.
85
- for (const cond of plan.conditionals) {
86
- emitOuterConditional(lines, indent, elVar, cond, pc)
260
+ // 3. The single row effect: attr writes, then (if any region needs it) the
261
+ // preamble re-run, then text writes, then region writes. The by-far
262
+ // most common shape no attrs, no regions, exactly one outer text —
263
+ // collapses to the SAME single-line `createEffect(() => { ... })` the
264
+ // legacy per-text emitter used (doc-examples snapshots pin this byte
265
+ // shape); anything busier (an attr present, multiple slots, or a
266
+ // preamble region) uses the multi-line block.
267
+ if (attrSlots.length === 0 && preambleRegions.length === 0 && outerTexts.length === 1) {
268
+ const text = outerTexts[0]
269
+ lines.push(`${indent}createEffect(() => { ${writer}('${text.slotId}', String(${text.wrappedExpression})) })`)
270
+ return
271
+ }
272
+ lines.push(`${indent}createEffect(() => {`)
273
+ for (const slot of attrSlots) {
274
+ const varName = `__ra_${varSlotId(slot.slotId)}`
275
+ lines.push(`${indent} if (${varName}) {`)
276
+ for (const attr of slot.attrs) {
277
+ lines.push(`${indent} {`)
278
+ for (const stmt of emitAttrUpdate(varName, attr.attrName, attr.wrappedExpression, attr.meta)) {
279
+ lines.push(`${indent} ${stmt}`)
280
+ }
281
+ lines.push(`${indent} }`)
282
+ }
283
+ lines.push(`${indent} }`)
284
+ }
285
+ if (preambleRegions.length > 0 && mapPreambleWrapped) {
286
+ lines.push(`${indent} ${mapPreambleWrapped}`)
87
287
  }
288
+ for (const text of outerTexts) {
289
+ lines.push(`${indent} ${writer}('${text.slotId}', String(${text.wrappedExpression}))`)
290
+ }
291
+ for (const region of preambleRegions) {
292
+ lines.push(`${indent} ${writer}('${region.slotId}', ${region.valueExpr})`)
293
+ }
294
+ lines.push(`${indent}})`)
88
295
  }
89
296
 
90
- function emitOuterText(
297
+ function emitOuterTexts(
91
298
  lines: string[],
92
299
  indent: string,
93
300
  elVar: string,
94
- text: ReactiveTextEffect,
95
- bfId: string = '',
96
- pIdx?: number,
301
+ texts: readonly ReactiveTextEffect[],
302
+ bindingBfId: (slotId: string) => string,
303
+ textClaimPathExprs?: ReadonlyMap<string, string>,
97
304
  ): void {
98
- const varName = `__rt_${varSlotId(text.slotId)}`
99
- if (pIdx !== undefined) {
100
- // Direct-path resolution (#2143): `__p` holds the marker Comment node;
101
- // `tAfter` mirrors `$t`'s create-if-absent Text-node semantics exactly.
102
- lines.push(`${indent}{ const ${varName} = __p ? tAfter(__p[${pIdx}]) : $t(${elVar}, '${text.slotId}')[0]`)
103
- } else {
104
- lines.push(`${indent}{ const [${varName}] = $t(${elVar}, '${text.slotId}')`)
305
+ if (texts.length === 0) return
306
+ const slots: ClaimSlotSpec[] = texts.map(t => ({
307
+ id: t.slotId,
308
+ kind: 'text',
309
+ path: [],
310
+ pathExpr: textClaimPathExprs?.get(t.slotId),
311
+ }))
312
+ const writer = claimWriterVarName(slots, varSlotId)
313
+ lines.push(`${indent}const ${writer} = lazySlots(${elVar}, ${claimPlanLiteral(slots)})`)
314
+ for (const text of texts) {
315
+ lines.push(`${indent}createEffect(() => { ${writer}('${text.slotId}', String(${text.wrappedExpression})) }${bindingBfId(text.slotId)})`)
105
316
  }
106
- lines.push(`${indent}if (${varName}) createEffect(() => { ${varName}.textContent = String(${text.wrappedExpression}) }${bfId}) }`)
107
317
  }
108
318
 
109
319
  function emitOuterConditional(
@@ -4,7 +4,7 @@
4
4
  * These three functions are called from `generate-init.ts` to emit the
5
5
  * client-side runtime calls that drive reactive conditionals
6
6
  * (`insert(...)`) and reactive loops (`mapArray(...)` /
7
- * `reconcileElements`) for a single component.
7
+ * `mapArrayAnchored(...)`) for a single component.
8
8
  *
9
9
  * Each entry point thinly wraps the Plan layer:
10
10
  *
@@ -25,6 +25,7 @@ import { internalInvariant } from '../errors.ts'
25
25
  import { buildInsertPlan } from './control-flow/plan/build-insert.ts'
26
26
  import { stringifyInsert } from './control-flow/stringify/insert.ts'
27
27
  import { buildLoopPlan } from './control-flow/plan/build-loop.ts'
28
+ import { buildLazyRowScopeInfo } from './control-flow/plan/build-lazy-row.ts'
28
29
  import { stringifyLoop } from './control-flow/stringify/loop.ts'
29
30
  import {
30
31
  buildDynamicLoopDelegationPlan,
@@ -36,7 +37,7 @@ import { stringifyEventDelegation } from './control-flow/stringify/event-delegat
36
37
  export function emitConditionalUpdates(lines: string[], ctx: ClientJsContext): void {
37
38
  const profileComponentName = ctx.profile ? ctx.componentName : undefined
38
39
  for (const elem of ctx.conditionalElements) {
39
- const plan = buildInsertPlan(elem, { scope: { kind: 'top' }, eventNameMode: 'dom', profileComponentName })
40
+ const plan = buildInsertPlan(elem, { scope: { kind: 'top' }, eventNameMode: 'dom', profileComponentName, lazyScope: buildLazyRowScopeInfo(ctx) })
40
41
  stringifyInsert(lines, plan, { leadingIndent: ' ', bodyIndent: ' ' })
41
42
  lines.push('')
42
43
  }
@@ -46,7 +47,7 @@ export function emitConditionalUpdates(lines: string[], ctx: ClientJsContext): v
46
47
  export function emitClientOnlyConditionals(lines: string[], ctx: ClientJsContext): void {
47
48
  const profileComponentName = ctx.profile ? ctx.componentName : undefined
48
49
  for (const elem of ctx.clientOnlyConditionals) {
49
- const plan = buildInsertPlan(elem, { scope: { kind: 'top' }, eventNameMode: 'raw', profileComponentName })
50
+ const plan = buildInsertPlan(elem, { scope: { kind: 'top' }, eventNameMode: 'raw', profileComponentName, lazyScope: buildLazyRowScopeInfo(ctx) })
50
51
  lines.push(` // @client conditional: ${elem.slotId}`)
51
52
  stringifyInsert(lines, plan, { leadingIndent: ' ', bodyIndent: ' ' })
52
53
  lines.push('')
@@ -66,10 +67,14 @@ export function emitClientOnlyConditionals(lines: string[], ctx: ClientJsContext
66
67
  * event surface, no delegation pass needed
67
68
  */
68
69
  export function emitLoopUpdates(lines: string[], ctx: ClientJsContext, unsafeLocalNames: Set<string>): void {
70
+ // Lazy row graph (§9.4) name facts — built once per component, consulted
71
+ // by every plain loop's eligibility gate.
72
+ const lazyScope = buildLazyRowScopeInfo(ctx)
69
73
  for (const elem of ctx.loopElements) {
70
74
  const plan = buildLoopPlan(elem, {
71
75
  unsafeLocalNames,
72
76
  profileComponentName: ctx.profile ? ctx.componentName : undefined,
77
+ lazyScope,
73
78
  })
74
79
  // Stage 3 root cure — a JSX-bearing preamble can only be spliced into a
75
80
  // string-templated row (renderPreamble). Every shape that reaches a
@@ -2,13 +2,18 @@
2
2
  * Element ref declarations — `const [_s0, _s1] = $(…, 's0', 's1')`.
3
3
  *
4
4
  * Every element the init body needs to find at hydration time (events,
5
- * dynamic text, loops, refs, reactive attrs, reactive props, rest-attr
6
- * elements, child component slots) is emitted via one of three finders:
5
+ * loops, refs, reactive attrs, reactive props, rest-attr elements, child
6
+ * component slots) is emitted via one of two finders:
7
7
  *
8
8
  * `$(…)` — regular DOM elements (bf-*)
9
- * `$t(…)` — text comment markers for dynamic text (`<!--bf:sN-->`)
10
9
  * `$c(…)` — component slots (bf-s, component-rooted)
11
10
  *
11
+ * Dynamic text/JSX content slots (`<!--bf:sN-->` markers) don't need a ref
12
+ * here at all — they resolve through a claimed-slot writer built where
13
+ * they're used (`emitDynamicTextUpdates`/`emitClientOnlyExpressions` in
14
+ * `ir-to-client-js/emit-reactive.ts`, slot unification A3), not a
15
+ * pre-resolved `_sN` var.
16
+ *
12
17
  * Slots inside conditional branches are excluded here because
13
18
  * `insert()` bindEvents re-queries them when the branch activates.
14
19
  * Component slots take precedence over regular slots (#360): when a
@@ -26,7 +31,6 @@ import { varSlotId } from './utils.ts'
26
31
  */
27
32
  export function generateElementRefs(ctx: ClientJsContext): string {
28
33
  const regularSlots = new Set<string>()
29
- const textSlots = new Set<string>()
30
34
  const componentSlots = new Set<string>()
31
35
  const conditionalSlotIds = collectConditionalSlotIds(ctx)
32
36
 
@@ -35,11 +39,6 @@ export function generateElementRefs(ctx: ClientJsContext): string {
35
39
  regularSlots.add(elem.slotId)
36
40
  }
37
41
  }
38
- for (const elem of ctx.dynamicElements) {
39
- if (!elem.insideConditional) {
40
- textSlots.add(elem.slotId)
41
- }
42
- }
43
42
  for (const elem of ctx.conditionalElements) {
44
43
  regularSlots.add(elem.slotId)
45
44
  }
@@ -72,11 +71,10 @@ export function generateElementRefs(ctx: ClientJsContext): string {
72
71
  regularSlots.delete(slotId)
73
72
  }
74
73
 
75
- if (regularSlots.size === 0 && textSlots.size === 0 && componentSlots.size === 0) return ''
74
+ if (regularSlots.size === 0 && componentSlots.size === 0) return ''
76
75
 
77
76
  const refLines: string[] = []
78
77
  emitSlotRefs(refLines, [...regularSlots], '$')
79
- emitSlotRefs(refLines, [...textSlots], '$t')
80
78
  emitSlotRefs(refLines, [...componentSlots], '$c')
81
79
  return refLines.join('\n')
82
80
  }