@barefootjs/jsx 0.31.4 → 0.31.5
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/jsx-adapter.d.ts.map +1 -1
- package/dist/adapters/loop-bound-names.d.ts +18 -0
- package/dist/adapters/loop-bound-names.d.ts.map +1 -1
- package/dist/adapters/test-adapter.d.ts.map +1 -1
- package/dist/augment-inherited-props.d.ts +12 -2
- package/dist/augment-inherited-props.d.ts.map +1 -1
- package/dist/compiler.d.ts.map +1 -1
- package/dist/debug.d.ts.map +1 -1
- package/dist/free-refs.d.ts +11 -2
- package/dist/free-refs.d.ts.map +1 -1
- package/dist/index.js +765 -649
- package/dist/ir-to-client-js/collect-elements.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/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/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts +16 -0
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +16 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +15 -0
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/module-exports.d.ts +64 -0
- package/dist/module-exports.d.ts.map +1 -1
- package/dist/scope/binding-scope.d.ts +1 -1
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/binding-scope-ratchet.test.ts +146 -21
- package/src/__tests__/component-type-parameters.test.ts +70 -0
- package/src/__tests__/csr-materialize-loop-preamble-shadow.test.ts +10 -1
- package/src/__tests__/free-refs.test.ts +1 -1
- package/src/__tests__/mutable-binding-writers.test.ts +133 -0
- package/src/__tests__/preamble-conditional-reactivity.test.ts +191 -0
- package/src/__tests__/signal-setter-updater-type.test.ts +81 -0
- package/src/adapters/jsx-adapter.ts +29 -3
- package/src/adapters/loop-bound-names.ts +18 -0
- package/src/adapters/test-adapter.ts +4 -1
- package/src/augment-inherited-props.ts +13 -1
- package/src/compiler.ts +18 -0
- package/src/debug.ts +34 -21
- package/src/free-refs.ts +14 -5
- package/src/ir-to-client-js/collect-elements.ts +17 -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/reactive-effects.ts +7 -0
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +12 -2
- package/src/ir-to-client-js/html-template.ts +5 -0
- package/src/ir-to-client-js/reactivity.ts +4 -2
- package/src/ir-to-client-js/types.ts +16 -0
- package/src/ir-to-client-js/utils.ts +15 -0
- package/src/jsx-to-ir.ts +117 -1
- package/src/module-exports.ts +137 -0
- package/src/scope/binding-scope.ts +1 -1
- package/src/types.ts +41 -0
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* pattern covers both.
|
|
13
13
|
* Modeled in spirit on `map-body-no-silent-divergence.test.ts`'s known-hole
|
|
14
14
|
* ledger: a known-inventory of the current reality that may only shrink as
|
|
15
|
-
* Stages 1-4 migrate call sites onto `BindingScope`, never grow.
|
|
15
|
+
* Stages 1-4 migrate call sites onto `BindingScope`, never grow. Stage 4
|
|
16
|
+
* drove this to its FLOOR — every remaining entry is a permanent, justified
|
|
17
|
+
* exception (see the `ALLOWLIST` doc comment below), not leftover debt.
|
|
16
18
|
*
|
|
17
19
|
* Scans every `.ts` file under `packages/jsx/src/` and each `packages/adapter-X/src/`
|
|
18
20
|
* (excluding `__tests__` directories, `*.test.ts` files, and
|
|
@@ -58,44 +60,167 @@ const PATTERNS = [
|
|
|
58
60
|
type Pattern = (typeof PATTERNS)[number]
|
|
59
61
|
|
|
60
62
|
/**
|
|
61
|
-
* Exact per-file, per-pattern occurrence counts
|
|
62
|
-
*
|
|
63
|
-
*
|
|
63
|
+
* Exact per-file, per-pattern occurrence counts, updated through #2482
|
|
64
|
+
* Stage 4 (the FLOOR — see that stage's PR for the file-by-file
|
|
65
|
+
* MIGRATE-or-FLOOR review). Only ever shrinks — see header comment. A
|
|
66
|
+
* missing file or missing pattern key means an expected count of 0.
|
|
67
|
+
*
|
|
68
|
+
* ============================== FLOOR INVARIANT ==============================
|
|
69
|
+
* Every entry below is a PERMANENT, JUSTIFIED exception, not leftover debt —
|
|
70
|
+
* each one is annotated with why it can't (or shouldn't) thread a live
|
|
71
|
+
* `BindingScope` instead. Two shapes recur across the justifications:
|
|
72
|
+
*
|
|
73
|
+
* 1. NO LIVE SCOPE TO CONSULT: a prepass that runs once at `generate()` /
|
|
74
|
+
* constructor-generation time, OUTSIDE the render-time (or
|
|
75
|
+
* render-shaped) tree walk `BindingScope` is threaded through — there is
|
|
76
|
+
* no position to ask "what's bound HERE" about, so these fall back to a
|
|
77
|
+
* coarser, deliberately over-inclusive whole-component or whole-file
|
|
78
|
+
* scan (safe because over-exclusion only ever degrades to the
|
|
79
|
+
* already-accepted pre-#2482 residual, never to silently wrong output).
|
|
80
|
+
* 2. UNRELATED DOMAIN: a `localConstants.find(` matching the ledger's
|
|
81
|
+
* textual pattern by coincidence — const/prop resolution for SSR
|
|
82
|
+
* dead-code elimination, `Record[key]` lookup, cross-IR CSS class
|
|
83
|
+
* resolution, component-scope const-chain inlining — none of which ask
|
|
84
|
+
* "is this name shadowed by a loop callback param" at all, so
|
|
85
|
+
* `BindingScope` (a loop-row/callback binding stack) has no bearing on
|
|
86
|
+
* them.
|
|
87
|
+
* 3. ACCESSOR/REWRITE PAYLOAD, NOT AN EXISTENCE QUERY: `BindingScope` only
|
|
88
|
+
* carries binding EXISTENCE/kind/depth — never per-binding rendering
|
|
89
|
+
* payload (a Go template accessor string, an ordered client-JS
|
|
90
|
+
* accessor-rewrite spec). The Go adapter's `loopBindingStack` and the
|
|
91
|
+
* client-JS emitter's `loopParams: ReadonlyArray<string | LoopParamSpec>`
|
|
92
|
+
* (`ir-to-client-js/utils.ts`'s `wrapExprWithLoopParams` and its
|
|
93
|
+
* `collect-elements.ts`/`html-template.ts`/`build-event-delegation.ts`
|
|
94
|
+
* callers) are this shape — a `BindingScope`-adjacent but structurally
|
|
95
|
+
* distinct concern.
|
|
96
|
+
*
|
|
97
|
+
* New code must use `BindingScope` (`packages/jsx/src/scope/binding-scope.ts`)
|
|
98
|
+
* for any "is this name bound by an enclosing loop callback" question. Per
|
|
99
|
+
* the mechanics already documented below: entries may only shrink (or be
|
|
100
|
+
* removed outright when a file reaches 0), never grow — a grown or added
|
|
101
|
+
* entry is a regression to flag in review, not to allowlist away.
|
|
102
|
+
* ===============================================================================
|
|
64
103
|
*/
|
|
65
104
|
const ALLOWLIST: Record<string, Partial<Record<Pattern, number>>> = {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
105
|
+
// FLOOR (shape 1, already guarded): `expandDynamicPropValue`'s
|
|
106
|
+
// `this.scope`-mirroring shadow guard (`erb-adapter.ts`'s own
|
|
107
|
+
// `!this.scope.isBound(trimmed)` check, #2489) precedes this `.find(` —
|
|
108
|
+
// the lookup itself is the legitimate const-resolution step once the
|
|
109
|
+
// shadow question is already answered by `scope`.
|
|
110
|
+
'packages/adapter-erb/src/adapter/erb-adapter.ts': { 'localConstants.find(': 1 },
|
|
111
|
+
// FLOOR (shape 2/3 boundary): `inlineLocalHelperCall` resolves a call's
|
|
112
|
+
// CALLEE name against module/component consts to inline a local
|
|
113
|
+
// arrow-helper's body (`sortClass(k)` → its substituted expression) — a
|
|
114
|
+
// "does a helper by this name exist" lookup, not itself a shadow guard.
|
|
115
|
+
// Flagged for the human maintainer (see Stage 4 report): unlike the
|
|
116
|
+
// sibling `litConst` fast path directly above its call site in
|
|
117
|
+
// `go-template-adapter.ts` (which DOES guard with `isLoopShadowedName`),
|
|
118
|
+
// this call site has no such guard — a `.map((sortClass) => ...)`
|
|
119
|
+
// shadowing a same-named module helper is a narrow, unverified gap left
|
|
120
|
+
// for a follow-up rather than fixed speculatively here.
|
|
69
121
|
'packages/adapter-go-template/src/adapter/expr/helper-inline.ts': { 'localConstants.find(': 1 },
|
|
70
122
|
'packages/adapter-go-template/src/adapter/go-template-adapter.ts': {
|
|
123
|
+
// FLOOR: of the 5 `.find(` sites — `resolveDynamicPropValue` (child-prop
|
|
124
|
+
// passthrough, called only from `generateNewPropsFunction`'s
|
|
125
|
+
// `emitStaticChildInstances`), `computeDerivedConstFields` and
|
|
126
|
+
// `isStringExpr` (same `generateNewPropsFunction` constructor-context
|
|
127
|
+
// bucket), and `resolveModuleNumericConst` (guarded in place by
|
|
128
|
+
// `isCurrentLoopItem`/`isOuterLoopParam`, both already `this.scope.lookup`-
|
|
129
|
+
// backed) — 3 are shape-1 (no live scope in the ctor-generation prepass)
|
|
130
|
+
// and 1 is already scope-guarded. The 5th, `renderLoop`'s loop-array
|
|
131
|
+
// const lookup, gained a `!this.scope.isBound(arrayName)` guard in
|
|
132
|
+
// Stage 4 (a real gap: an enclosing loop's own item param could shadow
|
|
133
|
+
// a same-named module const and misfire a BF101) — MIGRATED in place,
|
|
134
|
+
// the `.find(` call itself stays as the legitimate lookup once shadow
|
|
135
|
+
// is ruled out.
|
|
71
136
|
'localConstants.find(': 5,
|
|
72
|
-
|
|
73
|
-
|
|
137
|
+
// #2482 Stage 3: `loopParamStack` eliminated entirely (0, down from 35)
|
|
138
|
+
// — replaced by the threaded `this.scope: BindingScope`. The remaining
|
|
139
|
+
// 2 `staticLoopSourceBoundNames` uses (down from 3) are the
|
|
140
|
+
// `getBakedStaticChildLoop` shadow guard shared with two call sites
|
|
141
|
+
// OUTSIDE the live `renderLoop` tree walk (no live `scope` to consult
|
|
142
|
+
// there — shape 1) — a genuinely-legitimate surviving use, confirmed
|
|
143
|
+
// FLOOR in Stage 4 (`primeCompileState`'s own comment documents the
|
|
144
|
+
// three-call-site agreement requirement).
|
|
145
|
+
staticLoopSourceBoundNames: 2,
|
|
74
146
|
},
|
|
75
|
-
|
|
147
|
+
// FLOOR (shape 1 for the field; the doc-only `loopParamStack` mention this
|
|
148
|
+
// file's top-level comment used to carry was reworded in Stage 4 — that
|
|
149
|
+
// stack no longer exists anywhere, so the ledger's last trace of it is
|
|
150
|
+
// gone too).
|
|
151
|
+
'packages/adapter-go-template/src/adapter/lib/compile-state.ts': { staticLoopSourceBoundNames: 1 },
|
|
152
|
+
// FLOOR (shape 1): `lowerCtorExpr`/`lowerCtorStringArray` lower a derived-
|
|
153
|
+
// state memo's computation into Go CODE evaluated in the `NewXxxProps`
|
|
154
|
+
// constructor — the same no-live-scope bucket as `go-template-adapter.ts`'s
|
|
155
|
+
// ctor-context `.find(` calls above.
|
|
76
156
|
'packages/adapter-go-template/src/adapter/memo/ctor-lowering.ts': { 'localConstants.find(': 3 },
|
|
157
|
+
// FLOOR (shape 1): `packageModuleConst` — same `NewXxxProps` ctor-context
|
|
158
|
+
// bucket as `ctor-lowering.ts`.
|
|
77
159
|
'packages/adapter-go-template/src/adapter/memo/memo-value.ts': { 'localConstants.find(': 1 },
|
|
78
|
-
|
|
160
|
+
// FLOOR (shape 1, already guarded): same `this.scope.isBound(trimmed)`
|
|
161
|
+
// shadow guard as `erb-adapter.ts` (#2221 — `resolveLiteralConst` /
|
|
162
|
+
// `resolveStaticRecordLiteral`'s established pattern).
|
|
79
163
|
'packages/adapter-mojolicious/src/adapter/mojo-adapter.ts': {
|
|
80
164
|
'localConstants.find(': 1,
|
|
81
|
-
staticLoopSourceBoundNames: 1,
|
|
82
|
-
loopBoundNames: 23,
|
|
83
165
|
},
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
166
|
+
// FLOOR (shape 2): `generateSignalInitializers`'s reachability analysis
|
|
167
|
+
// for SSR no-op initializer dead-code elimination — module/component-level
|
|
168
|
+
// declaration reachability, unrelated to loop-row shadowing entirely.
|
|
87
169
|
'packages/jsx/src/adapters/jsx-adapter.ts': { 'localConstants.find(': 1 },
|
|
170
|
+
// FLOOR (shape 2): resolves a `Record<T,string>[key]`-shaped indexed
|
|
171
|
+
// lookup's IDENT operand to a module-scope object-literal const for
|
|
172
|
+
// `renderToTest`'s union-semantics resolution — a component-level static
|
|
173
|
+
// analysis pass, not a loop-row shadow question.
|
|
88
174
|
'packages/jsx/src/augment-inherited-props.ts': { 'localConstants.find(': 1 },
|
|
175
|
+
// FLOOR (shape 2): cross-IR CSS class-const resolution for the UnoCSS
|
|
176
|
+
// layer prefixer's transitive-reference walk — operates across whole
|
|
177
|
+
// components' `localConstants` lists, no loop-row scope involved.
|
|
89
178
|
'packages/jsx/src/css-layer-prefixer.ts': { 'localConstants.find(': 1 },
|
|
90
|
-
'packages/jsx/src/
|
|
91
|
-
|
|
179
|
+
'packages/jsx/src/free-refs.ts': {
|
|
180
|
+
// FLOOR (shape 2): `resolveConstantInitializerRefs`'s transitive-taint
|
|
181
|
+
// expansion — resolves a local constant's OWN initializer by name to
|
|
182
|
+
// recurse into its free refs, not a loop-row shadow check.
|
|
183
|
+
'localConstants.find(': 1,
|
|
184
|
+
// FLOOR: the `BindingEnvironment.loopParams` field itself was RENAMED
|
|
185
|
+
// (Stage 4) to `loopValueBoundNames` (it carries `scope.valueBoundNames()`
|
|
186
|
+
// — the field name now says so). This single remaining occurrence is the
|
|
187
|
+
// rename's own historical-name mention in `loopValueBoundNames`'s
|
|
188
|
+
// docstring, matching the established convention elsewhere (e.g.
|
|
189
|
+
// `binding-scope.ts`'s own header keeps six `ctx.loopParams` mentions as
|
|
190
|
+
// migration history).
|
|
191
|
+
loopParams: 1,
|
|
192
|
+
},
|
|
193
|
+
// FLOOR (shape 3): `irToPlaceholderTemplate`'s loop-param accessor-rewrite
|
|
194
|
+
// spec forwarding — see the canonical docstring on
|
|
195
|
+
// `wrapExprWithLoopParams` in `ir-to-client-js/utils.ts`.
|
|
92
196
|
'packages/jsx/src/ir-to-client-js/collect-elements.ts': { loopParams: 9 },
|
|
197
|
+
// FLOOR (shape 2): `computeCsrInlinability`'s fixed-point constant-chain
|
|
198
|
+
// inlining loop over `ctx.localConstants` — component-scope const
|
|
199
|
+
// resolution, not loop-row scope.
|
|
93
200
|
'packages/jsx/src/ir-to-client-js/compute-inlinability.ts': { 'localConstants.find(': 1 },
|
|
201
|
+
// FLOOR (shape 3): event-delegation plan building deliberately does NOT
|
|
202
|
+
// pass a loop-param accessor-rewrite spec (see the file's own comments) —
|
|
203
|
+
// the remaining mentions are all in that reasoning, not device usage.
|
|
94
204
|
'packages/jsx/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts': { loopParams: 4 },
|
|
95
|
-
|
|
205
|
+
// FLOOR (shape 3): `irToHtmlTemplate`/`irToPlaceholderTemplate`'s
|
|
206
|
+
// loop-param accessor-rewrite spec — see `ir-to-client-js/utils.ts`'s
|
|
207
|
+
// `wrapExprWithLoopParams` docstring (the canonical explanation, pointed
|
|
208
|
+
// to from this file's own function signature since Stage 4).
|
|
209
|
+
'packages/jsx/src/ir-to-client-js/html-template.ts': { loopParams: 17 },
|
|
210
|
+
// FLOOR (shape 1, already guarded): both `expandDynamicPropValue` and
|
|
211
|
+
// `expandConstantForReactivity` precede their `.find(` with
|
|
212
|
+
// `scope?.isBound(trimmedValue)` — see this file's own header comment
|
|
213
|
+
// (added Stage 1b) for the full SHADOW GUARD reasoning.
|
|
96
214
|
'packages/jsx/src/ir-to-client-js/prop-handling.ts': { 'localConstants.find(': 2 },
|
|
97
|
-
|
|
98
|
-
|
|
215
|
+
// FLOOR (shape 3): `wrapExprWithLoopParams` / `LoopParamSpec` — the
|
|
216
|
+
// canonical definition of the accessor-rewrite payload every other
|
|
217
|
+
// `loopParams`-named parameter in `ir-to-client-js/` forwards. See its
|
|
218
|
+
// docstring (added Stage 4) for the full shape-3 reasoning.
|
|
219
|
+
'packages/jsx/src/ir-to-client-js/utils.ts': { loopParams: 4 },
|
|
220
|
+
// `jsx-to-ir.ts` — MIGRATED to 0 in Stage 4: `makeBindingEnv`'s
|
|
221
|
+
// `loopParams: boundNames` field now reads `loopValueBoundNames:
|
|
222
|
+
// boundNames`, matching `free-refs.ts`'s renamed field. No entry needed
|
|
223
|
+
// (a missing key means an expected count of 0).
|
|
99
224
|
}
|
|
100
225
|
|
|
101
226
|
const SCOPE_MODULE_DIR = join(REPO_ROOT, 'packages', 'jsx', 'src', 'scope')
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for #2573 (xyflow TS2304 family): a component function's
|
|
3
|
+
* own generic type parameters (`function Flow<NodeType, EdgeType>(...)`)
|
|
4
|
+
* were dropped from the emitted `.tsx` SSR template — the function
|
|
5
|
+
* signature came out as `function Flow(...)` even though its props type
|
|
6
|
+
* annotation (and often its body) kept referencing `NodeType`/`EdgeType`
|
|
7
|
+
* verbatim (`props: FlowComponentProps<NodeType, EdgeType>`,
|
|
8
|
+
* `createFlowStore<NodeType, EdgeType>(props)`). TypeScript then reported
|
|
9
|
+
* "Cannot find name 'NodeType'" (TS2304) at every such reference — the
|
|
10
|
+
* declaration that would have brought the name into scope was never
|
|
11
|
+
* emitted. Runtime output (client JS) was always correct; this is a
|
|
12
|
+
* type-level emission defect in the SSR template only.
|
|
13
|
+
*
|
|
14
|
+
* `IRMetadata.typeParameters` now carries the source's type parameter list
|
|
15
|
+
* verbatim (`node.getText()` per parameter, mirroring `ConstantInfo.
|
|
16
|
+
* typeAnnotation`'s #2589 precedent), and `HonoAdapter`/`TestAdapter` splice
|
|
17
|
+
* it between the function name and the parameter list.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, test, expect } from 'bun:test'
|
|
21
|
+
import { compileJSX } from '../compiler'
|
|
22
|
+
import { HonoAdapter } from '../../../../packages/adapter-hono/src/adapter/hono-adapter'
|
|
23
|
+
|
|
24
|
+
describe('component type parameter preservation in emitted templates (#2573)', () => {
|
|
25
|
+
test('a generic function component keeps its type parameters on the emitted signature', () => {
|
|
26
|
+
const honoAdapter = new HonoAdapter()
|
|
27
|
+
const source = `
|
|
28
|
+
interface NodeBase { id: string }
|
|
29
|
+
interface EdgeBase { id: string }
|
|
30
|
+
interface FlowProps<NodeType extends NodeBase, EdgeType extends EdgeBase> {
|
|
31
|
+
nodes: NodeType[]
|
|
32
|
+
edges: EdgeType[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function Flow<NodeType extends NodeBase = NodeBase, EdgeType extends EdgeBase = EdgeBase>(
|
|
36
|
+
props: FlowProps<NodeType, EdgeType>,
|
|
37
|
+
) {
|
|
38
|
+
const count = props.nodes.length + props.edges.length
|
|
39
|
+
return <div>{count}</div>
|
|
40
|
+
}
|
|
41
|
+
`
|
|
42
|
+
|
|
43
|
+
const result = compileJSX(source, 'Flow.tsx', { adapter: honoAdapter })
|
|
44
|
+
expect(result.errors).toHaveLength(0)
|
|
45
|
+
|
|
46
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
47
|
+
expect(template).toBeDefined()
|
|
48
|
+
expect(template.content).toContain(
|
|
49
|
+
'function Flow<NodeType extends NodeBase = NodeBase, EdgeType extends EdgeBase = EdgeBase>(',
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('a non-generic function component gains no type parameter clause', () => {
|
|
54
|
+
const honoAdapter = new HonoAdapter()
|
|
55
|
+
const source = `
|
|
56
|
+
interface CardProps { title: string }
|
|
57
|
+
export function Card(props: CardProps) {
|
|
58
|
+
return <div>{props.title}</div>
|
|
59
|
+
}
|
|
60
|
+
`
|
|
61
|
+
|
|
62
|
+
const result = compileJSX(source, 'Card.tsx', { adapter: honoAdapter })
|
|
63
|
+
expect(result.errors).toHaveLength(0)
|
|
64
|
+
|
|
65
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
66
|
+
expect(template).toBeDefined()
|
|
67
|
+
expect(template.content).toContain('function Card(')
|
|
68
|
+
expect(template.content).not.toMatch(/function Card</)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -69,7 +69,16 @@ describe('CSR materialize template vs .map() preamble-local shadowing a module c
|
|
|
69
69
|
|
|
70
70
|
// The row-local `label` (the preamble's OWN computed value) must
|
|
71
71
|
// drive the branch — never the outer module const's literal.
|
|
72
|
-
|
|
72
|
+
//
|
|
73
|
+
// The branch also carries a `bf-c="s0"` slot marker on both arms
|
|
74
|
+
// (#2596 follow-up): `label`'s initializer reads the real signal `on()`,
|
|
75
|
+
// so `markPreambleConditionalReactivity` now grants this conditional a
|
|
76
|
+
// slot id and the `reactive` flag it lacked before — the SAME
|
|
77
|
+
// observable-failure-mode class this file's docstring describes
|
|
78
|
+
// ("every row's branch condition evaluated the constant's fixed
|
|
79
|
+
// truthiness"), just for the signal dimension instead of the shadowing
|
|
80
|
+
// one. Nothing else about this fixture's shape changed.
|
|
81
|
+
expect(tpl).toContain('label ? `<span bf-c="s0">yes</span>` : `<span bf-c="s0">no</span>`')
|
|
73
82
|
expect(tpl).not.toContain("('MODULE_CONST')")
|
|
74
83
|
expect(tpl).not.toContain('MODULE_CONST')
|
|
75
84
|
})
|
|
@@ -110,7 +110,7 @@ describe('resolveFreeRefs — kind resolution', () => {
|
|
|
110
110
|
const env = {
|
|
111
111
|
...emptyEnv(),
|
|
112
112
|
localConstants: [mkConst('item')],
|
|
113
|
-
|
|
113
|
+
loopValueBoundNames: new Set(['item']),
|
|
114
114
|
}
|
|
115
115
|
const refs = resolveFreeRefs(parseExpression('item'), env)
|
|
116
116
|
expect(refs.find(r => r.name === 'item')?.kind).toBe('render-item')
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit coverage for `findAssignedNames` / `closeOverWritersOfMutableBindings`
|
|
3
|
+
* (#2598).
|
|
4
|
+
*
|
|
5
|
+
* The e2e half lives in `packages/vite/src/__tests__/mutable-binding-writers.test.ts`
|
|
6
|
+
* (real `vite build`, emitted template type-checked). This half pins what an
|
|
7
|
+
* e2e fixture can't isolate: which syntactic forms count as a WRITE, and the
|
|
8
|
+
* reads/false matches that must not. Those negative cases are why this uses
|
|
9
|
+
* the AST — a regex for `name\s*=` would match every one of them.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, test, expect } from 'bun:test'
|
|
12
|
+
import { findAssignedNames, closeOverWritersOfMutableBindings } from '../module-exports.ts'
|
|
13
|
+
|
|
14
|
+
const candidates = (...names: string[]) => new Set(names)
|
|
15
|
+
|
|
16
|
+
describe('findAssignedNames', () => {
|
|
17
|
+
test('plain assignment', () => {
|
|
18
|
+
expect([...findAssignedNames(`el = node`, candidates('el'))]).toEqual(['el'])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('compound assignment', () => {
|
|
22
|
+
expect([...findAssignedNames(`seq += 1`, candidates('seq'))]).toEqual(['seq'])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('logical assignment', () => {
|
|
26
|
+
expect([...findAssignedNames(`cached ??= build()`, candidates('cached'))]).toEqual(['cached'])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('postfix and prefix update', () => {
|
|
30
|
+
expect([...findAssignedNames(`a++`, candidates('a'))]).toEqual(['a'])
|
|
31
|
+
expect([...findAssignedNames(`--b`, candidates('b'))]).toEqual(['b'])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('assignment nested inside a function body', () => {
|
|
35
|
+
const body = `(el) => { if (el) { highlightEl = el } }`
|
|
36
|
+
expect([...findAssignedNames(body, candidates('highlightEl'))]).toEqual(['highlightEl'])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('reports only the candidates asked about', () => {
|
|
40
|
+
const body = `a = 1; b = 2; c = 3`
|
|
41
|
+
expect([...findAssignedNames(body, candidates('b'))]).toEqual(['b'])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('a read is not a write', () => {
|
|
45
|
+
expect([...findAssignedNames(`const x = el`, candidates('el'))]).toEqual([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('a comparison is not a write', () => {
|
|
49
|
+
expect([...findAssignedNames(`if (el == null) {}`, candidates('el'))]).toEqual([])
|
|
50
|
+
expect([...findAssignedNames(`if (el === other) {}`, candidates('el'))]).toEqual([])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('a property write through the binding is not a write TO it', () => {
|
|
54
|
+
// `el` keeps whatever it already held — this cannot be what gives a
|
|
55
|
+
// never-narrowed binding its value.
|
|
56
|
+
expect([...findAssignedNames(`el.scrollTop = 0`, candidates('el'))]).toEqual([])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('a same-named property key is not a write', () => {
|
|
60
|
+
expect([...findAssignedNames(`const o = { el: 1 }`, candidates('el'))]).toEqual([])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('`name=` inside a string or JSX attribute is not a write', () => {
|
|
64
|
+
expect([...findAssignedNames(`const s = "el = node"`, candidates('el'))]).toEqual([])
|
|
65
|
+
expect([...findAssignedNames(`<div data-x="el = node" />`, candidates('el'))]).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('no candidates means no parse and no results', () => {
|
|
69
|
+
expect([...findAssignedNames(`el = node`, candidates())]).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('closeOverWritersOfMutableBindings', () => {
|
|
74
|
+
// `declarations` carries the local `let` bindings themselves alongside the
|
|
75
|
+
// functions, exactly as the adapter builds it (localConstants +
|
|
76
|
+
// localFunctions) — a binding only counts as "surviving" if it is in this
|
|
77
|
+
// list and reachable.
|
|
78
|
+
const decls = [
|
|
79
|
+
{ name: 'binding', body: `null` },
|
|
80
|
+
// Reachable from the rendered JSX below.
|
|
81
|
+
{ name: 'reader', body: `() => { if (binding) return binding.x; return 0 }` },
|
|
82
|
+
// Reachable ONLY through a stripped attribute — the writer.
|
|
83
|
+
{ name: 'writer', body: `(el) => { binding = el }` },
|
|
84
|
+
// Reachable from nothing at all; must stay pruned.
|
|
85
|
+
{ name: 'orphan', body: `() => { unrelated = 1 }` },
|
|
86
|
+
]
|
|
87
|
+
const rendered = `<div onScroll={reader} />`
|
|
88
|
+
|
|
89
|
+
test('pulls in the writer of a surviving mutable binding', () => {
|
|
90
|
+
const out = closeOverWritersOfMutableBindings(rendered, decls, new Set(['binding']))
|
|
91
|
+
expect(out.has('reader')).toBe(true)
|
|
92
|
+
expect(out.has('writer')).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('leaves declarations that write nothing reachable alone', () => {
|
|
96
|
+
const out = closeOverWritersOfMutableBindings(rendered, decls, new Set(['binding']))
|
|
97
|
+
expect(out.has('orphan')).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('does not retain a writer when the binding itself was pruned', () => {
|
|
101
|
+
// Nothing references `binding`, so it is not emitted and its writer is
|
|
102
|
+
// dead client-only code — the pruning this feature must not undo.
|
|
103
|
+
const out = closeOverWritersOfMutableBindings(`<div />`, decls, new Set(['binding']))
|
|
104
|
+
expect(out.has('writer')).toBe(false)
|
|
105
|
+
expect(out.has('reader')).toBe(false)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('is a no-op when nothing is mutable', () => {
|
|
109
|
+
const withMutables = closeOverWritersOfMutableBindings(rendered, decls, new Set(['binding']))
|
|
110
|
+
const withoutMutables = closeOverWritersOfMutableBindings(rendered, decls, new Set())
|
|
111
|
+
expect(withoutMutables.has('writer')).toBe(false)
|
|
112
|
+
expect(withMutables.has('writer')).toBe(true)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('reaches a writer that is only retained transitively', () => {
|
|
116
|
+
// `outerWriter` writes `a`; retaining it makes `b` reachable, whose own
|
|
117
|
+
// writer must then come along too — one round is not enough.
|
|
118
|
+
const chained = [
|
|
119
|
+
{ name: 'a', body: `null` },
|
|
120
|
+
{ name: 'b', body: `null` },
|
|
121
|
+
{ name: 'reader', body: `() => (a ? a.x : 0)` },
|
|
122
|
+
{ name: 'outerWriter', body: `(el) => { a = el; touch(b) }` },
|
|
123
|
+
{ name: 'innerWriter', body: `(el) => { b = el }` },
|
|
124
|
+
]
|
|
125
|
+
const out = closeOverWritersOfMutableBindings(
|
|
126
|
+
`<div onScroll={reader} />`,
|
|
127
|
+
chained,
|
|
128
|
+
new Set(['a', 'b']),
|
|
129
|
+
)
|
|
130
|
+
expect(out.has('outerWriter')).toBe(true)
|
|
131
|
+
expect(out.has('innerWriter')).toBe(true)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2596 — inside a `.map()` callback, a conditional whose condition is a
|
|
3
|
+
* bare reference to a preamble-declared local (a pre-return `const` in the
|
|
4
|
+
* callback body) never got its IR `reactive` flag set, even when the
|
|
5
|
+
* preamble local reads a signal:
|
|
6
|
+
*
|
|
7
|
+
* {items().map((item) => {
|
|
8
|
+
* const label = item.title || fallback() // preamble local reading a signal
|
|
9
|
+
* return <li>{label ? <Badge/> : <Placeholder/>}</li> // never re-evaluated
|
|
10
|
+
* })}
|
|
11
|
+
*
|
|
12
|
+
* Root cause: Phase 1's conditional-reactivity classifiers
|
|
13
|
+
* (`isReactiveExpression` for the condition's own text, `referencesLoopParam`
|
|
14
|
+
* for item/index/destructure names via `BindingScope.valueBoundNames()`) only
|
|
15
|
+
* ever see the token `label` — never its declaration — so a bare preamble-
|
|
16
|
+
* local reference tripped neither.
|
|
17
|
+
*
|
|
18
|
+
* Fix (`jsx-to-ir.ts`):
|
|
19
|
+
* - `computePreambleReactiveNames` determines which preamble-declared names
|
|
20
|
+
* are THEMSELVES reactive — read a signal/memo/reactive prop, directly or
|
|
21
|
+
* transitively through an earlier preamble declaration — by re-running
|
|
22
|
+
* the SAME `isReactiveExpression` classifier already used for ordinary
|
|
23
|
+
* conditions, against each declaration's initializer.
|
|
24
|
+
* - `markPreambleConditionalReactivity` is a new post-hoc pass (same shape
|
|
25
|
+
* as `collectPreambleRegions`/`markPreambleAttrSlots`, #2447) that grants
|
|
26
|
+
* `reactive: true` + a slot id to a loop-body conditional whose condition
|
|
27
|
+
* bare-references one of those names.
|
|
28
|
+
*
|
|
29
|
+
* This alone isn't sufficient — Phase 2's `collectLoopChildConditionals`
|
|
30
|
+
* independently re-derives reactivity from the condition's EXPANDED text via
|
|
31
|
+
* `classifyReactivity`, and `expandConstantForReactivity`'s shadow guard
|
|
32
|
+
* (#2482 Stage 1b) deliberately leaves a preamble-bound identifier like
|
|
33
|
+
* `label` unexpanded, so `classifyReactivity('label', …)` always reads
|
|
34
|
+
* 'none'. `collectLoopChildConditionals` and `emitOuterConditional` (in
|
|
35
|
+
* `ir-to-client-js/`) got the same `readsPreamble` bypass +
|
|
36
|
+
* preamble-re-run-in-getter treatment `collectLoopChildReactiveAttrs`
|
|
37
|
+
* already had (#2447) — the condition-position twin.
|
|
38
|
+
*
|
|
39
|
+
* These tests assert the COMPILED OUTPUT shape (mirrors
|
|
40
|
+
* `csr-materialize-loop-preamble-shadow.test.ts` and
|
|
41
|
+
* `preamble-region-patch.test.ts`'s convention: compiler-internals
|
|
42
|
+
* correctness is a generated-code-shape pin, not a live-DOM test — DOM
|
|
43
|
+
* execution of `insert()`/`mapArrayLazy`'s own reactive-tracking contract is
|
|
44
|
+
* covered by their own runtime unit tests).
|
|
45
|
+
*
|
|
46
|
+
* Depending on loop-row shape, the fix surfaces through one of two Phase 2
|
|
47
|
+
* plans — both draw from the SAME `collectLoopChildConditionals` fix:
|
|
48
|
+
* - Both branches "wiring-free static elements" (`analyzeLazyConditional`,
|
|
49
|
+
* §9.4 L3): the lazy-row plan (`mapArrayLazy`), which recomputes the
|
|
50
|
+
* preamble in `applyItem` (per-row) AND `applyOuter` (outer-signal
|
|
51
|
+
* effect, primed by reading the signal unconditionally).
|
|
52
|
+
* - Otherwise: the eager `mapArray` + `insert()` plan, which recomputes the
|
|
53
|
+
* preamble inside the getter passed to `insert()`.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { describe, test, expect } from 'bun:test'
|
|
57
|
+
import { compileJSX } from '../compiler'
|
|
58
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
59
|
+
|
|
60
|
+
const adapter = new TestAdapter()
|
|
61
|
+
|
|
62
|
+
function clientJsFor(source: string): string {
|
|
63
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
64
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
65
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
66
|
+
expect(clientJs).toBeDefined()
|
|
67
|
+
return clientJs!.content
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('preamble-conditional-reactivity (#2596)', () => {
|
|
71
|
+
test('(a) a conditional condition reading a signal-derived preamble local is reactive (lazy-row plan)', () => {
|
|
72
|
+
const js = clientJsFor(`
|
|
73
|
+
'use client'
|
|
74
|
+
import { createSignal } from '@barefootjs/client'
|
|
75
|
+
export function Widget({ items }: { items: { id: number; title: string }[] }) {
|
|
76
|
+
const [fallback, setFallback] = createSignal('x')
|
|
77
|
+
return (
|
|
78
|
+
<ul>
|
|
79
|
+
{items.map((item) => {
|
|
80
|
+
const label = item.title || fallback()
|
|
81
|
+
return <li key={item.id}>{label ? <span>yes</span> : <span>no</span>}</li>
|
|
82
|
+
})}
|
|
83
|
+
</ul>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
`)
|
|
87
|
+
// Both arms are wiring-free static elements — eligible for the lazy-row
|
|
88
|
+
// plan (§9.4 L3), which carries no per-row insert()/createEffect.
|
|
89
|
+
expect(js).toContain('mapArrayLazy(')
|
|
90
|
+
// The preamble re-runs (loop-param accessor form, `item()`) inside BOTH
|
|
91
|
+
// apply bodies — `applyItem` (per-row, same-key update) and `applyOuter`
|
|
92
|
+
// (the outer-signal effect) — never the plain (`item.title`) form.
|
|
93
|
+
const preambleRerun = /const label = item\(\)\.title \|\| fallback\(\);/g
|
|
94
|
+
expect(js.match(preambleRerun)?.length).toBeGreaterThanOrEqual(2)
|
|
95
|
+
// `applyOuter` primes the signal read unconditionally (§9.3(3)) so the
|
|
96
|
+
// effect subscribes even when the row list is momentarily empty — this
|
|
97
|
+
// IS the fix: before it, nothing in the emitted plan read `fallback()`
|
|
98
|
+
// outside the one-time row-construction line, so the effect never
|
|
99
|
+
// subscribed and the branch froze at its SSR-time value.
|
|
100
|
+
expect(js).toMatch(/applyOuter:\s*\(__es, __seed\) => \{\s*fallback\(\)/)
|
|
101
|
+
// The conditional actually got wired — not the empty no-op body a
|
|
102
|
+
// non-reactive conditional gets (see negative-case test below).
|
|
103
|
+
expect(js).not.toContain('applyItem: () => {}')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('(b) the same shape through the eager insert() plan when a branch carries its own reactive text', () => {
|
|
107
|
+
const js = clientJsFor(`
|
|
108
|
+
'use client'
|
|
109
|
+
import { createSignal } from '@barefootjs/client'
|
|
110
|
+
export function Widget({ items }: { items: { id: number; title: string }[] }) {
|
|
111
|
+
const [fallback, setFallback] = createSignal('x')
|
|
112
|
+
return (
|
|
113
|
+
<ul>
|
|
114
|
+
{items.map((item) => {
|
|
115
|
+
const label = item.title || fallback()
|
|
116
|
+
return <li key={item.id}>{label ? <span>{item.title} yes</span> : <span>no</span>}</li>
|
|
117
|
+
})}
|
|
118
|
+
</ul>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
`)
|
|
122
|
+
// A branch with its own reactive text disqualifies the lazy-row plan
|
|
123
|
+
// (`analyzeLazyConditional` requires wiring-free arms) — this exercises
|
|
124
|
+
// the OTHER Phase 2 path the fix threads through.
|
|
125
|
+
expect(js).toContain('mapArray(')
|
|
126
|
+
expect(js).not.toContain('mapArrayLazy(')
|
|
127
|
+
// `insert()`'s condition getter re-runs the preamble before reading
|
|
128
|
+
// `label` — the local isn't otherwise in scope inside that closure.
|
|
129
|
+
expect(js).toMatch(
|
|
130
|
+
/insert\(__el, 's1', \(\) => \{ const label = item\(\)\.title \|\| fallback\(\);; return \(label\) \}/,
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('(c) a conditional reading a NON-reactive preamble local stays non-reactive', () => {
|
|
135
|
+
const js = clientJsFor(`
|
|
136
|
+
'use client'
|
|
137
|
+
import { createSignal } from '@barefootjs/client'
|
|
138
|
+
export function Widget({ items }: { items: { id: number; title: string }[] }) {
|
|
139
|
+
const [count, setCount] = createSignal(0)
|
|
140
|
+
return (
|
|
141
|
+
<ul>
|
|
142
|
+
{items.map((item) => {
|
|
143
|
+
const label = item.title
|
|
144
|
+
return <li key={item.id}>{label ? <span>yes</span> : <span>no</span>}</li>
|
|
145
|
+
})}
|
|
146
|
+
</ul>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
`)
|
|
150
|
+
// Still lazy-eligible (both arms are wiring-free statics), but the
|
|
151
|
+
// conditional itself never got a slot id / reactive flag — no per-row
|
|
152
|
+
// OR per-outer wiring for it at all.
|
|
153
|
+
expect(js).toContain('mapArrayLazy(')
|
|
154
|
+
expect(js).toContain('applyItem: () => {}')
|
|
155
|
+
expect(js).not.toContain('applyOuter')
|
|
156
|
+
expect(js).not.toContain('insert(')
|
|
157
|
+
// The condition's static, row-construction-time value is still baked
|
|
158
|
+
// into the row template (correct — it's genuinely never going to
|
|
159
|
+
// change without a fresh row).
|
|
160
|
+
expect(js).toMatch(/label \? `<span>yes<\/span>` : `<span>no<\/span>`/)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('(d) transitive: a preamble local reading another preamble local that reads a signal', () => {
|
|
164
|
+
const js = clientJsFor(`
|
|
165
|
+
'use client'
|
|
166
|
+
import { createSignal } from '@barefootjs/client'
|
|
167
|
+
export function Widget({ items }: { items: { id: number; title: string }[] }) {
|
|
168
|
+
const [fallback, setFallback] = createSignal('x')
|
|
169
|
+
return (
|
|
170
|
+
<ul>
|
|
171
|
+
{items.map((item) => {
|
|
172
|
+
const base = fallback()
|
|
173
|
+
const label = item.title || base
|
|
174
|
+
return <li key={item.id}>{label ? <span>yes</span> : <span>no</span>}</li>
|
|
175
|
+
})}
|
|
176
|
+
</ul>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
`)
|
|
180
|
+
// `computePreambleReactiveNames` walks preamble declarations in source
|
|
181
|
+
// order, folding each name into the reactive set when its initializer
|
|
182
|
+
// reads a signal/memo/prop directly OR references an earlier name
|
|
183
|
+
// already in that set — `label` qualifies via `base`, not via any
|
|
184
|
+
// signal call of its own.
|
|
185
|
+
expect(js).toContain('mapArrayLazy(')
|
|
186
|
+
expect(js).not.toContain('applyItem: () => {}')
|
|
187
|
+
expect(js).toMatch(/applyOuter:\s*\(__es, __seed\) => \{\s*fallback\(\)/)
|
|
188
|
+
const preambleRerun = /const base = fallback\(\); const label = item\(\)\.title \|\| base;/g
|
|
189
|
+
expect(js.match(preambleRerun)?.length).toBeGreaterThanOrEqual(2)
|
|
190
|
+
})
|
|
191
|
+
})
|