@barefootjs/jsx 0.31.3 → 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/template-imports.d.ts +27 -15
- package/dist/adapters/template-imports.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/identifier-pattern.d.ts +62 -0
- package/dist/identifier-pattern.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +968 -789
- package/dist/ir-to-client-js/collect-elements.d.ts +23 -2
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.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/control-flow/stringify/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts +12 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +20 -12
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/prop-handling.d.ts +25 -2
- package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts +46 -2
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/rewrite-props-object.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/jsx-to-ir.d.ts.map +1 -1
- package/dist/module-exports.d.ts +64 -0
- package/dist/module-exports.d.ts.map +1 -1
- package/dist/relocate.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 +85 -0
- package/src/__tests__/csr-substitute-enclosing-scope.test.ts +77 -0
- package/src/__tests__/free-refs.test.ts +1 -1
- package/src/__tests__/identifier-pattern.test.ts +170 -0
- package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +107 -0
- package/src/__tests__/mutable-binding-writers.test.ts +133 -0
- package/src/__tests__/preamble-conditional-reactivity.test.ts +191 -0
- package/src/__tests__/rewrite-dynamic-imports.test.ts +98 -0
- package/src/__tests__/signal-setter-updater-type.test.ts +81 -0
- package/src/adapters/jsx-adapter.ts +31 -4
- package/src/adapters/loop-bound-names.ts +18 -0
- package/src/adapters/template-imports.ts +93 -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 +38 -24
- package/src/free-refs.ts +14 -5
- package/src/identifier-pattern.ts +79 -0
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/collect-elements.ts +60 -16
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +2 -1
- 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/event-delegation.ts +2 -1
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +12 -2
- package/src/ir-to-client-js/csr-substitute.ts +19 -2
- package/src/ir-to-client-js/html-template.ts +42 -31
- package/src/ir-to-client-js/imports.ts +2 -1
- package/src/ir-to-client-js/prop-handling.ts +28 -1
- package/src/ir-to-client-js/reactivity.ts +58 -6
- package/src/ir-to-client-js/rewrite-props-object.ts +2 -1
- package/src/ir-to-client-js/types.ts +16 -0
- package/src/ir-to-client-js/utils.ts +24 -8
- package/src/jsx-to-ir.ts +135 -10
- package/src/module-exports.ts +140 -2
- package/src/relocate.ts +2 -1
- package/src/scope/binding-scope.ts +1 -1
- package/src/types.ts +41 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2592 — `$`-containing identifiers broke the compiler's
|
|
3
|
+
* `new RegExp(`\\b${name}\\b`)` "does this expression reference identifier
|
|
4
|
+
* X?" heuristic in two ways: an unescaped `$` acts as a regex end anchor
|
|
5
|
+
* (false negative anywhere but the very end of the pattern), and even once
|
|
6
|
+
* escaped, `\b` treats `$` as a non-word character, so it fails to find a
|
|
7
|
+
* boundary between e.g. `(` and a leading `$` (both non-word — no
|
|
8
|
+
* transition). See `../identifier-pattern.ts` for the fix.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, test, expect } from 'bun:test'
|
|
11
|
+
import { identifierPattern, identifierCallPattern } from '../identifier-pattern.ts'
|
|
12
|
+
import { compileJSX } from '../compiler.ts'
|
|
13
|
+
import { TestAdapter } from '../adapters/test-adapter.ts'
|
|
14
|
+
|
|
15
|
+
describe('identifierPattern (#2592)', () => {
|
|
16
|
+
describe.each(['$item', 'item$', 'a$b', 'item'])('name = %p', (name) => {
|
|
17
|
+
test('matches a standalone parenthesized reference', () => {
|
|
18
|
+
expect(identifierPattern(name).test(`(${name})`)).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('matches a reference after a binary operator', () => {
|
|
22
|
+
expect(identifierPattern(name).test(`x + ${name}`)).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('matches as the base of a member-access expression', () => {
|
|
26
|
+
expect(identifierPattern(name).test(`${name}.foo`)).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('does not match when it is a substring of a longer identifier (suffix)', () => {
|
|
30
|
+
expect(identifierPattern(name).test(`my${name}`)).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('does not match when it is a substring of a longer identifier (prefix)', () => {
|
|
34
|
+
expect(identifierPattern(name).test(`${name}s`)).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('does not match when it appears as a substring inside another identifier', () => {
|
|
38
|
+
// e.g. name="item" inside "xitem" / name="$item" inside "x$item"
|
|
39
|
+
expect(identifierPattern(name).test(`x${name}`)).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// Literal spellings from the issue, so the fixture reads without having
|
|
44
|
+
// to mentally substitute the parameterised %p name above.
|
|
45
|
+
test('$item: matches ($item), x + $item, $item.foo', () => {
|
|
46
|
+
const re = identifierPattern('$item')
|
|
47
|
+
expect(re.test('($item)')).toBe(true)
|
|
48
|
+
expect(re.test('x + $item')).toBe(true)
|
|
49
|
+
expect(re.test('$item.foo')).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('$item: does not match my$item, $items, item$s, aitem, xitem', () => {
|
|
53
|
+
const re = identifierPattern('$item')
|
|
54
|
+
expect(re.test('my$item')).toBe(false)
|
|
55
|
+
expect(re.test('$items')).toBe(false)
|
|
56
|
+
expect(re.test('item$s')).toBe(false)
|
|
57
|
+
expect(re.test('aitem')).toBe(false)
|
|
58
|
+
expect(re.test('xitem')).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('item (plain, no $) still matches only standalone references', () => {
|
|
62
|
+
const re = identifierPattern('item')
|
|
63
|
+
expect(re.test('(item)')).toBe(true)
|
|
64
|
+
expect(re.test('x + item')).toBe(true)
|
|
65
|
+
expect(re.test('item.foo')).toBe(true)
|
|
66
|
+
expect(re.test('my$item')).toBe(false)
|
|
67
|
+
expect(re.test('$items')).toBe(false)
|
|
68
|
+
expect(re.test('item$s')).toBe(false)
|
|
69
|
+
expect(re.test('aitem')).toBe(false)
|
|
70
|
+
expect(re.test('xitem')).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('the `g` flag supports substitution scanning across multiple matches', () => {
|
|
74
|
+
const re = identifierPattern('$x', 'g')
|
|
75
|
+
expect('$x + $x'.replace(re, () => 'Y')).toBe('Y + Y')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('regex-metacharacter identifiers are escaped, not interpreted', () => {
|
|
79
|
+
// Not a realistic JS identifier, but guards the escape step directly:
|
|
80
|
+
// an unescaped '.' would match any character.
|
|
81
|
+
const re = identifierPattern('a.b')
|
|
82
|
+
expect(re.test('a.b')).toBe(true)
|
|
83
|
+
expect(re.test('axb')).toBe(false)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('identifierCallPattern (#2592)', () => {
|
|
88
|
+
test('matches call syntax for a $-prefixed getter, with or without whitespace', () => {
|
|
89
|
+
expect(identifierCallPattern('$count').test('$count()')).toBe(true)
|
|
90
|
+
expect(identifierCallPattern('$count').test('$count ()')).toBe(true)
|
|
91
|
+
expect(identifierCallPattern('$count').test('1 + $count()')).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('does not match a bare (non-call) reference', () => {
|
|
95
|
+
expect(identifierCallPattern('$count').test('$count')).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('does not match when the name is a substring of a longer call', () => {
|
|
99
|
+
expect(identifierCallPattern('$count').test('my$count()')).toBe(false)
|
|
100
|
+
expect(identifierCallPattern('$count').test('$counter()')).toBe(false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('plain (non-$) getter names keep matching call syntax as before', () => {
|
|
104
|
+
expect(identifierCallPattern('count').test('count()')).toBe(true)
|
|
105
|
+
expect(identifierCallPattern('count').test('acount()')).toBe(false)
|
|
106
|
+
expect(identifierCallPattern('count').test('counter()')).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// -----------------------------------------------------------------------------
|
|
111
|
+
// End-to-end: a `.map()` callback whose param is `$item` must classify
|
|
112
|
+
// identically to one named `item` — same slotId allocation, same
|
|
113
|
+
// `$item() ` accessor-wrapping in the emitted client JS. Before the fix,
|
|
114
|
+
// `referencesLoopParam` / `wrapLoopParamAsAccessor`'s `\b$item\b` pattern
|
|
115
|
+
// never matched, so the loop body silently lost slotId/reactive
|
|
116
|
+
// classification for the `$item`-named param (wrong-but-silent: `bun test`
|
|
117
|
+
// still passed because nothing asserted the accessor wrap for a `$`-param
|
|
118
|
+
// until now). Verified red-without/green-with: reverting `identifier-
|
|
119
|
+
// pattern.ts` to a plain `` new RegExp(`\\b${name}\\b`) ``-style
|
|
120
|
+
// implementation fails this describe block while leaving the plain-`item`
|
|
121
|
+
// sibling test green — see PR description for the local repro.
|
|
122
|
+
// -----------------------------------------------------------------------------
|
|
123
|
+
describe('$-prefixed loop param compiles identically to a plain-named one (#2592)', () => {
|
|
124
|
+
const adapter = new TestAdapter()
|
|
125
|
+
|
|
126
|
+
function compileMapBody(param: string): string {
|
|
127
|
+
const source = `
|
|
128
|
+
'use client'
|
|
129
|
+
import { createSignal } from '@barefootjs/client'
|
|
130
|
+
type Row = { id: number; label: string }
|
|
131
|
+
export function List() {
|
|
132
|
+
const [rows] = createSignal<Row[]>([])
|
|
133
|
+
return (
|
|
134
|
+
<ul>
|
|
135
|
+
{rows().map((${param}) => (
|
|
136
|
+
<Card key={${param}.id}>
|
|
137
|
+
<CardHeader>{${param}.label}</CardHeader>
|
|
138
|
+
</Card>
|
|
139
|
+
))}
|
|
140
|
+
</ul>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
`
|
|
144
|
+
const result = compileJSX(source, 'List.tsx', { adapter })
|
|
145
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
146
|
+
if (errors.length > 0) throw new Error(errors.map(e => e.message).join('\n'))
|
|
147
|
+
return result.files.find(f => f.type === 'clientJs')!.content
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
test('a plain `item` param is wrapped as an accessor in the child component prop', () => {
|
|
151
|
+
const js = compileMapBody('item')
|
|
152
|
+
expect(js).toContain('item().label')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('a `$item` param is wrapped as an accessor identically (was previously left bare)', () => {
|
|
156
|
+
const js = compileMapBody('$item')
|
|
157
|
+
expect(js).toContain('$item().label')
|
|
158
|
+
expect(js).toContain('$item().id')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('flags handling', () => {
|
|
163
|
+
test("passing flags that already include 'u' does not throw (no duplicate flag)", () => {
|
|
164
|
+
const p = identifierPattern('item', 'gu')
|
|
165
|
+
expect(p.flags).toBe('gu')
|
|
166
|
+
const c = identifierCallPattern('item', 'u')
|
|
167
|
+
expect(c.flags).toBe('u')
|
|
168
|
+
expect('a item b item'.replace(identifierPattern('item', 'gu'), 'x')).toBe('a x b x')
|
|
169
|
+
})
|
|
170
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2482 Stage 1b — `prop-handling.ts`'s `expandConstantForReactivity` /
|
|
3
|
+
* `expandDynamicPropValue` run on `ClientJsContext`, a per-component object
|
|
4
|
+
* with no loop-scope field at all. Both do a flat
|
|
5
|
+
* `ctx.localConstants.find((c) => c.name === trimmedValue)` lookup with no
|
|
6
|
+
* awareness that the expression being expanded might be sitting inside a
|
|
7
|
+
* `.map()` row whose OWN item/index/destructured/preamble binding shadows
|
|
8
|
+
* that same-named module/component-level const — the lookup resolves to
|
|
9
|
+
* the OUTER const's value regardless.
|
|
10
|
+
*
|
|
11
|
+
* This test pins the reachable, currently-shipping instance: a per-item
|
|
12
|
+
* reactive DOM attribute (forced via `/* @client *\/`, so it bypasses the
|
|
13
|
+
* loop-param-only reactivity classifier and always reaches
|
|
14
|
+
* `collectLoopChildReactiveAttrs` → `expandConstantForReactivity`) whose
|
|
15
|
+
* value is a bare reference to the loop's OWN item parameter, which
|
|
16
|
+
* shares a name with a module-level const. Before the fix, the per-item
|
|
17
|
+
* `createEffect`-equivalent (`applyItem`/`createRow` in the lazy-row
|
|
18
|
+
* runtime) baked in the outer const's fixed literal for every row instead
|
|
19
|
+
* of reading the row's own item value — every row rendered the SAME
|
|
20
|
+
* `data-label`, and it never diverged from that one baked value.
|
|
21
|
+
*
|
|
22
|
+
* Modeled on `csr-template-loop-shadowing.test.ts` (the sibling shadowing
|
|
23
|
+
* fix for the CSR *template* lambda, #2222) — this is the analogous fix
|
|
24
|
+
* for the per-item *reactive attribute effect* body, a different codegen
|
|
25
|
+
* path (`reactivity.ts`'s `collectLoopChildReactiveAttrs`, not
|
|
26
|
+
* `html-template.ts`).
|
|
27
|
+
*
|
|
28
|
+
* A second describe block below pins the INDEX-param shadowing case
|
|
29
|
+
* (Copilot review on PR #2595): `buildLoopRowScope` initially omitted
|
|
30
|
+
* the loop's second callback param (`.map((item, i) => ...)`'s `i`) from
|
|
31
|
+
* the `BindingScope` it builds, so an `i`-shadows-a-module-const attr
|
|
32
|
+
* const-folded the outer value exactly like the item-param case above,
|
|
33
|
+
* empirically confirmed reachable through the same
|
|
34
|
+
* `collectLoopChildReactiveAttrs` → `expandConstantForReactivity` path.
|
|
35
|
+
* Fixed by threading the IR loop's `index` field through
|
|
36
|
+
* `collectLoopChildBindings` / `collectLoopChildConditionals` /
|
|
37
|
+
* `summarizeLoopChildBranch` / `collectLoopChildReactiveAttrs` /
|
|
38
|
+
* `collectLoopChildReactiveTexts` into `buildLoopRowScope`.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { describe, test, expect } from 'bun:test'
|
|
42
|
+
import { compileJSX } from '../compiler'
|
|
43
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
44
|
+
|
|
45
|
+
const adapter = new TestAdapter()
|
|
46
|
+
|
|
47
|
+
function clientJsFor(source: string): string {
|
|
48
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
49
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
50
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
51
|
+
expect(clientJs).toBeDefined()
|
|
52
|
+
return clientJs!.content
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('loop-child reactive attr vs a loop-param shadowing a module const (#2482)', () => {
|
|
56
|
+
test('a /* @client */ attr reading the shadowing item param reads the row value, not the outer const', () => {
|
|
57
|
+
const js = clientJsFor(`
|
|
58
|
+
'use client'
|
|
59
|
+
import { createSignal } from '@barefootjs/client'
|
|
60
|
+
export function Widget({ items }: { items: string[] }) {
|
|
61
|
+
const label = 'MODULE_CONST'
|
|
62
|
+
const [n, setN] = createSignal(0)
|
|
63
|
+
return (
|
|
64
|
+
<div onClick={() => setN(n() + 1)}>
|
|
65
|
+
<ul>
|
|
66
|
+
{items.map((label) => (
|
|
67
|
+
<li key={label} data-label={/* @client */ label}>{n()}</li>
|
|
68
|
+
))}
|
|
69
|
+
</ul>
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
`)
|
|
74
|
+
|
|
75
|
+
// The per-item attribute effect must read the row's OWN item
|
|
76
|
+
// accessor — never the outer module const's baked literal.
|
|
77
|
+
expect(js).toContain('const __x = label()')
|
|
78
|
+
expect(js).not.toContain("const __x = 'MODULE_CONST'")
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('loop-child reactive attr vs a loop INDEX param shadowing a module const (#2482 / #2595)', () => {
|
|
83
|
+
test('a /* @client */ attr reading the shadowing index param reads the row index, not the outer const', () => {
|
|
84
|
+
const js = clientJsFor(`
|
|
85
|
+
'use client'
|
|
86
|
+
import { createSignal } from '@barefootjs/client'
|
|
87
|
+
export function Widget({ items }: { items: string[] }) {
|
|
88
|
+
const i = 'MODULE_CONST'
|
|
89
|
+
const [n, setN] = createSignal(0)
|
|
90
|
+
return (
|
|
91
|
+
<div onClick={() => setN(n() + 1)}>
|
|
92
|
+
<ul>
|
|
93
|
+
{items.map((item, i) => (
|
|
94
|
+
<li key={item} data-idx={/* @client */ i}>{n()}</li>
|
|
95
|
+
))}
|
|
96
|
+
</ul>
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
`)
|
|
101
|
+
|
|
102
|
+
// The per-item attribute effect must read the row's OWN index
|
|
103
|
+
// closure variable — never the outer module const's baked literal.
|
|
104
|
+
expect(js).toContain("const __v = i;")
|
|
105
|
+
expect(js).not.toContain("const __v = 'MODULE_CONST';")
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -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
|
+
})
|