@barefootjs/jsx 0.27.0 → 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 (50) hide show
  1. package/dist/index.js +620 -26
  2. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +6 -0
  3. package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
  4. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts +2 -1
  5. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts +7 -0
  7. package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +107 -0
  9. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -0
  10. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts +9 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +203 -0
  13. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -0
  14. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -0
  15. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/branch-loop.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts +98 -0
  18. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -0
  19. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +5 -0
  20. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/imports.d.ts +2 -2
  24. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  25. package/package.json +2 -2
  26. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +162 -36
  27. package/src/__tests__/client-js-generation.test.ts +4 -1
  28. package/src/__tests__/composite-branch-loop.test.ts +12 -3
  29. package/src/__tests__/conditional-mapArray-key.test.ts +7 -1
  30. package/src/__tests__/create-selector.test.ts +21 -7
  31. package/src/__tests__/lazy-row-eligibility.test.ts +692 -0
  32. package/src/__tests__/loop-branch-bare-expression-reactive-text.test.ts +98 -0
  33. package/src/__tests__/loop-fallback-wrap.test.ts +31 -12
  34. package/src/__tests__/loop-hoisted-template.test.ts +10 -6
  35. package/src/__tests__/static-loop-csr-materialize.test.ts +6 -1
  36. package/src/ir-to-client-js/collect-elements.ts +48 -13
  37. package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +6 -0
  38. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +35 -12
  39. package/src/ir-to-client-js/control-flow/plan/build-insert.ts +9 -2
  40. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +305 -0
  41. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +44 -12
  42. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +440 -0
  43. package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -0
  44. package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +22 -0
  45. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +478 -0
  46. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +6 -1
  47. package/src/ir-to-client-js/control-flow/stringify/loop.ts +23 -0
  48. package/src/ir-to-client-js/control-flow.ts +7 -2
  49. package/src/ir-to-client-js/imports.ts +8 -2
  50. package/src/jsx-to-ir.ts +18 -1
@@ -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 }
@@ -97,6 +97,11 @@ export function stringifyBranchEventBindings(
97
97
  * SSR side: element has `bf-s` → qsa() finds it, initChild wires events.
98
98
  * CSR side: element is a `data-bf-ph` placeholder → createComponent
99
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.
100
105
  */
101
106
  export function stringifyBranchChildComponentInits(
102
107
  lines: string[],
@@ -104,7 +109,7 @@ export function stringifyBranchChildComponentInits(
104
109
  indent: string,
105
110
  ): void {
106
111
  for (const init of plan) {
107
- 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}) }`)
108
113
  }
109
114
  }
110
115
 
@@ -37,6 +37,7 @@ import { buildSkeletonPathPlan, type SkeletonPathPlan } from './skeleton-paths.t
37
37
  import { stringifyComponentLoop } from './component-loop.ts'
38
38
  import { stringifyCompositeLoop } from './composite-loop.ts'
39
39
  import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
40
+ import { stringifyLazyRowLoop } from './lazy-row.ts'
40
41
  import type { LoopChildRefBinding, LoopPlan, PlainLoopPlan, StaticLoopPlan } from '../plan/types.ts'
41
42
 
42
43
  /**
@@ -182,6 +183,28 @@ export function stringifyPlainLoop(
182
183
  return
183
184
  }
184
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
+
185
208
  // Hoisted shared-template fast path (perf, see `buildLoopSkeletonTemplate`):
186
209
  // declare the once-per-loop template BEFORE the `mapArray` call so every
187
210
  // row clones from an already-parsed node instead of re-running
@@ -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
@@ -8,7 +8,7 @@ import { isClientBuiltinName } from '../builtins.ts'
8
8
  // All exports from @barefootjs/client/runtime that may be used in generated code
9
9
  export const RUNTIME_IMPORT_CANDIDATES = [
10
10
  'createSignal', 'createMemo', 'createEffect', 'onCleanup', 'onMount',
11
- 'hydrate', 'insert', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'patchLeaf', 'createDisposableEffect',
11
+ 'hydrate', 'insert', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'mapArrayLazy', 'patchLeaf', 'createDisposableEffect',
12
12
  'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild',
13
13
  'createPortal',
14
14
  'provideContext', 'createContext', 'useContext',
@@ -17,7 +17,13 @@ export const RUNTIME_IMPORT_CANDIDATES = [
17
17
  // Claim-plan interpreter (slot unification A2/A3, spec/slot-unification.md)
18
18
  // — the "one claim mechanism" that replaced `patchSlotRange` and
19
19
  // `updateClientMarker` (both deleted) as the content-slot update door.
20
- 'claimSlots', 'lazySlots',
20
+ // `lazyClaimSlots` is the read-capable twin of `lazySlots` over the same
21
+ // claim — emitted only by lazy loops that seed an outer-involving TEXT
22
+ // binding by read-compare-write (§9.3(1)).
23
+ // `textOrNode` is the 'text' door's Node guard: a child-position value that
24
+ // turns out to be a live Node must reach the writer as a Node so the claim
25
+ // can promote to 'markup', never as `String(node)`.
26
+ 'claimSlots', 'lazySlots', 'lazyClaimSlots', 'textOrNode',
21
27
  // Profile mode (#1690, SR3) — turn-boundary markers around event handlers.
22
28
  'beginTurn', 'endTurn',
23
29
  // Catalogued `Date` lowering (#2274/#2292) — the client counterpart to
package/src/jsx-to-ir.ts CHANGED
@@ -2707,7 +2707,24 @@ function transformConditionalBranch(
2707
2707
  const callsReactive = exprCallsReactiveGetters(node, ctx)
2708
2708
  const hasCalls = exprHasFunctionCalls(node)
2709
2709
  const reactive = isReactiveExpression(exprText, ctx, node) || isReactiveOrigin(branchOrigin)
2710
- const needsSlot = reactive || callsReactive
2710
+ // A branch whose entire value is a bare loop-item read (`row.label`) sets
2711
+ // neither `reactive` nor `callsReactive`: `render-item` is deliberately
2712
+ // excluded from `REACTIVE_BINDING_KINDS` (types.ts) because per-item
2713
+ // reactivity flows through the loop's own per-item signal accessor, not
2714
+ // this origin-based classification, and there is no call to trip
2715
+ // `callsReactive`/`hasCalls` either. Without a slotId here, a keyed loop
2716
+ // row that changes value without its condition flipping has nothing for
2717
+ // `collectLoopChildReactiveTexts` (ir-to-client-js/reactivity.ts) to
2718
+ // attach an update effect to, and the branch is frozen at its
2719
+ // mount-time value forever (the loop-branch-stale-text defect). Read the
2720
+ // freeRefs this function already computed above — no new parse, and no
2721
+ // regex re-scan of `exprText` (contrast the legacy `referencesLoopParam`
2722
+ // used by the sibling `transformConditional`/`transformLogicalAnd`
2723
+ // condition-side decisions, which token-matches the loop param name
2724
+ // against expression TEXT and can false-match inside an unrelated string
2725
+ // literal branch like `"this row is empty"`).
2726
+ const refsLoopParam = branchOrigin.freeRefs?.some(r => r.kind === 'render-item') ?? false
2727
+ const needsSlot = reactive || callsReactive || refsLoopParam
2711
2728
  const slotId = needsSlot ? generateSlotId(ctx) : null
2712
2729
  return {
2713
2730
  type: 'expression',