@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.
- package/dist/adapters/child-scope.d.ts +17 -0
- package/dist/adapters/child-scope.d.ts.map +1 -0
- package/dist/adapters/loop-bound-names.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +990 -367
- package/dist/ir-to-client-js/collect-elements.d.ts +5 -25
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +66 -2
- package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +92 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +134 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +46 -11
- package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
- package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +2 -3
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts +1 -10
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +12 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/prop-rewrite.d.ts +24 -7
- package/dist/prop-rewrite.d.ts.map +1 -1
- package/dist/rich-type-evidence.d.ts.map +1 -1
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/to-locale-date-lowering.d.ts.map +1 -1
- package/dist/types.d.ts +78 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +6 -24
- package/src/__tests__/branch-loop-plain.test.ts +28 -45
- package/src/__tests__/csr-substitution-safety-divergence.test.ts +9 -3
- package/src/__tests__/csr-template-scope-soundness.test.ts +167 -0
- package/src/__tests__/early-return-branch-switch.test.ts +96 -0
- package/src/__tests__/form-control-value-ssr.test.ts +102 -0
- package/src/__tests__/lazy-conditional.test.ts +190 -0
- package/src/__tests__/lazy-preamble.test.ts +297 -0
- package/src/__tests__/lazy-row-eligibility.test.ts +97 -28
- package/src/__tests__/loop-item-root-scope.test.ts +213 -0
- package/src/__tests__/map-multi-return-body.test.ts +30 -3
- package/src/__tests__/nested-loop-conditional.test.ts +27 -6
- package/src/__tests__/preamble-attr-reactivity.test.ts +217 -0
- package/src/__tests__/preamble-declarations.test.ts +207 -0
- package/src/__tests__/ssr-defaults.test.ts +44 -0
- package/src/adapters/child-scope.ts +22 -0
- package/src/adapters/loop-bound-names.ts +6 -0
- package/src/analyzer-context.ts +43 -0
- package/src/analyzer.ts +33 -3
- package/src/index.ts +1 -0
- package/src/ir-to-client-js/collect-elements.ts +51 -10
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +1 -1
- package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -2
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +3 -3
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -2
- package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +147 -5
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -2
- package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
- package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +151 -0
- package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +287 -0
- package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +85 -22
- package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
- package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +133 -35
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +24 -4
- package/src/ir-to-client-js/csr-substitute.ts +6 -1
- package/src/ir-to-client-js/emit-registration.ts +1 -1
- package/src/ir-to-client-js/html-template.ts +51 -40
- package/src/ir-to-client-js/index.ts +1 -1
- package/src/ir-to-client-js/plan/build-declaration-emit.ts +2 -0
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +1 -1
- package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
- package/src/ir-to-client-js/reactivity.ts +25 -0
- package/src/ir-to-client-js/stringify/declaration-emit.ts +9 -4
- package/src/ir-to-client-js/types.ts +12 -0
- package/src/jsx-to-ir.ts +409 -24
- package/src/prop-rewrite.ts +149 -19
- package/src/rich-type-evidence.ts +6 -7
- package/src/ssr-defaults.ts +7 -2
- package/src/to-locale-date-lowering.ts +8 -2
- 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,
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
242
|
-
|
|
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)
|
|
251
|
-
//
|
|
252
|
-
if (shape.
|
|
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
|
-
|
|
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
|
-
|
|
445
|
+
return
|
|
399
446
|
}
|
|
400
447
|
if (name === indexParam) {
|
|
401
448
|
referencesIndex = true
|
|
402
|
-
|
|
449
|
+
return
|
|
403
450
|
}
|
|
404
|
-
if (INERT_BINDING_GLOBALS.has(name))
|
|
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
|
-
|
|
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)
|
|
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). */
|