@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
@@ -34,6 +34,7 @@ import type { InsertPlan, InsertArm, ArmBody, ScopeRef } from '../plan/types.ts'
34
34
  import { stringifyBranchLoops } from './branch-loop.ts'
35
35
  import { emitListenerLine } from './event-listener.ts'
36
36
  import { nameForRegistryRef } from '../../component-scope.ts'
37
+ import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
37
38
 
38
39
  export interface StringifyInsertOptions {
39
40
  /** Indent on the `insert(` line itself. */
@@ -173,18 +174,23 @@ function emitArmBody(
173
174
  lines.push(`${indent}} }`)
174
175
  }
175
176
 
176
- for (const te of body.textEffects) {
177
- const v = varSlotId(te.slotId)
178
- // Route through `__bfText` so a JSX-valued expression (`{cond && logo(id)}`)
177
+ if (body.textEffects.length > 0) {
178
+ // 'markup' kind so a JSX-valued expression (`{cond && logo(id)}`)
179
179
  // re-splices the live element by identity instead of stringifying it to
180
- // "[object HTMLElement]" — the branch template already spliced it via
181
- // `__bfSlot`, and this effect re-renders it when its deps change (#1663).
182
- // The `let` tracker carries the replaced node across reactive re-runs.
183
- lines.push(`${indent}let __anchor_${v} = $t(__branchScope, '${te.slotId}')[0]`)
184
- lines.push(`${indent}__disposers.push(createDisposableEffect(() => {`)
185
- lines.push(`${indent} const __val = ${te.expression}`)
186
- lines.push(`${indent} __anchor_${v} = __bfText(__anchor_${v}, __val)`)
187
- lines.push(`${indent}}${bindingBfId(te.slotId)}))`)
180
+ // "[object HTMLElement]" — the same contract `__bfText` used to provide
181
+ // (#1663). Built fresh here, inside this arm's `bindEvents` body, so
182
+ // every branch activation re-claims against that swap's own
183
+ // `__branchScope` DOM (spec/slot-unification.md §5-A3's "branch-internal
184
+ // slots re-claim per branch activation").
185
+ const slots: ClaimSlotSpec[] = body.textEffects.map(te => ({ id: te.slotId, kind: 'markup', path: [] }))
186
+ const writer = claimWriterVarName(slots, varSlotId)
187
+ lines.push(`${indent}const ${writer} = lazySlots(__branchScope, ${claimPlanLiteral(slots)})`)
188
+ for (const te of body.textEffects) {
189
+ // `escapeTextOrNode`: see emit-reactive.ts's identical wrap for why a
190
+ // string value must be escaped before a 'markup' writer's
191
+ // `innerHTML =` insertion, while a live Node passes through untouched.
192
+ lines.push(`${indent}__disposers.push(createDisposableEffect(() => { ${writer}('${te.slotId}', escapeTextOrNode(${te.expression})) }${bindingBfId(te.slotId)}))`)
193
+ }
188
194
  }
189
195
 
190
196
  // Branch loops, now fully Plan-built. The stringifier writes its own
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Emit a lazy row graph loop — `mapArrayLazy(...)` + a compiler-built
3
+ * `LazyRowPlan` object literal (`spec/slot-unification.md` §9, L3).
4
+ *
5
+ * This is the alternative to `stringifyPlainLoop`'s `mapArray` + renderItem
6
+ * emission, taken only when `lazyRowEligibility` said yes (see
7
+ * `plan/lazy-row-eligibility.ts`). Every ineligible loop keeps today's
8
+ * emission byte-for-byte — sound-or-loud, no silent third path.
9
+ *
10
+ * Output shape:
11
+ *
12
+ * const __tpl_<mid> = document.createElement('template') // hoisted skeleton, when usable
13
+ * __tpl_<mid>.innerHTML = `…`
14
+ * const __lzp_<mid> = [...] // hoisted fresh-clone text paths, when usable
15
+ * const __lzs_<mid> = [{ id, kind: 'text', path: [] }, …] // hoisted claim plan, ADOPTED-row form
16
+ * const __lzsc_<mid> = [{ id, kind: 'text', path: __lzp_<mid>[i] }, …] // FRESH-CLONE form, only when it differs
17
+ * const __lzc_<mid> = (__e) => { … } // lazy ref claim for ADOPTED rows (door slot empty)
18
+ * mapArrayLazy(() => <arr>, <container>, <keyFn>, {
19
+ * createRow: (__e, <idx>) => { … writes every binding, seeds refs+last … },
20
+ * applyItem: (__e) => { … item-driven bindings, dedup against __e.last … },
21
+ * applyOuter: (__es, __seed) => { … one loop-level effect body … },
22
+ * }, '<markerId>')
23
+ *
24
+ * Three shapes carry the plan's obligations (runtime docstring,
25
+ * `packages/client/src/runtime/map-array-lazy.ts`):
26
+ *
27
+ * **`createRow`** — CSR creation. Clones the row, resolves refs from KNOWN
28
+ * clone paths (the hoisted skeleton's `__p`-style child-index chains, reused
29
+ * verbatim from #2143 via `pathExpr`) with no scan, writes ALL bindings
30
+ * (item-driven and outer-involving alike) and seeds `entry.last` so the
31
+ * loop-level outer effect's dedup is correct from the row's first tick.
32
+ *
33
+ * **`applyItem`** — called by the reconciler after `entry.item` changed.
34
+ * Claims refs lazily through `__lzc_<mid>` (a `qsa` scan inside that ONE row)
35
+ * when `entry.refs` is null, materializes the content door on demand
36
+ * (`doorAccess`), then writes each item-driven binding behind a per-binding
37
+ * dedup on `entry.last`.
38
+ *
39
+ * **`applyOuter`** — the ONE loop-level effect body, emitted only when some
40
+ * binding is outer-involving. Two details matter:
41
+ *
42
+ * - **Prime reads first.** The effect subscribes to whatever its body reads.
43
+ * With an empty entry list the per-row loop reads nothing, so the effect
44
+ * would never subscribe and the loop would go permanently dead. The plan
45
+ * therefore emits a bare `getter()` statement per reactive outer name
46
+ * BEFORE the row loop. The eligibility gate guarantees every reactive
47
+ * outer name is a primable zero-arg signal/memo getter.
48
+ * - **Read-compare-write seeding (§9.3(1)).** On the first run (`__seed`)
49
+ * each binding computes its value, READS the current DOM, and writes only
50
+ * on difference — sound even when the outer state is client-only and
51
+ * diverges from SSR, with no writes on the consistent path. The DOM read
52
+ * is per attribute KIND and pairs with `emitAttrUpdate`'s dispatch
53
+ * (`emit-reactive.ts`); any kind this module does not recognise falls back
54
+ * to `true` (always write on seed), which is conservative, never wrong.
55
+ * CONTENT slots seed the same way through the claim's `read(id)` door
56
+ * (§9.5, lifted) — see `refParts` for the per-loop door choice.
57
+ *
58
+ * A loop with NO outer binding emits no `applyOuter` at all — its rows do
59
+ * literally nothing at hydration.
60
+ */
61
+
62
+ import { isBooleanAttr } from '../../../html-constants.ts'
63
+ import { emitAttrUpdate } from '../../emit-reactive.ts'
64
+ import { toHtmlAttrName } from '../../utils.ts'
65
+ import type { SkeletonSlotPaths } from '../../html-template.ts'
66
+ import { claimPlanLiteral, type ClaimSlotSpec } from './claim-plan.ts'
67
+ import { pathExpr } from './skeleton-paths.ts'
68
+ import {
69
+ emitHoistedTemplateDecl,
70
+ emitTemplateCloneInline,
71
+ hoistedCloneExpr,
72
+ } from './template-parse.ts'
73
+ import type { LazyRowAttrBinding, LazyRowPlanData, LazyRowTextBinding } from '../plan/build-lazy-row.ts'
74
+
75
+ export interface StringifyLazyRowOptions {
76
+ /** Indent of the `mapArrayLazy(` line itself. */
77
+ indent: string
78
+ containerVar: string
79
+ /** Wrap the call in `if (<containerVar>) …` — branch-scoped loops do. */
80
+ guardContainer: boolean
81
+ markerId: string
82
+ arrayExpr: string
83
+ keyFn: string
84
+ paramHead: string
85
+ indexParam: string
86
+ /** Per-row interpolated template (the non-hoisted clone source). */
87
+ template: string
88
+ skeletonTemplate?: string
89
+ skeletonPaths?: SkeletonSlotPaths
90
+ lazyRow: LazyRowPlanData
91
+ }
92
+
93
+ export function stringifyLazyRowLoop(lines: string[], o: StringifyLazyRowOptions): void {
94
+ const { indent, lazyRow, paramHead } = o
95
+ const mid = o.markerId.replace(/[^A-Za-z0-9_$]/g, '_')
96
+ const tplVar = `__tpl_${mid}`
97
+ const claimVar = `__lzc_${mid}`
98
+ const hasRefs = lazyRow.attrSlotIds.length > 0 || lazyRow.texts.length > 0
99
+ const hasBindings = lazyRow.attrs.length > 0 || lazyRow.texts.length > 0
100
+ // ONE per-loop door decision (see `refParts`): the read-capable claim, or
101
+ // today's write-only writer. Never decided per binding — the door is
102
+ // allocated once per ROW.
103
+ const rwDoor = lazyRow.textNeedsRead
104
+
105
+ // Hoisted once-per-loop skeleton (perf, #2143): clone an already-parsed
106
+ // node per row instead of re-running an `innerHTML` parse. The skeleton
107
+ // omits dynamic ATTRIBUTE VALUES and empties text markers but keeps every
108
+ // `bf="sN"` attribute and `<!--bf:sN-->` marker, so a slot with no
109
+ // compile-time path still resolves by scan against the clone — same
110
+ // per-slot fallback the eager `__p` path takes (`buildSkeletonPathPlan`
111
+ // simply omits pathless slots).
112
+ const paths = o.skeletonPaths
113
+ const useHoisted = Boolean(o.skeletonTemplate)
114
+
115
+ if (useHoisted) emitHoistedTemplateDecl(lines, indent, tplVar, o.skeletonTemplate!)
116
+
117
+ // Hoisted fresh-clone claim paths for this loop's text slots, REFERENCED
118
+ // (not inlined) by `createRow`'s claim plan. Two reasons, both load-bearing:
119
+ //
120
+ // 1. One array per loop instead of one per row — `createRow` runs per CSR
121
+ // row and would otherwise re-allocate every path literal.
122
+ // 2. These paths describe the SKELETON CLONE, not the server-rendered
123
+ // tree, and they are ROW-relative — resolving them from the component
124
+ // scope root (which is what an SSR-shape checker can see) is
125
+ // meaningless. That is the same situation `ClaimSlotSpec.pathExpr`
126
+ // exists for on the eager path (`__existing ? [] : […]`), and the
127
+ // claim-plan conformance harness (`adapter-tests/src/
128
+ // claim-plan-conformance.ts`) correctly verifies only literal
129
+ // `number[]` paths. Adopted (SSR) rows never use these — `__lzc_<mid>`
130
+ // claims with `path: []`, the sanctioned marker-scan case.
131
+ const textPathVar = useHoisted && paths && lazyRow.texts.length > 0 ? `__lzp_${mid}` : null
132
+ if (textPathVar) {
133
+ const arrays = lazyRow.texts.map(t => `[${(paths!.textMarkerPaths.get(t.slotId) ?? []).join(', ')}]`)
134
+ lines.push(`${indent}const ${textPathVar} = [${arrays.join(', ')}]`)
135
+ }
136
+
137
+ // Hoisted claim-plan literal(s) for this loop's text slots (perf): a
138
+ // `ClaimPlan` is `readonly SlotSpec[]` (`claim-slots.ts:145`) and
139
+ // `claimRefs` (line 601) only ever READS it — there is no `spec.<field> = `
140
+ // assignment anywhere in that module, and `claimSlots`/`claimRefs` build a
141
+ // fresh `Map` per call — so one shared plan object is safe across every
142
+ // row of the loop instead of a fresh `{ id, kind, path }` object (plus,
143
+ // when `textPathVar` is null, a fresh inner `path: []` array) per row.
144
+ // `__lzs_<mid>` is the ADOPTED-row form (`path: []`, resolved by A2's
145
+ // marker scan); `__lzsc_<mid>` is the FRESH-CLONE form (each slot's path
146
+ // is a `__lzp_<mid>` lookup) and is only emitted when it differs from the
147
+ // adopted form — i.e. when `textPathVar` is non-null.
148
+ let adoptedPlanVar: string | null = null
149
+ let freshPlanVar: string | null = null
150
+ if (lazyRow.texts.length > 0) {
151
+ adoptedPlanVar = `__lzs_${mid}`
152
+ const adoptedSlots: ClaimSlotSpec[] = lazyRow.texts.map(t => ({ id: t.slotId, kind: 'text', path: [] }))
153
+ lines.push(`${indent}const ${adoptedPlanVar} = ${claimPlanLiteral(adoptedSlots)}`)
154
+ if (textPathVar) {
155
+ freshPlanVar = `__lzsc_${mid}`
156
+ const freshSlots: ClaimSlotSpec[] = lazyRow.texts.map((t, i) => ({
157
+ id: t.slotId,
158
+ kind: 'text',
159
+ path: [],
160
+ pathExpr: `${textPathVar}[${i}]`,
161
+ }))
162
+ lines.push(`${indent}const ${freshPlanVar} = ${claimPlanLiteral(freshSlots)}`)
163
+ } else {
164
+ freshPlanVar = adoptedPlanVar
165
+ }
166
+ }
167
+
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}}`)
190
+ }
191
+
192
+ const call = `mapArrayLazy(() => ${o.arrayExpr}, ${o.containerVar}, ${o.keyFn}, {`
193
+ lines.push(`${indent}${o.guardContainer ? `if (${o.containerVar}) ` : ''}${call}`)
194
+
195
+ // --- createRow ---------------------------------------------------------
196
+ const b1 = `${indent} `
197
+ const b2 = `${indent} `
198
+ lines.push(`${b1}createRow: (__e, ${o.indexParam}) => {`)
199
+ // Always bound, even with no reactive bindings: the non-hoisted clone
200
+ // interpolates the per-row template, which reads the param for at least
201
+ // the `data-key` attribute.
202
+ lines.push(`${b2}const ${paramHead} = () => __e.item`)
203
+ const cloneExpr = useHoisted
204
+ ? hoistedCloneExpr(tplVar, o.skeletonTemplate!)
205
+ : `(() => { ${emitTemplateCloneInline(o.template)} })()`
206
+ lines.push(`${b2}const __el = ${cloneExpr}`)
207
+ if (hasRefs) {
208
+ lines.push(`${b2}const __r = __e.refs = [${refParts(lazyRow, '__el', useHoisted ? (paths ?? null) : null, freshPlanVar).join(', ')}]`)
209
+ }
210
+ if (hasBindings) {
211
+ lines.push(`${b2}const __l = __e.last = []`)
212
+ for (const a of lazyRow.attrs) emitAttrBinding(lines, b2, a, 'create')
213
+ const createDoor = `__r[${lazyRow.writerIndex}]`
214
+ for (const t of lazyRow.texts) emitTextBinding(lines, b2, t, createDoor, 'create', rwDoor)
215
+ }
216
+ lines.push(`${b2}return __el`)
217
+ lines.push(`${b1}},`)
218
+
219
+ // --- applyItem ---------------------------------------------------------
220
+ const itemAttrs = lazyRow.attrs.filter(a => a.readsItem)
221
+ const itemTexts = lazyRow.texts.filter(t => t.readsItem)
222
+ if (itemAttrs.length === 0 && itemTexts.length === 0) {
223
+ lines.push(`${b1}applyItem: () => {},`)
224
+ } else {
225
+ lines.push(`${b1}applyItem: (__e) => {`)
226
+ lines.push(`${b2}const ${paramHead} = () => __e.item`)
227
+ lines.push(`${b2}const __r = __e.refs ?? (__e.refs = ${claimVar}(__e))`)
228
+ lines.push(`${b2}const __l = __e.last ?? (__e.last = [])`)
229
+ for (const a of itemAttrs) emitAttrBinding(lines, b2, a, 'item')
230
+ if (itemTexts.length > 0) {
231
+ lines.push(`${b2}const __d = ${doorAccess(lazyRow, lazyRow.writerIndex, adoptedPlanVar)}`)
232
+ for (const t of itemTexts) emitTextBinding(lines, b2, t, '__d', 'item', rwDoor)
233
+ }
234
+ lines.push(`${b1}},`)
235
+ }
236
+
237
+ // --- applyOuter (only when some binding is outer-involving) -------------
238
+ const outerAttrs = lazyRow.attrs.filter(a => a.readsOuter)
239
+ const outerTexts = lazyRow.texts.filter(t => t.readsOuter)
240
+ if (outerAttrs.length > 0 || outerTexts.length > 0) {
241
+ const b3 = `${indent} `
242
+ lines.push(`${b1}applyOuter: (__es, __seed) => {`)
243
+ // Prime the outer reads so this ONE loop-level effect subscribes even
244
+ // when the entry list is momentarily empty (see module docstring).
245
+ for (const g of lazyRow.outerPrimeGetters) lines.push(`${b2}${g}()`)
246
+ lines.push(`${b2}for (const __e of __es) {`)
247
+ lines.push(`${b3}const ${paramHead} = () => __e.item`)
248
+ lines.push(`${b3}const __r = __e.refs ?? (__e.refs = ${claimVar}(__e))`)
249
+ lines.push(`${b3}const __l = __e.last ?? (__e.last = [])`)
250
+ for (const a of outerAttrs) emitAttrBinding(lines, b3, a, 'outer')
251
+ if (outerTexts.length > 0) {
252
+ lines.push(`${b3}const __d = ${doorAccess(lazyRow, lazyRow.writerIndex, adoptedPlanVar)}`)
253
+ for (const t of outerTexts) emitTextBinding(lines, b3, t, '__d', 'outer', rwDoor)
254
+ }
255
+ lines.push(`${b2}}`)
256
+ lines.push(`${b1}},`)
257
+ }
258
+
259
+ lines.push(`${indent}}, '${o.markerId}')`)
260
+ }
261
+
262
+ /**
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`.
266
+ *
267
+ * **Which door (per LOOP, never per binding).** `lazySlots` returns a bare
268
+ * write function; `lazyClaimSlots` returns the `{ write, read }` pair over
269
+ * the SAME claim, at the cost of an extra closure on EVERY row of the list
270
+ * (`claim-slots.ts` measured ~40-84KB/1k rows). So the RW door is taken only
271
+ * when this loop actually has an outer-involving text binding to seed by
272
+ * read-compare-write (`plan.textNeedsRead`, decided once in
273
+ * `build-lazy-row.ts`); every other loop keeps today's writer byte-for-byte.
274
+ *
275
+ * **`deferDoor`** (adopted-row claim only). Even the cheap write-only door is
276
+ * a closure per row, and an `applyOuter` that drives ATTRIBUTES only claims
277
+ * every row at seed without ever touching a content slot — so the door was
278
+ * being built 1,000 times for a list of 1,000 rows and used zero times until
279
+ * some row's item changed. With `deferDoor` the slot holds `null` and the
280
+ * first content write fills it (`doorAccess`). Measured on the SSR bench's
281
+ * 1,000-row table (item texts + one outer-signal class, the shape this
282
+ * describes): post-hydration heap 1630.6KB -> 1573.2KB, -57.4KB, reproduced
283
+ * twice, against a per-run stdev of 0.1-0.6KB and with react/solid unmoved.
284
+ *
285
+ * `createRow` does NOT defer: it writes every text on the tick it builds the
286
+ * row, so its door is used immediately and a `??=` would only add a branch.
287
+ *
288
+ * **Honest cost note.** Reading a text slot CLAIMS that row's whole plan
289
+ * (§2's claim-once rule — `read` and `write` share `claimRefs`), so a loop
290
+ * with an outer-involving TEXT still pays one claim per row at hydration
291
+ * instead of the row-pristine zero. That is inherent to read-compare-write
292
+ * for content: you cannot compare what you have not resolved. It is still
293
+ * far cheaper than the eager path this replaces, which pays a root + a
294
+ * signal + an effect per row. A loop whose outer bindings are all attributes
295
+ * now pays neither the claim nor the door.
296
+ *
297
+ * `skeletonPaths` non-null = fresh-clone context (`createRow`): resolve via
298
+ * compile-time child-index chains, no scan. Null = adopted-row context
299
+ * (`__lzc_<mid>`): `qsa` + an empty claim path, which A2's marker scan
300
+ * resolves — the sanctioned "cannot be statically pathed" case for a
301
+ * server-rendered tree the skeleton does not describe (§5-A3).
302
+ *
303
+ * `planVar` is the hoisted claim-plan variable for THIS context (`__lzs_<mid>`
304
+ * for the adopted-row call site, `__lzsc_<mid>` — or the same `__lzs_<mid>`
305
+ * when the two forms coincide — for the fresh-clone one), built once by
306
+ * `stringifyLazyRowLoop` and referenced here rather than rebuilt per row.
307
+ * `null` iff `lazyRow.texts` is empty, the only case that skips the text part
308
+ * below.
309
+ */
310
+ function refParts(
311
+ lazyRow: LazyRowPlanData,
312
+ elVar: string,
313
+ skeletonPaths: SkeletonSlotPaths | null,
314
+ planVar: string | null,
315
+ deferDoor = false,
316
+ ): string[] {
317
+ const parts: string[] = []
318
+ for (const slotId of lazyRow.attrSlotIds) {
319
+ const path = skeletonPaths?.elementPaths.get(slotId)
320
+ parts.push(path ? pathExpr(elVar, path) : `qsa(${elVar}, '[bf="${slotId}"]')`)
321
+ }
322
+ if (lazyRow.texts.length > 0) {
323
+ parts.push(deferDoor ? 'null' : `${doorCtor(lazyRow)}(${elVar}, ${planVar})`)
324
+ }
325
+ return parts
326
+ }
327
+
328
+ /** The door constructor for this loop — see `refParts`'s "which door" note. */
329
+ function doorCtor(lazyRow: LazyRowPlanData): string {
330
+ return lazyRow.textNeedsRead ? 'lazyClaimSlots' : 'lazySlots'
331
+ }
332
+
333
+ /**
334
+ * How a content write reaches this row's door.
335
+ *
336
+ * `createRow` seeds the door itself (fresh-clone plan, used on the same
337
+ * tick), so there it is a plain slot read. An ADOPTED row's slot is `null`
338
+ * until the first content write, so applyItem/applyOuter materialize it
339
+ * on demand against the ADOPTED plan — the only plan an adopted row can
340
+ * use, and reachable here because a row whose refs `createRow` seeded
341
+ * always finds a door already in the slot and never evaluates the `??`
342
+ * right-hand side.
343
+ *
344
+ * Emitted ONCE per apply body rather than per binding, and only when that
345
+ * body actually has content bindings: an `applyOuter` driving attributes
346
+ * only never mentions the door, which is the whole point of deferring it.
347
+ */
348
+ function doorAccess(
349
+ lazyRow: LazyRowPlanData,
350
+ writerIndex: number,
351
+ adoptedPlanVar: string | null,
352
+ ): string {
353
+ const slot = `__r[${writerIndex}]`
354
+ return `${slot} ?? (${slot} = ${doorCtor(lazyRow)}(__e.primaryEl, ${adoptedPlanVar}))`
355
+ }
356
+
357
+ /** `entry.last`-backed dedup test. `in` (not a truthiness check) so a
358
+ * legitimately `undefined` value still records and still writes once. */
359
+ function dedupGuard(ordinal: number): string {
360
+ return `!(${ordinal} in __l) || !Object.is(__l[${ordinal}], __x)`
361
+ }
362
+
363
+ function emitAttrBinding(
364
+ lines: string[],
365
+ ind: string,
366
+ a: LazyRowAttrBinding,
367
+ mode: 'create' | 'item' | 'outer',
368
+ ): void {
369
+ lines.push(`${ind}{ const __t = __r[${a.refIndex}]`)
370
+ lines.push(`${ind}if (__t) {`)
371
+ lines.push(`${ind} const __x = ${a.wrappedExpression}`)
372
+ const guard = mode === 'create'
373
+ ? null
374
+ : mode === 'item'
375
+ ? dedupGuard(a.ordinal)
376
+ : `__seed ? (${seedDiffersExpr('__t', a)}) : (${dedupGuard(a.ordinal)})`
377
+ const writeIndent = guard ? `${ind} ` : `${ind} `
378
+ if (guard) lines.push(`${ind} if (${guard}) {`)
379
+ for (const stmt of emitAttrUpdate('__t', a.attrName, '__x', a.meta)) {
380
+ lines.push(`${writeIndent}${stmt}`)
381
+ }
382
+ if (guard) lines.push(`${ind} }`)
383
+ lines.push(`${ind} __l[${a.ordinal}] = __x`)
384
+ lines.push(`${ind}} }`)
385
+ }
386
+
387
+ /**
388
+ * A content-slot write, in the same three modes as {@link emitAttrBinding}.
389
+ *
390
+ * `'outer'` seeds by read-compare-write (§9.3(1)) through the RW door's
391
+ * `read(id)`. `read` returns `null` when it cannot answer (slot never
392
+ * claimed, or not a 'text' slot) and `null !== <the string>` is always true,
393
+ * so the comparison already fails safe into "write it" — no explicit null
394
+ * handling is needed or wanted here.
395
+ *
396
+ * The `'outer'` mode emits a real `if`/`else if` rather than one ternary
397
+ * guard so the seed branch can bind `textOrNode(__x)` ONCE and use it for
398
+ * both the comparison and the write. A ternary would coerce twice on the
399
+ * seed path, which double-invokes a user value's `toString` — observable
400
+ * when it has side effects, wasted work when it doesn't. Hoisting it above
401
+ * the branch instead would fix that but would also coerce on the non-seed
402
+ * path even when dedup skips the write, i.e. on every later tick; the branch
403
+ * keeps exactly one coercion per path taken.
404
+ *
405
+ * `textOrNode` rather than a bare `String(...)` because a child-position
406
+ * interpolation can evaluate to a live Node (`props.renderRow(item)` handed
407
+ * an inline-JSX arrow), and `String(node)` destroys it. The helper passes a
408
+ * Node through so the claim door can promote the slot to 'markup' and splice
409
+ * it; see `claim-slots.ts`. Whether such a call yields a string or a Node is
410
+ * not decidable from the expression's syntax — both are `CallExpression` —
411
+ * so this stays a runtime decision on the value, not a compile-time
412
+ * classification.
413
+ *
414
+ * A Node also makes the seed comparison fail safe on its own: `read(id)`
415
+ * answers with a string or `null`, neither of which is ever `===` a Node, so
416
+ * the seed always writes. That is the correct direction — a Node is freshly
417
+ * built on this run and is never the SSR-rendered content by identity.
418
+ */
419
+ function emitTextBinding(
420
+ lines: string[],
421
+ ind: string,
422
+ t: LazyRowTextBinding,
423
+ doorExpr: string,
424
+ mode: 'create' | 'item' | 'outer',
425
+ rwDoor: boolean,
426
+ ): void {
427
+ lines.push(`${ind}{ const __x = ${t.wrappedExpression}`)
428
+ const writeOf = (valueExpr: string): string =>
429
+ rwDoor
430
+ ? `${doorExpr}.write('${t.slotId}', ${valueExpr})`
431
+ : `${doorExpr}('${t.slotId}', ${valueExpr})`
432
+
433
+ if (mode === 'outer') {
434
+ lines.push(`${ind}if (__seed) {`)
435
+ lines.push(`${ind} const __s = textOrNode(__x)`)
436
+ lines.push(`${ind} if (${doorExpr}.read('${t.slotId}') !== __s) ${writeOf('__s')}`)
437
+ lines.push(`${ind}} else if (${dedupGuard(t.ordinal)}) ${writeOf('textOrNode(__x)')}`)
438
+ } else if (mode === 'item') {
439
+ lines.push(`${ind}if (${dedupGuard(t.ordinal)}) ${writeOf('textOrNode(__x)')}`)
440
+ } else {
441
+ lines.push(`${ind}${writeOf('textOrNode(__x)')}`)
442
+ }
443
+ lines.push(`${ind}__l[${t.ordinal}] = __x }`)
444
+ }
445
+
446
+ /**
447
+ * Read-compare-write seeding predicate (§9.3(1)): does the CURRENT DOM
448
+ * differ from the value `__x` this binding would write? Mirrors
449
+ * `emitAttrUpdate`'s per-kind dispatch (`emit-reactive.ts`) — the two must
450
+ * be read together. An unrecognised kind returns `true`, i.e. write on the
451
+ * seed run unconditionally: conservative, never unsound.
452
+ */
453
+ function seedDiffersExpr(target: string, a: LazyRowAttrBinding): string {
454
+ const html = toHtmlAttrName(a.attrName)
455
+ if (a.attrName === 'dangerouslySetInnerHTML' || html === 'dangerouslySetInnerHTML') return 'true'
456
+ if (html === 'style') return `${target}.getAttribute('style') !== styleToCss(__x)`
457
+ if (html === 'class') return `${target}.getAttribute('class') !== (__x != null ? String(__x) : null)`
458
+ if (html === 'value') return `${target}.value !== String(__x)`
459
+ if (isBooleanAttr(html)) return `${target}.${html} !== !!(__x)`
460
+ if (a.meta.presenceOrUndefined) {
461
+ // Compare the VALUE the writer would produce, not just presence.
462
+ // `emitAttrUpdate` writes `'true'` for `aria-*` (WAI-ARIA requires an
463
+ // explicit value) and `''` for everything else, while SSR renders these
464
+ // as a BARE attribute name (`templateAttrExpr` in `html-template.ts`),
465
+ // which parses to the empty string. So for `aria-*` the SSR value and
466
+ // the client's value legitimately differ while presence agrees — a
467
+ // presence-only check would skip the seed write and leave
468
+ // `aria-pressed=""` where the eager path produces `aria-pressed="true"`.
469
+ // `getAttribute` returns null when absent, which is exactly the falsy
470
+ // expectation, so one comparison covers both directions.
471
+ const written = html.startsWith('aria-') ? 'true' : ''
472
+ return `${target}.getAttribute('${html}') !== (__x ? '${written}' : null)`
473
+ }
474
+ return `${target}.getAttribute('${html}') !== (__x != null ? String(__x) : null)`
475
+ }
476
+
477
+ /** Exported for the emission-shape unit tests. */
478
+ export const __lazyRowInternals = { seedDiffersExpr, dedupGuard }
@@ -17,6 +17,7 @@ import { emitAttrUpdate } from '../../emit-reactive.ts'
17
17
  import { templateRootIsSvg } from './template-parse.ts'
18
18
  import { emitListenerLine } from './event-listener.ts'
19
19
  import { nameForRegistryRef } from '../../component-scope.ts'
20
+ import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
20
21
  import type {
21
22
  BranchChildComponentInitsPlan,
22
23
  BranchEventBindingsPlan,
@@ -96,6 +97,11 @@ export function stringifyBranchEventBindings(
96
97
  * SSR side: element has `bf-s` → qsa() finds it, initChild wires events.
97
98
  * CSR side: element is a `data-bf-ph` placeholder → createComponent
98
99
  * replaces it, then initChild runs against the new element.
100
+ *
101
+ * The placeholder is passed to `createComponent` as its `mountAt`
102
+ * argument so the component's own init runs connected — context resolves
103
+ * by DOM position, and a detached init falls back to the global,
104
+ * last-writer-wins context store.
99
105
  */
100
106
  export function stringifyBranchChildComponentInits(
101
107
  lines: string[],
@@ -103,7 +109,7 @@ export function stringifyBranchChildComponentInits(
103
109
  indent: string,
104
110
  ): void {
105
111
  for (const init of plan) {
106
- lines.push(`${indent}{ let __c = qsa(__branchScope, ${init.selector}); if (!__c) { const __ph = __branchScope.querySelector('[${DATA_BF_PH}="${init.placeholderId}"]'); if (__ph) { __c = createComponent('${nameForRegistryRef(init.name)}', ${init.propsExpr}); __ph.replaceWith(__c) } } if (__c) initChild('${nameForRegistryRef(init.name)}', __c, ${init.propsExpr}) }`)
112
+ lines.push(`${indent}{ let __c = qsa(__branchScope, ${init.selector}); if (!__c) { const __ph = __branchScope.querySelector('[${DATA_BF_PH}="${init.placeholderId}"]'); if (__ph) { __c = createComponent('${nameForRegistryRef(init.name)}', ${init.propsExpr}, undefined, undefined, __ph) } } if (__c) initChild('${nameForRegistryRef(init.name)}', __c, ${init.propsExpr}) }`)
107
113
  }
108
114
  }
109
115
 
@@ -153,15 +159,22 @@ export function stringifyBranchInnerLoops(
153
159
  inner.outerLoopParamBindings,
154
160
  )
155
161
  }
156
- for (const text of inner.reactiveTexts) {
162
+ const conditionalTexts = inner.reactiveTexts.filter(t => t.insideConditional)
163
+ const plainTexts = inner.reactiveTexts.filter(t => !t.insideConditional)
164
+ for (const text of conditionalTexts) {
157
165
  const bf = profileBindingId(pc, text.slotId)
158
- if (text.insideConditional) {
159
- // Re-query $t inside the effect: insert() may swap the text node so a
160
- // captured reference would silently stop updating.
161
- lines.push(`${indent} createEffect(() => { const [__rt] = $t(__bel${uid}, '${text.slotId}'); if (__rt) __rt.textContent = String(${text.wrappedExpression}) }${bf})`)
162
- } else {
163
- lines.push(`${indent} { const [__rt] = $t(__bel${uid}, '${text.slotId}')`)
164
- lines.push(`${indent} if (__rt) createEffect(() => { __rt.textContent = String(${text.wrappedExpression}) }${bf}) }`)
166
+ // Fresh `claimSlots` claim every run (not the cached `lazySlots` door):
167
+ // insert() may swap the branch's DOM between runs, and a 'text' write
168
+ // is a plain, idempotent `nodeValue` assignment with no dedup state to
169
+ // go stale replaces the old re-query-$t-on-every-run discipline.
170
+ lines.push(`${indent} createEffect(() => { claimSlots(__bel${uid}, [{ id: '${text.slotId}', kind: 'text', path: [] }]).write('${text.slotId}', String(${text.wrappedExpression})) }${bf})`)
171
+ }
172
+ if (plainTexts.length > 0) {
173
+ const slots: ClaimSlotSpec[] = plainTexts.map(t => ({ id: t.slotId, kind: 'text', path: [] }))
174
+ const writer = claimWriterVarName(slots, varSlotId)
175
+ lines.push(`${indent} const ${writer} = lazySlots(__bel${uid}, ${claimPlanLiteral(slots)})`)
176
+ for (const text of plainTexts) {
177
+ lines.push(`${indent} createEffect(() => { ${writer}('${text.slotId}', String(${text.wrappedExpression})) }${profileBindingId(pc, text.slotId)})`)
165
178
  }
166
179
  }
167
180
  if (inner.nestedConditionals.length > 0) {
@@ -254,14 +267,25 @@ export function stringifyLoopChildArm(
254
267
  lines.push(`${armIndent}}))`)
255
268
  }
256
269
 
257
- for (const text of arm.texts) {
258
- // __bfText (not a naive `.textContent = String(...)`) so a Child-position
259
- // expression whose value is a live Node (e.g. a hoisted `renderNode={(n)
260
- // => <PillNode/>}` callback, #1213) is spliced into the slot by identity
261
- // instead of being stringified to "[object HTMLElement]" (#2347).
262
- const varName = `__rt_${varSlotId(text.slotId)}`
263
- lines.push(`${armIndent}let ${varName} = $t(__branchScope, '${text.slotId}')[0]`)
264
- lines.push(`${armIndent}__disposers.push(createDisposableEffect(() => { ${varName} = __bfText(${varName}, ${text.wrappedExpression}) }${profileBindingId(pc, text.slotId)}))`)
270
+ if (arm.texts.length > 0) {
271
+ // 'markup' kind (not 'text') because a Child-position expression's value
272
+ // may be a live Node (e.g. a hoisted `renderNode={(n) => <PillNode/>}`
273
+ // callback, #1213) claim-slots' markup writer splices a Node in by
274
+ // identity instead of stringifying it to "[object HTMLElement]" (#2347),
275
+ // the same contract `__bfText` used to provide. The writer is built
276
+ // fresh HERE, inside this arm's `bindEvents` body, so every branch
277
+ // activation re-claims against that swap's own `__branchScope` DOM
278
+ // (spec/slot-unification.md §5-A3's "branch-internal slots re-claim per
279
+ // branch activation").
280
+ const slots: ClaimSlotSpec[] = arm.texts.map(t => ({ id: t.slotId, kind: 'markup', path: [] }))
281
+ const writer = claimWriterVarName(slots, varSlotId)
282
+ lines.push(`${armIndent}const ${writer} = lazySlots(__branchScope, ${claimPlanLiteral(slots)})`)
283
+ for (const text of arm.texts) {
284
+ // `escapeTextOrNode`: see emit-reactive.ts's identical wrap for why a
285
+ // string value must be escaped before a 'markup' writer's
286
+ // `innerHTML =` insertion, while a live Node passes through untouched.
287
+ lines.push(`${armIndent}__disposers.push(createDisposableEffect(() => { ${writer}('${text.slotId}', escapeTextOrNode(${text.wrappedExpression})) }${profileBindingId(pc, text.slotId)}))`)
288
+ }
265
289
  }
266
290
 
267
291
  lines.push(`${armIndent}return () => __disposers.forEach(d => d())`)