@barefootjs/jsx 0.29.0 → 0.30.2

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 (90) hide show
  1. package/dist/adapters/child-scope.d.ts +17 -0
  2. package/dist/adapters/child-scope.d.ts.map +1 -0
  3. package/dist/adapters/loop-bound-names.d.ts.map +1 -1
  4. package/dist/analyzer-context.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +990 -367
  9. package/dist/ir-to-client-js/collect-elements.d.ts +5 -25
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +66 -2
  12. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +92 -0
  15. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -0
  16. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +134 -0
  17. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -0
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +46 -11
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
  21. package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/html-template.d.ts +2 -3
  25. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
  27. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  28. package/dist/ir-to-client-js/reactivity.d.ts +1 -10
  29. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/types.d.ts +12 -0
  31. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  32. package/dist/jsx-to-ir.d.ts.map +1 -1
  33. package/dist/prop-rewrite.d.ts +24 -7
  34. package/dist/prop-rewrite.d.ts.map +1 -1
  35. package/dist/rich-type-evidence.d.ts.map +1 -1
  36. package/dist/ssr-defaults.d.ts.map +1 -1
  37. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  38. package/dist/types.d.ts +78 -0
  39. package/dist/types.d.ts.map +1 -1
  40. package/package.json +2 -2
  41. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +6 -24
  42. package/src/__tests__/branch-loop-plain.test.ts +28 -45
  43. package/src/__tests__/csr-substitution-safety-divergence.test.ts +9 -3
  44. package/src/__tests__/csr-template-scope-soundness.test.ts +167 -0
  45. package/src/__tests__/early-return-branch-switch.test.ts +96 -0
  46. package/src/__tests__/form-control-value-ssr.test.ts +102 -0
  47. package/src/__tests__/lazy-conditional.test.ts +190 -0
  48. package/src/__tests__/lazy-preamble.test.ts +297 -0
  49. package/src/__tests__/lazy-row-eligibility.test.ts +97 -28
  50. package/src/__tests__/loop-item-root-scope.test.ts +213 -0
  51. package/src/__tests__/map-multi-return-body.test.ts +30 -3
  52. package/src/__tests__/nested-loop-conditional.test.ts +27 -6
  53. package/src/__tests__/preamble-attr-reactivity.test.ts +217 -0
  54. package/src/__tests__/preamble-declarations.test.ts +207 -0
  55. package/src/__tests__/ssr-defaults.test.ts +44 -0
  56. package/src/adapters/child-scope.ts +22 -0
  57. package/src/adapters/loop-bound-names.ts +6 -0
  58. package/src/analyzer-context.ts +43 -0
  59. package/src/analyzer.ts +33 -3
  60. package/src/index.ts +1 -0
  61. package/src/ir-to-client-js/collect-elements.ts +51 -10
  62. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +1 -1
  63. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -2
  64. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +3 -3
  65. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -2
  66. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +147 -5
  67. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -2
  68. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
  69. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +151 -0
  70. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +287 -0
  71. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +85 -22
  72. package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
  73. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +133 -35
  74. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +24 -4
  75. package/src/ir-to-client-js/csr-substitute.ts +6 -1
  76. package/src/ir-to-client-js/emit-registration.ts +1 -1
  77. package/src/ir-to-client-js/html-template.ts +51 -40
  78. package/src/ir-to-client-js/index.ts +1 -1
  79. package/src/ir-to-client-js/plan/build-declaration-emit.ts +2 -0
  80. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +1 -1
  81. package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
  82. package/src/ir-to-client-js/reactivity.ts +25 -0
  83. package/src/ir-to-client-js/stringify/declaration-emit.ts +9 -4
  84. package/src/ir-to-client-js/types.ts +12 -0
  85. package/src/jsx-to-ir.ts +409 -24
  86. package/src/prop-rewrite.ts +149 -19
  87. package/src/rich-type-evidence.ts +6 -7
  88. package/src/ssr-defaults.ts +7 -2
  89. package/src/to-locale-date-lowering.ts +8 -2
  90. package/src/types.ts +80 -0
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Is a `.map()` callback preamble safe to run inside a LAZY row's apply
3
+ * bodies? — `spec/slot-unification.md` §9.5, the "row has a map-callback
4
+ * preamble" widening.
5
+ *
6
+ * ## Why the old rule was "any preamble at all"
7
+ *
8
+ * The eager emission runs the preamble ONCE per row, inside `renderItem`.
9
+ * The lazy emission has no per-row body — a row's writes are split across
10
+ * `createRow` / `applyItem` / `applyOuter`, each a separate function called
11
+ * at a different time. To let a binding read a preamble-declared local, the
12
+ * preamble's statements have to be re-executed at the top of every body that
13
+ * needs them. Two things follow, and both have to be PROVEN, not assumed:
14
+ *
15
+ * 1. **Re-execution must be observationally free.** Running the statements
16
+ * three times instead of once is only sound if they have no effects and
17
+ * allocate nothing whose identity is load-bearing. A preamble declaring a
18
+ * signal (`const [x, setX] = createSignal(0)`) would mint a NEW signal on
19
+ * every apply — the row would silently lose its state.
20
+ * 2. **No reactive binding may read a preamble local.** A binding's
21
+ * dependency set is its own free identifiers; a preamble local hides
22
+ * whatever the preamble read (`const cls = selected() === row.id ? …`),
23
+ * so classifying `cls` on its own name would put the binding in
24
+ * `applyItem` only and the row would never react to `selected()`.
25
+ *
26
+ * That case is REFUSED rather than modelled, because it is currently
27
+ * unreachable and paying for unreachable machinery is how a gate rots: a
28
+ * preamble local read in CHILD position becomes a `preambleRegions` entry
29
+ * (`IRLoop.preambleRegions`, #2389) which the gate already refuses on its
30
+ * own, and one read in ATTRIBUTE position is not classified as reactive at
31
+ * all, so it is interpolated into the row template and never becomes a
32
+ * `reactiveAttrs` binding. `lazyRowEligibility` therefore refuses any
33
+ * binding whose free identifiers touch `declaredNames`, and that refusal
34
+ * is what keeps this sound if either of those two facts ever changes.
35
+ *
36
+ * ## The proof this module accepts
37
+ *
38
+ * Deliberately narrow, and structural — no regex, per the repo rule. A
39
+ * preamble qualifies only when it is a sequence of `const` declarations whose
40
+ * initializers cannot do anything:
41
+ *
42
+ * - Every statement is a `const` `VariableStatement`. A `let` can be
43
+ * reassigned by a later statement, a `function`/`class` declaration is a
44
+ * fresh object per run, and any other statement kind (an `if`, a bare
45
+ * call, a `for`) is either an effect or control flow this analysis does
46
+ * not model.
47
+ * - Every declaration has an initializer, and that initializer contains no
48
+ * `new`, tagged template, assignment, `++`/`--`, `await`, `yield`,
49
+ * `delete`, or function/arrow/class expression.
50
+ * - **Calls are refused with ONE exception: a zero-argument call to a
51
+ * component signal or memo getter.** That exception is the whole point —
52
+ * `const cls = selected() === row.id ? 'on' : 'off'` is the shape the
53
+ * krausest bench writes, and a signal read is pure, deterministic, and
54
+ * already the unit this design primes and subscribes to. Every other call
55
+ * stays refused: `createSignal` is a call, `arr.push(x)` is a call,
56
+ * `Math.random()` is a call whose value would differ between `createRow`
57
+ * and `applyItem`, and an ordinary-looking local can hide a reactive
58
+ * accessor behind one (`isSelected(row.id)` — see `classifyLazyBinding`'s
59
+ * note) which this emitter cannot prime.
60
+ *
61
+ * The signal read is sound in all three bodies because of where they run:
62
+ * `createRow` and `applyItem` are wrapped in `untrack()` by `mapArrayLazy`,
63
+ * so a read there subscribes nothing, and `applyOuter` IS the loop-level
64
+ * effect that is supposed to subscribe. A preamble-declared local therefore
65
+ * behaves exactly like a binding expression that read the same signal
66
+ * directly.
67
+ * - No `jsx` segment. A JSX leaf in the preamble means the row accumulates
68
+ * markup, which is `preambleRegions` territory (refused separately).
69
+ * - No `builderNames`. Same shape as above, pinned explicitly so the reason
70
+ * names it rather than surfacing as a confusing "contains a call".
71
+ * - The preamble does not read the loop's INDEX parameter. `applyItem` and
72
+ * `applyOuter` have no index to give it — the same reason
73
+ * `ClassifiedLazyBinding.referencesIndex` refuses a binding.
74
+ *
75
+ * Member access is deliberately NOT banned even though a getter could in
76
+ * principle run code: a binding expression like `class={row.cls}` is already
77
+ * re-evaluated on every apply by design, so a call-free preamble is exactly
78
+ * the same risk class as the emission this feature extends. Banning member
79
+ * access would reject every useful preamble and buy no real guarantee.
80
+ *
81
+ * Everything refused carries a specific reason — `lazyRowEligibility` passes
82
+ * it straight through, so a unit test naming the shape gets a diagnosable
83
+ * failure instead of a silent fallback to eager emission.
84
+ */
85
+
86
+ import ts from 'typescript'
87
+ import { preambleAnalysisText, type MapCallbackPreamble } from '../../../types.ts'
88
+ import { extractFreeIdentifiersFromStatementText } from '../../csr-substitute.ts'
89
+
90
+ /** What the gate and the binding classifier need to know about a preamble. */
91
+ export interface LazyPreambleFacts {
92
+ /**
93
+ * Names the preamble declares. A binding naming one of these is reading a
94
+ * ROW-LOCAL value, not an outer one — and the name SHADOWS any
95
+ * component-scope signal/const of the same name, which is why
96
+ * `classifyLazyBinding` substitutes rather than falling through to the
97
+ * component scope.
98
+ */
99
+ declaredNames: ReadonlySet<string>
100
+ /**
101
+ * What the preamble itself reads — its free identifiers, minus the names it
102
+ * declares. This is the SUBSTITUTION set: a binding that names a preamble
103
+ * local inherits these as its own dependencies, because that is literally
104
+ * what the value depends on.
105
+ *
106
+ * Returned as raw names, unclassified, on purpose. Deciding which are row
107
+ * locals, which are primable getters, and which are opaque needs the loop's
108
+ * `rowLocalNames` / `indexParam` / `LazyRowScopeInfo`, none of which this
109
+ * module has — and re-deriving that judgement here would be a second copy
110
+ * of `classifyLazyBinding`'s rules, free to drift from the one the emitter
111
+ * actually primes against. So the names go back unjudged and run through
112
+ * the SAME loop a binding's own identifiers do.
113
+ */
114
+ freeNames: ReadonlySet<string>
115
+ }
116
+
117
+ export type LazyPreambleAnalysis =
118
+ | { lazySafe: true; facts: LazyPreambleFacts }
119
+ | { lazySafe: false; reason: string }
120
+
121
+ /** No preamble at all — nothing to prove, nothing to substitute. */
122
+ export const NO_PREAMBLE: LazyPreambleAnalysis = {
123
+ lazySafe: true,
124
+ facts: { declaredNames: new Set(), freeNames: new Set() },
125
+ }
126
+
127
+ const NO = (reason: string): LazyPreambleAnalysis => ({ lazySafe: false, reason })
128
+
129
+ /**
130
+ * Decide whether `preamble` may be re-executed inside a lazy row's apply
131
+ * bodies, and if so what it declares and reads.
132
+ *
133
+ * `indexParam` is the loop's index parameter name as the emitter uses it
134
+ * (`elem.index || '__idx'`); a preamble reading it is refused.
135
+ *
136
+ * `primableNames` are the component's signal getters and memos — the only
137
+ * callees a preamble initializer may invoke (see the module docstring). Pass
138
+ * the same names `LazyRowScopeInfo` carries, so "primable here" and "primable
139
+ * in `classifyLazyBinding`" cannot drift.
140
+ */
141
+ export function analyzeLazyPreamble(
142
+ preamble: MapCallbackPreamble | undefined,
143
+ indexParam: string,
144
+ primableNames: ReadonlySet<string>,
145
+ ): LazyPreambleAnalysis {
146
+ if (!preamble) return NO_PREAMBLE
147
+
148
+ if (preamble.builderNames.length > 0) {
149
+ return NO(`map-callback preamble accumulates JSX leaves (${preamble.builderNames.join(', ')})`)
150
+ }
151
+ for (const seg of preamble.segments) {
152
+ if (seg.kind !== 'js') return NO('map-callback preamble contains a JSX leaf')
153
+ }
154
+
155
+ const text = preambleAnalysisText(preamble)
156
+ if (text.trim().length === 0) return NO_PREAMBLE
157
+
158
+ const declaredNames = new Set<string>()
159
+ const sf = ts.createSourceFile(
160
+ '__lazy_preamble__.ts',
161
+ text,
162
+ ts.ScriptTarget.Latest,
163
+ /* setParentNodes */ true,
164
+ ts.ScriptKind.TS,
165
+ )
166
+
167
+ for (const stmt of sf.statements) {
168
+ if (!ts.isVariableStatement(stmt)) {
169
+ return NO(`map-callback preamble has a non-declaration statement (${ts.SyntaxKind[stmt.kind]})`)
170
+ }
171
+ const isConst = (stmt.declarationList.flags & ts.NodeFlags.Const) !== 0
172
+ if (!isConst) return NO('map-callback preamble declares a mutable binding (let/var)')
173
+ for (const decl of stmt.declarationList.declarations) {
174
+ collectBindingNames(decl.name, declaredNames)
175
+ if (!decl.initializer) {
176
+ return NO('map-callback preamble has a declaration with no initializer')
177
+ }
178
+ const impure = findImpureNode(decl.initializer, primableNames)
179
+ if (impure) {
180
+ return NO(`map-callback preamble initializer is not re-runnable (${impure})`)
181
+ }
182
+ }
183
+ }
184
+
185
+ // `extractFreeIdentifiersFromStatementText` scopes nested binding forms, so
186
+ // the preamble's own `const` names never appear here; deleting them is a
187
+ // belt-and-braces step for a declaration form it might not model.
188
+ // A preamble local that shadows a signal/memo getter would make the
189
+ // call check above answer for the WRONG binding (`const selected = …` then
190
+ // `selected()`). Refuse rather than model shadowing inside the preamble;
191
+ // shadowing across the preamble boundary is handled correctly by
192
+ // `classifyLazyBinding`'s substitution.
193
+ for (const name of declaredNames) {
194
+ if (primableNames.has(name)) {
195
+ return NO(`map-callback preamble shadows the signal/memo getter '${name}'`)
196
+ }
197
+ }
198
+
199
+ // The index parameter does not exist in `applyItem` / `applyOuter`, and
200
+ // `createRow` receives it but the preamble must read the same values in
201
+ // every body it runs in. Same reason `referencesIndex` refuses a binding.
202
+ const readNames = extractFreeIdentifiersFromStatementText(text)
203
+ if (readNames.has(indexParam) && !declaredNames.has(indexParam)) {
204
+ return NO(`map-callback preamble reads the loop index parameter '${indexParam}'`)
205
+ }
206
+
207
+ const freeNames = new Set(readNames)
208
+ for (const declared of declaredNames) freeNames.delete(declared)
209
+
210
+ return { lazySafe: true, facts: { declaredNames, freeNames } }
211
+ }
212
+
213
+ /** Every name a `const` binding form introduces (identifier or pattern). */
214
+ function collectBindingNames(name: ts.BindingName, out: Set<string>): void {
215
+ if (ts.isIdentifier(name)) {
216
+ out.add(name.text)
217
+ return
218
+ }
219
+ for (const element of name.elements) {
220
+ if (ts.isOmittedExpression(element)) continue
221
+ collectBindingNames(element.name, out)
222
+ }
223
+ }
224
+
225
+ /**
226
+ * The first node in `root` that makes re-execution observable, described for
227
+ * the refusal reason — or `null` when the expression is provably free to run
228
+ * again. Walks the whole subtree, including into nested array/object literals
229
+ * and template-literal spans.
230
+ */
231
+ function findImpureNode(root: ts.Node, primableNames: ReadonlySet<string>): string | null {
232
+ let found: string | null = null
233
+
234
+ const visit = (node: ts.Node): void => {
235
+ if (found) return
236
+ if (ts.isCallExpression(node)) {
237
+ const callee = node.expression
238
+ const isSignalRead =
239
+ ts.isIdentifier(callee) &&
240
+ primableNames.has(callee.text) &&
241
+ node.arguments.length === 0 &&
242
+ node.questionDotToken === undefined
243
+ if (!isSignalRead) {
244
+ found = `call to ${callee.getText(callee.getSourceFile())}`
245
+ return
246
+ }
247
+ // A zero-arg signal read has no arguments to walk, but keep the
248
+ // traversal uniform rather than returning early.
249
+ }
250
+ if (ts.isNewExpression(node)) { found = 'new expression'; return }
251
+ if (ts.isTaggedTemplateExpression(node)) { found = 'tagged template'; return }
252
+ if (ts.isAwaitExpression(node)) { found = 'await'; return }
253
+ if (ts.isYieldExpression(node)) { found = 'yield'; return }
254
+ if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) {
255
+ const op = (node as ts.PrefixUnaryExpression | ts.PostfixUnaryExpression).operator
256
+ if (op === ts.SyntaxKind.PlusPlusToken || op === ts.SyntaxKind.MinusMinusToken) {
257
+ found = 'increment/decrement'
258
+ return
259
+ }
260
+ }
261
+ if (ts.isDeleteExpression(node)) { found = 'delete'; return }
262
+ if (
263
+ ts.isFunctionExpression(node) ||
264
+ ts.isArrowFunction(node) ||
265
+ ts.isClassExpression(node)
266
+ ) {
267
+ // A closure is a fresh object per run. Nothing in an eligible row can
268
+ // hold onto one (a handler would be an event binding, not a preamble
269
+ // local), but "fresh identity per apply" is exactly the hazard this
270
+ // module exists to exclude, so it is refused rather than reasoned about.
271
+ found = 'function or class expression'
272
+ return
273
+ }
274
+ if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) {
275
+ found = 'assignment'
276
+ return
277
+ }
278
+ ts.forEachChild(node, visit)
279
+ }
280
+
281
+ visit(root)
282
+ return found
283
+ }
284
+
285
+ function isAssignmentOperator(kind: ts.SyntaxKind): boolean {
286
+ return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment
287
+ }
@@ -16,15 +16,21 @@
16
16
  * "widen later" decision, never a correctness compromise:
17
17
  *
18
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.
19
+ * single-root, keyed, ref-free, no nested components or inner loops. A
20
+ * reactive CONDITIONAL is allowed when `analyzeLazyConditional`
21
+ * (`lazy-conditional.ts`) proves both arms are wiring-free static elements,
22
+ * which reduces the per-row `insert()` effect to a `replaceWith` the apply
23
+ * bodies can carry; anything else refuses with that analysis's reason.
24
+ * 2. **A preamble must be provably re-runnable.** The lazy emission has no
25
+ * single per-row body, so a preamble-declared local only exists if the
26
+ * preamble's statements are re-executed at the top of each apply body
27
+ * that reads one. `analyzeLazyPreamble` (`lazy-preamble.ts`) proves that
28
+ * structurally — `const` declarations whose initializers do nothing but
29
+ * read the item and zero-arg signal getters — and hands back the declared
30
+ * names. The gate consumes its verdict as a pre-computed
31
+ * `mapPreambleRefusal` string so this function stays pure data-in/data-out.
32
+ * A binding that READS a declared local still refuses (see the per-binding
33
+ * gate), and so does any `preambleRegions` entry (§9.5, a separate row).
28
34
  * 3. **Every reactive outer dependency must be primable.** `mapArrayLazy`
29
35
  * creates ONE loop-level effect for `applyOuter`; that effect subscribes
30
36
  * only to what its body reads, and its body must read the outer signals
@@ -63,6 +69,8 @@
63
69
  * `#binding:<slotId>` id.
64
70
  */
65
71
 
72
+ import type { LazyPreambleFacts } from './lazy-preamble.ts'
73
+
66
74
  /** Facts about one component signal needed by the §9.3(2) source gate. */
67
75
  export interface LazyRowSignalFacts {
68
76
  /**
@@ -158,6 +166,13 @@ export interface ClassifiedLazyBinding {
158
166
  opaqueOuterNames: readonly string[]
159
167
  /** References the loop's index parameter — `applyItem`/`applyOuter` have no index. */
160
168
  referencesIndex: boolean
169
+ /**
170
+ * Names a preamble-declared local, so every apply body this binding is
171
+ * emitted into must re-run the preamble first. Tracked per binding rather
172
+ * than per loop so `applyOuter` does not pay for a preamble only
173
+ * `applyItem`'s bindings read (`stringify/lazy-row.ts`).
174
+ */
175
+ readsPreamble: boolean
161
176
  }
162
177
 
163
178
  /** §9.4 shape facts, read off the loop plan / IR by the caller. */
@@ -169,12 +184,24 @@ export interface LazyRowShapeFacts {
169
184
  bodyIsMultiRoot: boolean
170
185
  /** `LoopCore.key != null` — index-keyed loops are ineligible in v1 (§9.4). */
171
186
  hasExplicitKey: boolean
172
- conditionalCount: number
187
+ /**
188
+ * `analyzeLazyConditional`'s refusal reason for the FIRST row conditional it
189
+ * rejects, or `null` when the row has none or all of them can be driven from
190
+ * the apply bodies (`lazy-conditional.ts`). A string here is emitted verbatim
191
+ * as the gate's reason.
192
+ */
193
+ conditionalRefusal: string | null
173
194
  childRefCount: number
174
195
  nestedComponentCount: number
175
196
  innerLoopCount: number
176
197
  hasChildComponent: boolean
177
- hasMapPreamble: boolean
198
+ /**
199
+ * `analyzeLazyPreamble`'s refusal reason, or `null` when the row has no
200
+ * preamble or has one that is provably safe to re-run in the apply bodies
201
+ * (`lazy-preamble.ts`). A string here is emitted verbatim as the gate's
202
+ * reason — the analysis, not this function, knows what was wrong.
203
+ */
204
+ mapPreambleRefusal: string | null
178
205
  preambleRegionCount: number
179
206
  /** A destructured loop param with no `paramBindings` (snapshot unwrap). */
180
207
  hasParamUnwrap: boolean
@@ -238,8 +265,10 @@ export function lazyRowEligibility(args: LazyRowEligibilityArgs): LazyRowEligibi
238
265
  // loops ever become legal, the test naming this fails and points here.
239
266
  if (!shape.hasExplicitKey) return NO('index-keyed loop (no explicit key)')
240
267
 
241
- // (4) No conditionals in the row.
242
- if (shape.conditionalCount > 0) return NO('row contains a reactive conditional')
268
+ // (4) Row conditionals: allowed when every one of them is a wiring-free
269
+ // static element conditional, refused with the analysis's own reason
270
+ // otherwise.
271
+ if (shape.conditionalRefusal) return NO(shape.conditionalRefusal)
243
272
 
244
273
  // (5) No refs / child components / inner loops.
245
274
  if (shape.childRefCount > 0) return NO('row has imperative child refs')
@@ -247,9 +276,9 @@ export function lazyRowEligibility(args: LazyRowEligibilityArgs): LazyRowEligibi
247
276
  if (shape.nestedComponentCount > 0) return NO('row contains nested child components')
248
277
  if (shape.innerLoopCount > 0) return NO('row contains an inner loop')
249
278
 
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)')
279
+ // (6) Row-local declarations: a preamble is allowed when it is provably
280
+ // re-runnable, refused with the analysis's own reason otherwise.
281
+ if (shape.mapPreambleRefusal) return NO(shape.mapPreambleRefusal)
253
282
  if (shape.preambleRegionCount > 0) return NO('row has preamble-patched regions')
254
283
  if (shape.hasParamUnwrap) return NO('destructured loop param without param bindings')
255
284
 
@@ -359,6 +388,13 @@ function checkSourceConsistency(
359
388
  * subscribe (see module docstring, restriction 3). So the fail-safe direction
360
389
  * is preserved (maximally conservative classification) while the emission
361
390
  * refuses loudly instead of shipping a dead effect.
391
+ *
392
+ * **Preamble locals are flagged, not resolved.** A name the preamble declares
393
+ * hides whatever the preamble read, so classifying it against the component
394
+ * scope would name the wrong dependency (and would silently treat a shadowed
395
+ * signal as the outer one). `readsPreamble` records the fact and
396
+ * `lazyRowEligibility` refuses the loop — see `lazy-preamble.ts` for why that
397
+ * case is currently unreachable and why the refusal is still the right shape.
362
398
  */
363
399
  export function classifyLazyBinding(args: {
364
400
  kind: 'attr' | 'text'
@@ -368,8 +404,11 @@ export function classifyLazyBinding(args: {
368
404
  rowLocalNames: ReadonlySet<string>
369
405
  indexParam: string
370
406
  scope: LazyRowScopeInfo
407
+ /** Proven-safe preamble facts, or the empty set pair when there is none. */
408
+ preamble?: LazyPreambleFacts
371
409
  }): ClassifiedLazyBinding {
372
410
  const { kind, slotId, free, rowLocalNames, indexParam, scope } = args
411
+ const preamble = args.preamble
373
412
 
374
413
  if (free === null) {
375
414
  return {
@@ -384,27 +423,35 @@ export function classifyLazyBinding(args: {
384
423
  // identifier set we cannot rule out an index read either, and
385
424
  // `applyItem` / `applyOuter` have no index parameter to give it.
386
425
  referencesIndex: false,
426
+ // An assumption like `referencesIndex` above, and the conservative
427
+ // one: with no identifier set we cannot rule out a preamble read
428
+ // either. The loop is refused for `UNKNOWN_IDENTIFIERS` regardless.
429
+ readsPreamble: preamble != null && preamble.declaredNames.size > 0,
387
430
  }
388
431
  }
389
432
 
390
433
  let readsItem = false
391
434
  let referencesIndex = false
435
+ let readsPreamble = false
392
436
  const reactiveOuterNames: string[] = []
393
437
  const opaqueOuterNames: string[] = []
394
438
 
395
- for (const name of free) {
439
+ // Classify ONE free identifier. Applied to the binding's own names and — for
440
+ // a binding that reads a preamble local — to the preamble's free names too,
441
+ // so both go through exactly the same rules (see `LazyPreambleFacts.freeNames`).
442
+ const classifyName = (name: string): void => {
396
443
  if (rowLocalNames.has(name)) {
397
444
  readsItem = true
398
- continue
445
+ return
399
446
  }
400
447
  if (name === indexParam) {
401
448
  referencesIndex = true
402
- continue
449
+ return
403
450
  }
404
- if (INERT_BINDING_GLOBALS.has(name)) continue
451
+ if (INERT_BINDING_GLOBALS.has(name)) return
405
452
  if (scope.signals.has(name) || scope.memos.has(name)) {
406
453
  if (!reactiveOuterNames.includes(name)) reactiveOuterNames.push(name)
407
- continue
454
+ return
408
455
  }
409
456
  // A LITERAL-DERIVED constant (empty free-identifier set) is the only
410
457
  // non-signal local name provably incapable of reactivity: with nothing
@@ -424,10 +471,25 @@ export function classifyLazyBinding(args: {
424
471
  // fallback) is the sound answer; guessing "inert" would be a silent
425
472
  // never-updates bug.
426
473
  const constFree = scope.constants.get(name)
427
- if (constFree !== undefined && constFree !== null && constFree.size === 0) continue
474
+ if (constFree !== undefined && constFree !== null && constFree.size === 0) return
428
475
  opaqueOuterNames.push(name)
429
476
  }
430
477
 
478
+ for (const name of free) {
479
+ // A preamble-declared name is a ROW-LOCAL alias for whatever the preamble
480
+ // read. Substituting its dependencies is the whole point: `class={cls}`
481
+ // where `const cls = selected() === row().id ? …` depends on `selected`
482
+ // AND the item, so the binding has to land in BOTH apply bodies and
483
+ // `selected` has to reach the prime list. Classifying `cls` on its own
484
+ // name would name neither, and an unprimed `applyOuter` never subscribes.
485
+ if (preamble?.declaredNames.has(name)) {
486
+ readsPreamble = true
487
+ for (const dep of preamble.freeNames) classifyName(dep)
488
+ continue
489
+ }
490
+ classifyName(name)
491
+ }
492
+
431
493
  return {
432
494
  kind,
433
495
  slotId,
@@ -436,5 +498,6 @@ export function classifyLazyBinding(args: {
436
498
  reactiveOuterNames,
437
499
  opaqueOuterNames,
438
500
  referencesIndex,
501
+ readsPreamble,
439
502
  }
440
503
  }
@@ -19,6 +19,13 @@ export interface ReactiveAttrEffect {
19
19
  wrappedExpression: string
20
20
  /** Pre-copied attr metadata used by emitAttrUpdate. */
21
21
  meta: AttrMeta
22
+ /**
23
+ * `wrappedExpression` reads a `.map()` callback preamble local (#2447
24
+ * follow-up), so the emitter must run the preamble ahead of this write —
25
+ * the local is not otherwise in scope in the update body, and its value
26
+ * is what makes the attribute reactive at all.
27
+ */
28
+ readsPreamble?: boolean
22
29
  }
23
30
 
24
31
  /** Reactive attrs grouped by child slot (one qsa lookup per slot). */