@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
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Lazy row graph eligibility — `spec/slot-unification.md` §9.4 (L3).
3
+ *
4
+ * ONE explicit decision function (`lazyRowEligibility`) answering a single
5
+ * question: may this plain loop row be emitted through `mapArrayLazy`
6
+ * (a compiler-emitted `LazyRowPlan`, no per-row reactive resources) instead
7
+ * of today's A3b-consolidated eager `mapArray` emission?
8
+ *
9
+ * Sound-or-loud (§9.3): the answer is always either `{ eligible: true }` or
10
+ * `{ eligible: false, reason }`. There is no silent third path — an
11
+ * ineligible loop keeps byte-for-byte today's emission, and `reason` is a
12
+ * human-readable string so a regression is diagnosable from a unit test
13
+ * rather than by diffing generated JS.
14
+ *
15
+ * The gate is deliberately conservative. Every restriction below is a
16
+ * "widen later" decision, never a correctness compromise:
17
+ *
18
+ * 1. **Shape** (§9.4 "plain loop-row shape"): non-anchored, non-flatMap,
19
+ * single-root, keyed, conditional-free, ref-free, no nested components
20
+ * or inner loops.
21
+ * 2. **No preamble.** A `.map()` callback preamble declares row-local
22
+ * bindings (and can declare row-local signals/memos, which need per-row
23
+ * reactivity by definition). Proving "declares no signal/memo" is not
24
+ * cheap here, and every preamble-declared local would land in a
25
+ * binding's free-identifier set as an unresolvable name anyway — so the
26
+ * rule is the cheap conservative one: **any** `mapPreambleWrapped` or
27
+ * any `preambleRegions` entry makes the loop ineligible.
28
+ * 3. **Every reactive outer dependency must be primable.** `mapArrayLazy`
29
+ * creates ONE loop-level effect for `applyOuter`; that effect subscribes
30
+ * only to what its body reads, and its body must read the outer signals
31
+ * even when the entry list is momentarily EMPTY (otherwise the effect
32
+ * never subscribes and the loop goes permanently dead). The emitter
33
+ * primes those reads with `getter()` statements, which is only possible
34
+ * for names it can prove are zero-arg component signal / memo getters.
35
+ * Any other name that could change reactively — a prop accessor (props
36
+ * may be defined as getters over the parent's signals), or a name the
37
+ * compiler cannot classify at all — is `opaque` and makes the loop
38
+ * ineligible. Imports and component-local functions are opaque TOO
39
+ * (`const isSelected = createSelector(selected)` is an ordinary local
40
+ * whose CALL is the reactive read), so they refuse the loop as well —
41
+ * the `inert` set names them only so the refusal can say which one.
42
+ * Genuinely ignorable are the names that cannot carry a reactive read
43
+ * at all: literal-derived local / module constants and pure globals.
44
+ * 4. **Outer-involving TEXT bindings are ACCEPTED** (§9.5, lifted).
45
+ * §9.3(1) requires read-compare-write seeding on `applyOuter`'s first
46
+ * run: compute the value, READ the current DOM, write only on
47
+ * difference. For an attribute that read is `getAttribute` / a DOM
48
+ * property on the held element ref. For a content slot the read door is
49
+ * `lazyClaimSlots`' `read(id)` (`claim-slots.ts`), the read-capable twin
50
+ * of `lazySlots` over the SAME claim. The emitter picks the door per
51
+ * LOOP — a loop with no outer-involving text keeps the single-closure
52
+ * writer — so this widening costs nothing for loops that do not use it
53
+ * (see `refParts` in `stringify/lazy-row.ts`).
54
+ * 5. **Loop-source hydration consistency** (§9.3(2)): item-driven bindings
55
+ * are never evaluated at hydration, so the SSR rows and the first client
56
+ * `items()` read are TRUSTED to agree. That trust is only sound when
57
+ * both derive from the same data — props (identical by construction via
58
+ * the `bf-p` protocol), literals, and derivations thereof. Any name in
59
+ * the loop source that reaches an import, an environment read, or
60
+ * anything the compiler cannot resolve → ineligible.
61
+ * 6. **Profile mode is never lazy** (§9.4, same policy as A3b): a merged
62
+ * loop-level effect would make every binding on every row share one
63
+ * `#binding:<slotId>` id.
64
+ */
65
+
66
+ /** Facts about one component signal needed by the §9.3(2) source gate. */
67
+ export interface LazyRowSignalFacts {
68
+ /**
69
+ * Free identifiers of the signal's initializer, derived from its
70
+ * structured `SignalInfo.parsed` tree. `null` when `parsed` was absent or
71
+ * the tree contained an `unsupported` node — "unprovable", which the gate
72
+ * treats as a hard stop rather than an assumption.
73
+ */
74
+ initializerFreeIdentifiers: ReadonlySet<string> | null
75
+ }
76
+
77
+ /** Component-scope facts the gate resolves names against. */
78
+ export interface LazyRowScopeInfo {
79
+ /** Signal getter name → initializer facts. */
80
+ signals: ReadonlyMap<string, LazyRowSignalFacts>
81
+ /** Memo getter names — reactive and primable via `name()`. */
82
+ memos: ReadonlySet<string>
83
+ /** Props emit name (`_p`), the source props object name, and every destructured prop param. */
84
+ props: ReadonlySet<string>
85
+ /**
86
+ * Constant name → free identifiers of its value (`ConstantInfo.freeIdentifiers`).
87
+ * An entry with an EMPTY set is literal-derived. Absent free-id data is
88
+ * modelled as `null` → unprovable for the source gate (but still inert for
89
+ * the binding gate — a `const` never changes reactively).
90
+ */
91
+ constants: ReadonlyMap<string, ReadonlySet<string> | null>
92
+ /**
93
+ * Import local names and component-local function names. Used ONLY by the
94
+ * §9.3(2) source gate (to name the failure precisely); binding
95
+ * classification treats them as OPAQUE, not inert — see
96
+ * {@link classifyLazyBinding}.
97
+ */
98
+ inert: ReadonlySet<string>
99
+ /** Profile mode (#1690) — never lazy. */
100
+ profile: boolean
101
+ }
102
+
103
+ /**
104
+ * Globals a loop source may legitimately reach without breaking
105
+ * hydration consistency: pure, deterministic, environment-free. Anything
106
+ * NOT in this list (`Date`, `window`, `document`, `localStorage`,
107
+ * `navigator`, `performance`, `Math.random` via `Math`… ) is rejected by
108
+ * the source gate. `Math` is deliberately absent: `Math.random()` is an
109
+ * environment read in every way that matters here.
110
+ */
111
+ const PURE_SOURCE_GLOBALS: ReadonlySet<string> = new Set([
112
+ 'Object', 'Array', 'JSON', 'Number', 'String', 'Boolean',
113
+ ])
114
+
115
+ /**
116
+ * Globals that are inert for BINDING classification (they cannot change
117
+ * reactively, so a binding reading one needs no subscription). Broader than
118
+ * {@link PURE_SOURCE_GLOBALS} — a binding may read `Date`/`Intl` and still be
119
+ * lazy, because the binding is re-evaluated on every apply; only the LOOP
120
+ * SOURCE has a hydration-trust obligation.
121
+ */
122
+ const INERT_BINDING_GLOBALS: ReadonlySet<string> = new Set([
123
+ 'Object', 'Array', 'JSON', 'Number', 'String', 'Boolean', 'Math', 'Date',
124
+ 'Intl', 'Symbol', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Promise', 'RegExp',
125
+ 'Error', 'BigInt', 'console', 'undefined', 'NaN', 'Infinity', 'globalThis',
126
+ 'parseInt', 'parseFloat', 'isNaN', 'isFinite',
127
+ 'encodeURIComponent', 'decodeURIComponent', 'encodeURI', 'decodeURI',
128
+ ])
129
+
130
+ /**
131
+ * Sentinel `opaqueOuterNames` entry for "free-identifier analysis produced
132
+ * nothing". Distinct from a real opaque NAME: a name means the identifier
133
+ * set is known and only its primability is not, which the re-subscribe seam
134
+ * handles; this means the set itself is unknown, which nothing can handle.
135
+ */
136
+ export const UNKNOWN_IDENTIFIERS = '<unknown>'
137
+
138
+ /** One reactive binding of the row, already classified at build time. */
139
+ export interface ClassifiedLazyBinding {
140
+ kind: 'attr' | 'text'
141
+ slotId: string
142
+ /** Reads the loop param / a destructured param binding. */
143
+ readsItem: boolean
144
+ /** Reads at least one reactive outer name (or is `opaque`). */
145
+ readsOuter: boolean
146
+ /**
147
+ * Reactive outer names this binding depends on — signal / memo getters the
148
+ * emitter primes at the top of `applyOuter` so the loop-level effect
149
+ * subscribes even with zero rows.
150
+ */
151
+ reactiveOuterNames: readonly string[]
152
+ /**
153
+ * Outer names that are neither primable (signal/memo) nor provably inert
154
+ * — including the FAIL-SAFE case where free identifiers were unavailable
155
+ * (then `readsItem`/`readsOuter` are both forced true and this carries the
156
+ * `'<unknown>'` sentinel). Non-empty ⇒ ineligible.
157
+ */
158
+ opaqueOuterNames: readonly string[]
159
+ /** References the loop's index parameter — `applyItem`/`applyOuter` have no index. */
160
+ referencesIndex: boolean
161
+ }
162
+
163
+ /** §9.4 shape facts, read off the loop plan / IR by the caller. */
164
+ export interface LazyRowShapeFacts {
165
+ /** Which emission site is asking. Both plain-loop-row sites are in scope. */
166
+ callSite: 'plain' | 'branch-plain'
167
+ flatMapLeafItem: boolean
168
+ anchored: boolean
169
+ bodyIsMultiRoot: boolean
170
+ /** `LoopCore.key != null` — index-keyed loops are ineligible in v1 (§9.4). */
171
+ hasExplicitKey: boolean
172
+ conditionalCount: number
173
+ childRefCount: number
174
+ nestedComponentCount: number
175
+ innerLoopCount: number
176
+ hasChildComponent: boolean
177
+ hasMapPreamble: boolean
178
+ preambleRegionCount: number
179
+ /** A destructured loop param with no `paramBindings` (snapshot unwrap). */
180
+ hasParamUnwrap: boolean
181
+ }
182
+
183
+ export interface LazyRowEligibilityArgs {
184
+ shape: LazyRowShapeFacts
185
+ bindings: readonly ClassifiedLazyBinding[]
186
+ /**
187
+ * Free identifiers of the loop's SOURCE (`LoopCore.arrayFreeIdentifiers`
188
+ * unioned with the free identifiers of the fully-chained array expression,
189
+ * so `.filter(...)`/`.sort(...)` dependencies are covered). `null` when the
190
+ * IR carried none — ineligible, never assumed empty.
191
+ */
192
+ arraySourceIdentifiers: ReadonlySet<string> | null
193
+ scope: LazyRowScopeInfo
194
+ }
195
+
196
+ export type LazyRowEligibility =
197
+ | { eligible: true }
198
+ | { eligible: false; reason: string }
199
+
200
+ const NO: (reason: string) => LazyRowEligibility = (reason) => ({ eligible: false, reason })
201
+
202
+ /**
203
+ * Decide whether one plain loop row may use the lazy row graph (§9.4).
204
+ * Pure — every input is pre-resolved data, so this is directly unit-testable
205
+ * without building a component.
206
+ */
207
+ export function lazyRowEligibility(args: LazyRowEligibilityArgs): LazyRowEligibility {
208
+ const { shape, bindings, arraySourceIdentifiers, scope } = args
209
+
210
+ // (8) Profile mode keeps the granular eager emission so `#binding:<slotId>`
211
+ // attribution stays truthful — same policy as A3b.
212
+ if (scope.profile) return NO('profile mode keeps the granular eager emission')
213
+
214
+ // (1) Plain loop-row shape.
215
+ if (shape.callSite !== 'plain' && shape.callSite !== 'branch-plain') {
216
+ return NO(`call site '${shape.callSite}' is not a plain loop row`)
217
+ }
218
+ if (shape.flatMapLeafItem) return NO('flatMap descriptor loop (build-or-patch renderItem)')
219
+ if (shape.anchored) return NO('anchored whole-item-conditional loop')
220
+
221
+ // (2) Single-root rows — multi-root needs startMarker/extras bookkeeping
222
+ // `mapArrayLazy` deliberately does not carry.
223
+ if (shape.bodyIsMultiRoot) return NO('multi-root (Fragment) row')
224
+
225
+ // (3) Keyed via an explicit `key`.
226
+ //
227
+ // This is a FAIL-SAFE, not a widening opportunity — do not plan work
228
+ // around it. A `.map()` whose row element has no valid `key` is already
229
+ // a compile ERROR upstream (BF023 / BF024, `checkLoopKey` in
230
+ // `jsx-to-ir.ts`), so no program that compiles cleanly can reach this
231
+ // branch through the plain / branch-plain call sites. It stays because
232
+ // the gate must not depend on another pass having rejected the input,
233
+ // and because a `null` keyFn would silently switch `mapArrayLazy` to
234
+ // positional keying — correct in itself, but a decision this function
235
+ // should make explicitly rather than inherit.
236
+ //
237
+ // `lazy-row-eligibility.test.ts` pins the BF023 half so that if unkeyed
238
+ // loops ever become legal, the test naming this fails and points here.
239
+ if (!shape.hasExplicitKey) return NO('index-keyed loop (no explicit key)')
240
+
241
+ // (4) No conditionals in the row.
242
+ if (shape.conditionalCount > 0) return NO('row contains a reactive conditional')
243
+
244
+ // (5) No refs / child components / inner loops.
245
+ if (shape.childRefCount > 0) return NO('row has imperative child refs')
246
+ if (shape.hasChildComponent) return NO('row body is a child component')
247
+ if (shape.nestedComponentCount > 0) return NO('row contains nested child components')
248
+ if (shape.innerLoopCount > 0) return NO('row contains an inner loop')
249
+
250
+ // (6) No row-local declarations — see the module docstring for why the
251
+ // rule is "any preamble at all", not "a preamble declaring signals".
252
+ if (shape.hasMapPreamble) return NO('row has a map-callback preamble (may declare row-local reactivity)')
253
+ if (shape.preambleRegionCount > 0) return NO('row has preamble-patched regions')
254
+ if (shape.hasParamUnwrap) return NO('destructured loop param without param bindings')
255
+
256
+ // Per-binding gates.
257
+ for (const b of bindings) {
258
+ if (b.referencesIndex) {
259
+ return NO(`binding on slot ${b.slotId} references the loop index parameter`)
260
+ }
261
+ // An identifier set we could not compute at all is different in kind
262
+ // from a name we CAN see but cannot prime. With `free === null` the
263
+ // classifier knows nothing — not the outer reads, and not whether the
264
+ // binding reads the loop INDEX (which it reports as `false` by
265
+ // assumption, since there is nothing to look at). The seam covers
266
+ // stranded subscriptions; it cannot conjure an index parameter that
267
+ // `applyItem`/`applyOuter` do not have. So this one still refuses.
268
+ if (b.opaqueOuterNames.includes(UNKNOWN_IDENTIFIERS)) {
269
+ return NO(`binding on slot ${b.slotId} has no analyzable identifier set`)
270
+ }
271
+ // An outer name the emitter cannot prime used to refuse the loop: the
272
+ // loop-level effect must subscribe on its FIRST run, and with an empty
273
+ // entry list the per-entry reads never execute, so it would subscribe to
274
+ // nothing and go permanently dead. The runtime's re-subscribe seam
275
+ // in `mapArrayLazy` removes that obligation from the compiler (§9.3a):
276
+ // every loop-level outer effect re-runs after a reconcile that created a
277
+ // row or changed an item, so its subscription set is rebuilt against the
278
+ // current entries no matter what the reads turn out to be. Nothing to
279
+ // check here any more — and nothing to signal either, since the seam is
280
+ // unconditional rather than something the compiler opts a loop into.
281
+ }
282
+
283
+ // (7) Hydration-consistency gate on the loop source (§9.3(2)).
284
+ if (!arraySourceIdentifiers) return NO('loop source free identifiers unavailable')
285
+ const sourceGate = checkSourceConsistency(arraySourceIdentifiers, scope)
286
+ if (sourceGate) return NO(`loop source is not provably hydration-consistent: ${sourceGate}`)
287
+
288
+ return { eligible: true }
289
+ }
290
+
291
+ /**
292
+ * §9.3(2): walk each name in the loop source down to props / literals.
293
+ * Returns `null` when every name resolves, otherwise the first failure
294
+ * reason. Cycle-safe via `seen`.
295
+ */
296
+ function checkSourceConsistency(
297
+ names: ReadonlySet<string>,
298
+ scope: LazyRowScopeInfo,
299
+ ): string | null {
300
+ const seen = new Set<string>()
301
+
302
+ const resolve = (name: string): string | null => {
303
+ if (seen.has(name)) return null // already proven (or in-progress) — cycles cannot add a new source
304
+ seen.add(name)
305
+
306
+ if (scope.props.has(name)) return null // prop accessor — identical to SSR by the bf-p protocol
307
+ if (PURE_SOURCE_GLOBALS.has(name)) return null
308
+
309
+ const constFree = scope.constants.get(name)
310
+ if (constFree !== undefined) {
311
+ if (constFree === null) return `constant '${name}' has no analyzable value`
312
+ for (const inner of constFree) {
313
+ const failure = resolve(inner)
314
+ if (failure) return failure
315
+ }
316
+ return null
317
+ }
318
+
319
+ const signal = scope.signals.get(name)
320
+ if (signal !== undefined) {
321
+ if (signal.initializerFreeIdentifiers === null) {
322
+ return `signal '${name}' has no structured initializer to prove props/literal derivation`
323
+ }
324
+ for (const inner of signal.initializerFreeIdentifiers) {
325
+ const failure = resolve(inner)
326
+ if (failure) return failure
327
+ }
328
+ return null
329
+ }
330
+
331
+ if (scope.memos.has(name)) return `memo '${name}' initializer is not analyzed by the v1 gate`
332
+ if (scope.inert.has(name)) return `'${name}' is an import or local function`
333
+ return `'${name}' does not resolve to a prop, literal-derived const, or props/literal-derived signal`
334
+ }
335
+
336
+ for (const name of names) {
337
+ const failure = resolve(name)
338
+ if (failure) return failure
339
+ }
340
+ return null
341
+ }
342
+
343
+ /**
344
+ * Classify one binding's free identifiers into the row-local / reactive-outer
345
+ * / opaque-outer partition {@link lazyRowEligibility} consumes.
346
+ *
347
+ * **FAIL-SAFE (mandatory, §9.2):** when `free` is `null` — the IR carried no
348
+ * pre-computed free identifiers, or `freeIdentifiers()` refused the parsed
349
+ * tree (an `unsupported` node) — BOTH `readsItem` and `readsOuter` are forced
350
+ * true. A binding with `readsItem` is emitted into `applyItem`; a binding with
351
+ * `readsOuter` into `applyOuter`; a binding in both is emitted in BOTH. Double
352
+ * application is idempotent because every emitted write is dedup-guarded
353
+ * against `entry.last`, so "both" is always the safe answer and never a
354
+ * correctness risk.
355
+ *
356
+ * The unknown case ALSO records an `'<unknown>'` opaque name, which makes the
357
+ * loop ineligible: an outer dependency the compiler cannot name is an outer
358
+ * dependency it cannot PRIME, and an unprimed `applyOuter` effect would never
359
+ * subscribe (see module docstring, restriction 3). So the fail-safe direction
360
+ * is preserved (maximally conservative classification) while the emission
361
+ * refuses loudly instead of shipping a dead effect.
362
+ */
363
+ export function classifyLazyBinding(args: {
364
+ kind: 'attr' | 'text'
365
+ slotId: string
366
+ free: ReadonlySet<string> | null
367
+ /** Loop param name plus every destructured `paramBindings` name. */
368
+ rowLocalNames: ReadonlySet<string>
369
+ indexParam: string
370
+ scope: LazyRowScopeInfo
371
+ }): ClassifiedLazyBinding {
372
+ const { kind, slotId, free, rowLocalNames, indexParam, scope } = args
373
+
374
+ if (free === null) {
375
+ return {
376
+ kind,
377
+ slotId,
378
+ readsItem: true,
379
+ readsOuter: true,
380
+ reactiveOuterNames: [],
381
+ opaqueOuterNames: [UNKNOWN_IDENTIFIERS],
382
+ // An ASSUMPTION, not knowledge — which is precisely why
383
+ // `UNKNOWN_IDENTIFIERS` must keep refusing the loop: with no
384
+ // identifier set we cannot rule out an index read either, and
385
+ // `applyItem` / `applyOuter` have no index parameter to give it.
386
+ referencesIndex: false,
387
+ }
388
+ }
389
+
390
+ let readsItem = false
391
+ let referencesIndex = false
392
+ const reactiveOuterNames: string[] = []
393
+ const opaqueOuterNames: string[] = []
394
+
395
+ for (const name of free) {
396
+ if (rowLocalNames.has(name)) {
397
+ readsItem = true
398
+ continue
399
+ }
400
+ if (name === indexParam) {
401
+ referencesIndex = true
402
+ continue
403
+ }
404
+ if (INERT_BINDING_GLOBALS.has(name)) continue
405
+ if (scope.signals.has(name) || scope.memos.has(name)) {
406
+ if (!reactiveOuterNames.includes(name)) reactiveOuterNames.push(name)
407
+ continue
408
+ }
409
+ // A LITERAL-DERIVED constant (empty free-identifier set) is the only
410
+ // non-signal local name provably incapable of reactivity: with nothing
411
+ // free in its initializer, it cannot close over a signal, a selector, or
412
+ // any other reactive accessor.
413
+ //
414
+ // Everything else is OPAQUE, and deliberately so — this is where the
415
+ // conservative line has to sit, because a reactive accessor can hide
416
+ // behind an ordinary-looking name:
417
+ // - `const isSelected = createSelector(selected)` is a local `const`
418
+ // whose CALL is reactive (`create-selector.test.ts`),
419
+ // - a local function's body can read a signal,
420
+ // - a prop may be defined as a getter over the parent's signals,
421
+ // - an imported name may be another module's `@client` module signal.
422
+ // None of those can be primed by this emitter, and an unprimed
423
+ // `applyOuter` effect would never subscribe. Refusing the loop (eager
424
+ // fallback) is the sound answer; guessing "inert" would be a silent
425
+ // never-updates bug.
426
+ const constFree = scope.constants.get(name)
427
+ if (constFree !== undefined && constFree !== null && constFree.size === 0) continue
428
+ opaqueOuterNames.push(name)
429
+ }
430
+
431
+ return {
432
+ kind,
433
+ slotId,
434
+ readsItem,
435
+ readsOuter: reactiveOuterNames.length > 0 || opaqueOuterNames.length > 0,
436
+ reactiveOuterNames,
437
+ opaqueOuterNames,
438
+ referencesIndex,
439
+ }
440
+ }
@@ -26,6 +26,7 @@ import type { IRLoopChildComponent } from '../../../types.ts'
26
26
  import type { SkeletonSlotPaths } from '../../html-template.ts'
27
27
  import type { ReactiveEffectsPlan } from './reactive-effects.ts'
28
28
  import type { InnerLoopsPlan } from './inner-loop.ts'
29
+ import type { LazyRowPlanData } from './build-lazy-row.ts'
29
30
 
30
31
  /** Fields shared by every `LoopPlan` variant. */
31
32
  interface LoopPlanCommon {
@@ -174,6 +175,15 @@ interface PlainLoopVariant extends DynamicLoopCommon {
174
175
  * `__el` to query against.
175
176
  */
176
177
  preambleRegions: readonly PreambleRegionPlan[]
178
+ /**
179
+ * Lazy row graph plan (`spec/slot-unification.md` §9, L3). Non-null when
180
+ * `lazyRowEligibility` accepted this loop: the stringifier emits
181
+ * `mapArrayLazy(...)` with this row plan instead of `mapArray(...)` +
182
+ * renderItem, and the row carries NO per-row reactive resources.
183
+ * Undefined for every ineligible loop — those keep today's emission
184
+ * byte-for-byte (sound-or-loud, `plan/lazy-row-eligibility.ts`).
185
+ */
186
+ lazyRow?: LazyRowPlanData
177
187
  }
178
188
 
179
189
  /**
@@ -29,7 +29,7 @@ import {
29
29
  } from '../csr-substitute.ts'
30
30
 
31
31
  /**
32
- * Build the `keyFn` argument for mapArray / reconcileElements. `null` when
32
+ * Build the `keyFn` argument for mapArray / mapArrayAnchored. `null` when
33
33
  * the loop has no key expression. Every `CollectedLoop` variant (top-level /
34
34
  * branch / nested) carries an `index: string | null` field (#2218 threaded
35
35
  * it onto `NestedLoop` too), so the index param — when present — is always
@@ -14,7 +14,8 @@ import { stringifyCompositeLoop } from './composite-loop.ts'
14
14
  import { stringifyEventDelegation } from './event-delegation.ts'
15
15
  import { stringifyReactiveEffects } from './reactive-effects.ts'
16
16
  import { emitTemplateCloneInline, emitLoopItemElementSetup } from './template-parse.ts'
17
- import { emitLoopChildRefs, emitPreambleRegionEffects } from './loop.ts'
17
+ import { emitLoopChildRefs } from './loop.ts'
18
+ import { stringifyLazyRowLoop } from './lazy-row.ts'
18
19
  import type {
19
20
  BranchLoopPlan,
20
21
  BranchPlainLoopPlan,
@@ -89,6 +90,27 @@ function emitPlain(lines: string[], plan: BranchPlainLoopPlan): void {
89
90
  return
90
91
  }
91
92
 
93
+ // Lazy row graph (spec/slot-unification.md §9, L3) — same gate and same
94
+ // emitter as the top-level plain loop; only the container guard and indent
95
+ // differ. Ineligible loops fall through to the eager emission below.
96
+ if (plan.lazyRow) {
97
+ stringifyLazyRowLoop(lines, {
98
+ indent: ' ',
99
+ containerVar,
100
+ guardContainer: true,
101
+ markerId,
102
+ arrayExpr,
103
+ keyFn,
104
+ paramHead,
105
+ indexParam,
106
+ template,
107
+ lazyRow: plan.lazyRow,
108
+ })
109
+ lines.push(` }))`)
110
+ stringifyEventDelegation(lines, eventDelegation)
111
+ return
112
+ }
113
+
92
114
  // Non-empty `childRefs` need `__el` as a handle inside the factory body,
93
115
  // so force the multi-line layout (#1244) — preamble-patched regions
94
116
  // (#2389) need the same handle for their own effect.
@@ -115,11 +137,19 @@ function emitPlain(lines: string[], plan: BranchPlainLoopPlan): void {
115
137
  indent: ' ',
116
138
  singleRootLayout: 'inline',
117
139
  })
118
- if (reactiveEffects !== null) {
119
- stringifyReactiveEffects(lines, reactiveEffects, { indent: ' ', elVar: '__el', bodyIsMultiRoot })
140
+ if (reactiveEffects !== null || preambleRegions.length > 0) {
141
+ // Row-granularity effects (§3(c)): mirrors `stringifyPlainLoop`'s
142
+ // top-level call — attrs, outer texts, and preamble regions merge
143
+ // into ONE row effect (see `stringifyReactiveEffects`'s docstring).
144
+ stringifyReactiveEffects(lines, reactiveEffects, {
145
+ indent: ' ',
146
+ elVar: '__el',
147
+ bodyIsMultiRoot,
148
+ preambleRegions,
149
+ mapPreambleWrapped,
150
+ })
120
151
  }
121
152
  emitLoopChildRefs(lines, childRefs, { indent: ' ', elVar: '__el', bodyIsMultiRoot })
122
- emitPreambleRegionEffects(lines, preambleRegions, mapPreambleWrapped, { indent: ' ', elVar: '__el' })
123
153
  lines.push(` return __el`)
124
154
  lines.push(` }, '${markerId}'${loopBfId})`)
125
155
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Claim-plan literal rendering — the compiler-side half of slot unification
3
+ * A3 (`spec/slot-unification.md` §4/§5-A3). The runtime's `SlotSpec`/
4
+ * `ClaimPlan` (`@barefootjs/client/runtime/claim-slots.ts`, A2) are pure
5
+ * data: `{ id, kind, path }` triples. This module is the single place that
6
+ * turns compile-time slot descriptors into that data's source-text form —
7
+ * every content-slot emission site (loop rows, preamble regions, dynamic
8
+ * text/JSX slots, `@client` expressions) renders its plan through
9
+ * `claimPlanLiteral`, never by hand-splicing strings, so the literal shape
10
+ * can only ever match `SlotSpec`.
11
+ *
12
+ * Paths are real root-relative child-index arrays wherever the caller
13
+ * already has them (the hoisted single-root loop skeleton, #2143's
14
+ * `SkeletonSlotPaths`); everywhere else a slot gets `path: []` and A2's
15
+ * `lazySlots`/`claimSlots` marker-scan fallback resolves it — sound, just a
16
+ * per-claim scan instead of an O(depth) walk. This is the explicit
17
+ * "cannot be statically pathed" escape valve `spec/slot-unification.md`
18
+ * §5-A3 sanctions, not a shortcut invented here.
19
+ */
20
+
21
+ export interface ClaimSlotSpec {
22
+ readonly id: string
23
+ readonly kind: 'text' | 'markup'
24
+ /** Root-relative child-index path, used when the path is unconditionally
25
+ * valid at claim time. Ignored when `pathExpr` is set. */
26
+ readonly path: readonly number[]
27
+ /**
28
+ * Raw JS expression (already rendered source text) evaluating to a
29
+ * `number[]` at runtime, for the one case a plain literal can't express:
30
+ * a hoisted-skeleton path that's valid ONLY on the fresh-CSR-clone branch,
31
+ * never on the hydration-adopts-SSR branch (#2143 precedent — `__p` is
32
+ * nulled when `__existing` is truthy because the skeleton's simplified
33
+ * markup doesn't describe the real SSR-rendered tree). Typically
34
+ * `__existing ? [] : [1, 0]`. Overrides `path` when present.
35
+ */
36
+ readonly pathExpr?: string
37
+ /**
38
+ * Slot unification Step B (`spec/slot-unification.md` §3(b), §5 Step B):
39
+ * true when NO marker was emitted for this slot — `path`'s last index is
40
+ * the slot's own position, not an anchor comment. Only ever set by
41
+ * `client-only-elision.ts`-derived callers; every other emission site
42
+ * omits it (falsy = today's marker-based behavior, unchanged).
43
+ */
44
+ readonly markerless?: boolean
45
+ }
46
+
47
+ /** Render one `SlotSpec` as a source-text object literal. */
48
+ function slotSpecLiteral(slot: ClaimSlotSpec): string {
49
+ const pathSrc = slot.pathExpr ?? `[${slot.path.join(', ')}]`
50
+ const markerlessSrc = slot.markerless ? ', markerless: true' : ''
51
+ return `{ id: '${slot.id}', kind: '${slot.kind}', path: ${pathSrc}${markerlessSrc} }`
52
+ }
53
+
54
+ /** Render a `ClaimPlan` (an array of `SlotSpec`) as a source-text array literal. */
55
+ export function claimPlanLiteral(slots: readonly ClaimSlotSpec[]): string {
56
+ return `[${slots.map(slotSpecLiteral).join(', ')}]`
57
+ }
58
+
59
+ /**
60
+ * A stable JS identifier for the claimed-slot writer covering `slots`,
61
+ * derived from the first slot's id. Slot ids are unique per component (the
62
+ * `bf="sN"` marker namespace), so this can never collide across two
63
+ * distinct claim-plan call sites within the same emitted component.
64
+ */
65
+ export function claimWriterVarName(slots: readonly ClaimSlotSpec[], sanitize: (id: string) => string): string {
66
+ const first = slots[0]?.id ?? '0'
67
+ return `__bfw_${sanitize(first)}`
68
+ }
@@ -30,11 +30,12 @@
30
30
  * <indent>}) }
31
31
  */
32
32
 
33
- import { keyAttrName, profileBindingId } from '../../utils.ts'
33
+ import { keyAttrName, profileBindingId, varSlotId } from '../../utils.ts'
34
34
  import { emitComponentAndEventSetup } from '../shared.ts'
35
35
  import { emitAttrUpdate } from '../../emit-reactive.ts'
36
36
  import { emitMultiRootTemplateCloneLines, templateRootIsSvg } from './template-parse.ts'
37
37
  import { emitLoopChildRefs } from './loop.ts'
38
+ import { claimPlanLiteral, claimWriterVarName, type ClaimSlotSpec } from './claim-plan.ts'
38
39
  import type {
39
40
  InnerLoopPlan,
40
41
  InnerLoopsPlan,
@@ -110,15 +111,23 @@ function emitReactive(lines: string[], inner: InnerLoopPlan, indent: string, pc:
110
111
  if (inner.childLevels.length > 0) {
111
112
  stringifyInnerLoops(lines, inner.childLevels, `${indent} `, pc)
112
113
  }
113
- for (const text of emit.reactiveTexts) {
114
+ const conditionalTexts = emit.reactiveTexts.filter(t => t.insideConditional)
115
+ const plainTexts = emit.reactiveTexts.filter(t => !t.insideConditional)
116
+ for (const text of conditionalTexts) {
114
117
  const bf = profileBindingId(pc, text.slotId)
115
- if (text.insideConditional) {
116
- // Re-query $t inside the effect: insert() may swap the text node so a
117
- // captured reference would silently stop updating.
118
- lines.push(`${indent} createEffect(() => { const [__rt] = $t(__innerEl${uid}, '${text.slotId}'); if (__rt) __rt.textContent = String(${text.wrappedExpression}) }${bf})`)
119
- } else {
120
- lines.push(`${indent} { const [__rt] = $t(__innerEl${uid}, '${text.slotId}')`)
121
- lines.push(`${indent} if (__rt) createEffect(() => { __rt.textContent = String(${text.wrappedExpression}) }${bf}) }`)
118
+ // A fresh `claimSlots` claim on every run (not the cached `lazySlots`
119
+ // door): insert() may swap the branch's DOM between runs, and 'text'
120
+ // writes are a plain, idempotent `nodeValue` assignment with no
121
+ // dedup/trust-first-run state to go stale so re-claiming here is exactly
122
+ // as safe as, and replaces, the old re-query-$t-on-every-run discipline.
123
+ lines.push(`${indent} createEffect(() => { claimSlots(__innerEl${uid}, [{ id: '${text.slotId}', kind: 'text', path: [] }]).write('${text.slotId}', String(${text.wrappedExpression})) }${bf})`)
124
+ }
125
+ if (plainTexts.length > 0) {
126
+ const slots: ClaimSlotSpec[] = plainTexts.map(t => ({ id: t.slotId, kind: 'text', path: [] }))
127
+ const writer = claimWriterVarName(slots, varSlotId)
128
+ lines.push(`${indent} const ${writer} = lazySlots(__innerEl${uid}, ${claimPlanLiteral(slots)})`)
129
+ for (const text of plainTexts) {
130
+ lines.push(`${indent} createEffect(() => { ${writer}('${text.slotId}', String(${text.wrappedExpression})) }${profileBindingId(pc, text.slotId)})`)
122
131
  }
123
132
  }
124
133
  for (const attr of emit.reactiveAttrs) {