@barefootjs/jsx 0.35.0 → 0.35.1
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/analyzer-context.d.ts.map +1 -1
- package/dist/index.js +731 -691
- package/dist/ir-to-client-js/collect-elements.d.ts +22 -2
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts +14 -2
- package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
- 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-loop-child-arm.d.ts +6 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +3 -2
- 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/inner-loop.d.ts +24 -44
- package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +14 -0
- package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +6 -14
- package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts +1 -3
- package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +19 -3
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +5 -1
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts +54 -0
- package/dist/ir-to-client-js/emit-reactive.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 +62 -18
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +18 -2
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/reactivity-checker.d.ts +18 -0
- package/dist/reactivity-checker.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +128 -10
- package/src/__tests__/binding-scope-ratchet.test.ts +25 -2
- package/src/__tests__/child-prop-fallback-wrap.test.ts +4 -2
- package/src/__tests__/compiler-stress-1244.test.ts +8 -8
- package/src/__tests__/issue-2868-cond-arm-tag-collision.test.ts +185 -0
- package/src/__tests__/issue-2869-attr-dedup-guard.test.ts +141 -0
- package/src/__tests__/loop-index-reactivity.test.ts +208 -0
- package/src/__tests__/map-body-brand-checker.test.ts +176 -0
- package/src/__tests__/nested-loop-index-param.test.ts +27 -5
- package/src/__tests__/nested-loop-non-outer-reactivity.test.ts +177 -0
- package/src/__tests__/nested-loop-plain.test.ts +39 -25
- package/src/__tests__/nested-loop-reactive-attrs.test.ts +12 -4
- package/src/__tests__/preamble-branch-arm-rerun.test.ts +135 -0
- package/src/__tests__/style-css-var-reactive.test.ts +4 -1
- package/src/analyzer-context.ts +12 -10
- package/src/ir-to-client-js/collect-elements.ts +110 -32
- package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -0
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +28 -51
- package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +7 -2
- package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +33 -6
- package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +7 -1
- package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +18 -6
- package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +24 -45
- package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +14 -0
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +20 -66
- package/src/ir-to-client-js/control-flow/stringify/insert.ts +4 -2
- package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +12 -15
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +53 -11
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +4 -2
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +29 -18
- package/src/ir-to-client-js/emit-reactive.ts +88 -5
- package/src/ir-to-client-js/html-template.ts +9 -1
- package/src/ir-to-client-js/reactivity.ts +73 -27
- package/src/ir-to-client-js/types.ts +19 -3
- package/src/jsx-to-ir.ts +21 -4
- package/src/reactivity-checker.ts +60 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2868: a reactive conditional branch's own re-render `template()` builder
|
|
3
|
+
* could corrupt a tag name that collides with an enclosing `.map()`'s
|
|
4
|
+
* item/index parameter — `<i>` became `<i()>` — because the loop-param
|
|
5
|
+
* accessor rewrite ran as a word-boundary regex over the branch's already-
|
|
6
|
+
* assembled HTML string, and `<`/`>` are non-word characters so `<i>` and
|
|
7
|
+
* `<i` + identifier `i` are indistinguishable to that regex.
|
|
8
|
+
*
|
|
9
|
+
* Fixed by rendering every branch's HTML against the full loop-param chain
|
|
10
|
+
* at IR time (`irToHtmlTemplate`'s `loopParams`) and deleting the post-hoc
|
|
11
|
+
* regex pass entirely — `wrapLoopParamAsAccessor` only ever runs over
|
|
12
|
+
* parsed JS expression positions now, never over assembled markup.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, test, expect } from 'bun:test'
|
|
16
|
+
import { compileJSX } from '../compiler'
|
|
17
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
18
|
+
|
|
19
|
+
function compile(source: string): string {
|
|
20
|
+
const r = compileJSX(source, 'T.tsx', { adapter: new TestAdapter() })
|
|
21
|
+
expect(r.errors).toEqual([])
|
|
22
|
+
return r.files.find((f) => f.type === 'clientJs')?.content ?? ''
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** No tag name (or attribute) was corrupted into an accessor call. */
|
|
26
|
+
function expectNoCorruption(clientJs: string): void {
|
|
27
|
+
expect(clientJs).not.toMatch(/<\/?[A-Za-z][\w-]*\(\)/)
|
|
28
|
+
expect(clientJs).not.toMatch(/[\w-]+\(\)=["']/)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('reactive conditional branch does not corrupt a colliding tag name (#2868)', () => {
|
|
32
|
+
test('index param named like the branch tag (<i>) — eager row (forced via ref)', () => {
|
|
33
|
+
const clientJs = compile(`
|
|
34
|
+
'use client'
|
|
35
|
+
import { createSignal } from '@barefootjs/client'
|
|
36
|
+
function T() {
|
|
37
|
+
const [items] = createSignal([{ id: 'a' }, { id: 'b' }])
|
|
38
|
+
const [flag] = createSignal(true)
|
|
39
|
+
return (
|
|
40
|
+
<ul>
|
|
41
|
+
{items().map((item, i) => (
|
|
42
|
+
<li key={item.id} ref={(el: HTMLElement | null) => {}}>
|
|
43
|
+
{flag() ? <b>even</b> : <i>odd</i>}
|
|
44
|
+
</li>
|
|
45
|
+
))}
|
|
46
|
+
</ul>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
export { T }`)
|
|
50
|
+
expectNoCorruption(clientJs)
|
|
51
|
+
expect(clientJs).toContain('<i bf-c="s0">odd</i>')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('item param named like the branch tag (<b>)', () => {
|
|
55
|
+
const clientJs = compile(`
|
|
56
|
+
'use client'
|
|
57
|
+
import { createSignal } from '@barefootjs/client'
|
|
58
|
+
function T() {
|
|
59
|
+
const [items] = createSignal([{ id: 'a' }])
|
|
60
|
+
const [flag] = createSignal(true)
|
|
61
|
+
return (
|
|
62
|
+
<ul>
|
|
63
|
+
{items().map((b) => (
|
|
64
|
+
<li key={b.id} ref={(el: HTMLElement | null) => {}}>
|
|
65
|
+
{flag() ? <b>even</b> : <em>odd</em>}
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
export { T }`)
|
|
72
|
+
expectNoCorruption(clientJs)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('nested conditional inside a branch arm', () => {
|
|
76
|
+
const clientJs = compile(`
|
|
77
|
+
'use client'
|
|
78
|
+
import { createSignal } from '@barefootjs/client'
|
|
79
|
+
function T() {
|
|
80
|
+
const [items] = createSignal([{ id: 'a' }])
|
|
81
|
+
const [outer, setOuter] = createSignal(true)
|
|
82
|
+
const [inner] = createSignal(true)
|
|
83
|
+
return (
|
|
84
|
+
<ul>
|
|
85
|
+
{items().map((item, i) => (
|
|
86
|
+
<li key={item.id} ref={(el: HTMLElement | null) => {}}>
|
|
87
|
+
{outer() ? (inner() ? <b>x</b> : <i>y</i>) : <em>z</em>}
|
|
88
|
+
</li>
|
|
89
|
+
))}
|
|
90
|
+
</ul>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
export { T }`)
|
|
94
|
+
expectNoCorruption(clientJs)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('row template (build-plain-row): index-named tag as the row root, no ref forcing eager', () => {
|
|
98
|
+
const clientJs = compile(`
|
|
99
|
+
'use client'
|
|
100
|
+
import { createSignal } from '@barefootjs/client'
|
|
101
|
+
function T() {
|
|
102
|
+
const [items] = createSignal([{ id: 'a' }])
|
|
103
|
+
return (
|
|
104
|
+
<ul>
|
|
105
|
+
{items().map((item, i) => (
|
|
106
|
+
<li key={item.id} ref={(el: HTMLElement | null) => {}}>
|
|
107
|
+
<i>{i}</i>
|
|
108
|
+
</li>
|
|
109
|
+
))}
|
|
110
|
+
</ul>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
export { T }`)
|
|
114
|
+
expectNoCorruption(clientJs)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('child component prop inside a reactive conditional branch reads the loop item live (#2868 follow-up)', () => {
|
|
118
|
+
// `irToHtmlTemplate`'s `case 'component':` branch never ran the prop
|
|
119
|
+
// expression through `wrapExpr` — a pre-existing gap that #2868's own
|
|
120
|
+
// fix exposed by deleting the post-hoc `wrapLoopParamAsAccessor` regex
|
|
121
|
+
// pass that had accidentally been fixing it too. Caught by a real
|
|
122
|
+
// `site/ui` component (`DashboardBuilderDemo`), not by any fixture in
|
|
123
|
+
// #2868's own PR.
|
|
124
|
+
const clientJs = compile(`
|
|
125
|
+
'use client'
|
|
126
|
+
import { createSignal } from '@barefootjs/client'
|
|
127
|
+
function Badge(props: { text: string }) {
|
|
128
|
+
return <span>{props.text}</span>
|
|
129
|
+
}
|
|
130
|
+
function T() {
|
|
131
|
+
const [items] = createSignal([{ id: 'a', active: true, label: 'A' }])
|
|
132
|
+
return (
|
|
133
|
+
<ul>
|
|
134
|
+
{items().map((item, i) => (
|
|
135
|
+
<li key={item.id} ref={(el: HTMLElement | null) => {}}>
|
|
136
|
+
{item.active ? <Badge text={item.label} /> : null}
|
|
137
|
+
</li>
|
|
138
|
+
))}
|
|
139
|
+
</ul>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
export { T, Badge }`)
|
|
143
|
+
expectNoCorruption(clientJs)
|
|
144
|
+
// The branch's re-render template (the `insert(...)` reactive-update
|
|
145
|
+
// path, NOT the outer static SSR template built once from the literal
|
|
146
|
+
// array) must read the row's live item accessor (`item()`), not the
|
|
147
|
+
// bare loop param (`item`) — a bare reference would stringify to the
|
|
148
|
+
// destructured item at row-creation time and never update after that.
|
|
149
|
+
const insertCall = clientJs.slice(clientJs.indexOf('insert(__el'), clientJs.indexOf("}, 'l0')"))
|
|
150
|
+
expect(insertCall).toContain("renderChild('Badge', {text: item().label}")
|
|
151
|
+
expect(insertCall).not.toContain("renderChild('Badge', {text: item.label}")
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test("nested .map() conditional arm reads the OUTER loop's own index (ancestor chain)", () => {
|
|
155
|
+
const clientJs = compile(`
|
|
156
|
+
'use client'
|
|
157
|
+
import { createSignal } from '@barefootjs/client'
|
|
158
|
+
function T() {
|
|
159
|
+
const [groups] = createSignal([{ id: 'g1', kids: [{ id: 'k1' }] }])
|
|
160
|
+
const [flag] = createSignal(true)
|
|
161
|
+
return (
|
|
162
|
+
<ul>
|
|
163
|
+
{groups().map((group, gi) => (
|
|
164
|
+
<li key={group.id}>
|
|
165
|
+
<ul>
|
|
166
|
+
{group.kids.map((kid) => (
|
|
167
|
+
<li key={kid.id}>
|
|
168
|
+
{flag() ? <b>{gi}</b> : <em>none</em>}
|
|
169
|
+
</li>
|
|
170
|
+
))}
|
|
171
|
+
</ul>
|
|
172
|
+
</li>
|
|
173
|
+
))}
|
|
174
|
+
</ul>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
export { T }`)
|
|
178
|
+
expectNoCorruption(clientJs)
|
|
179
|
+
// The outer index must be called as an accessor inside the inner loop's
|
|
180
|
+
// branch arm, not passed by reference — passing the bare function would
|
|
181
|
+
// stringify to its source text instead of its current value.
|
|
182
|
+
expect(clientJs).toContain('gi()')
|
|
183
|
+
expect(clientJs).not.toMatch(/__bfSlot\(gi,/)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codegen-shape pins for #2869: every eager reactive-attribute write must go
|
|
3
|
+
* through `emitDedupedAttrUpdate` (`emit-reactive.ts`), never `emitAttrUpdate`
|
|
4
|
+
* directly from an effect body. These are cheap shape assertions alongside
|
|
5
|
+
* the behavioral regression test in
|
|
6
|
+
* `packages/client/__tests__/runtime/issue-2869-attr-dedup-guard.test.ts`,
|
|
7
|
+
* which is the one that actually proves the fix (this file cannot observe
|
|
8
|
+
* write counts over time, only the emitted source shape).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, test, expect } from 'bun:test'
|
|
12
|
+
import { compileJSX } from '../compiler'
|
|
13
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
14
|
+
|
|
15
|
+
const adapter = new TestAdapter()
|
|
16
|
+
|
|
17
|
+
function clientJsFor(source: string, options: Parameters<typeof compileJSX>[2] = { adapter }): string {
|
|
18
|
+
const result = compileJSX(source, 'Repro.tsx', options)
|
|
19
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
20
|
+
expect(errors).toHaveLength(0)
|
|
21
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')?.content
|
|
22
|
+
if (!clientJs) throw new Error('No client JS emitted')
|
|
23
|
+
return clientJs
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Non-loop per-slot reactive attrs (`emitReactiveAttributeUpdates`) — two
|
|
27
|
+
// attrs sharing one element, one of them wrapped in `untrack`.
|
|
28
|
+
const NON_LOOP_SOURCE = `'use client'
|
|
29
|
+
import { createSignal, untrack } from '@barefootjs/client'
|
|
30
|
+
|
|
31
|
+
function docFor(n: number): string {
|
|
32
|
+
return 'doc-' + n
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function Repro() {
|
|
36
|
+
const [count, setCount] = createSignal(0)
|
|
37
|
+
const [step, setStep] = createSignal(1)
|
|
38
|
+
return (
|
|
39
|
+
<div>
|
|
40
|
+
<button class="count" onClick={() => setCount(c => c + 1)}>count</button>
|
|
41
|
+
<button class="step" onClick={() => setStep(s => s + 1)}>step</button>
|
|
42
|
+
<p class="target" title={String(count())} data-doc={untrack(() => docFor(step()))}>x</p>
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
`
|
|
47
|
+
|
|
48
|
+
// Keyed \`.map()\` row whose bindings share one fused row effect
|
|
49
|
+
// (\`emitConsolidatedRowEffect\`). The loop source is a module-level function
|
|
50
|
+
// call (not a signal/prop/literal), so \`lazy-row-eligibility.ts\`'s §9.3(2)
|
|
51
|
+
// hydration-consistency gate refuses lazy adoption and the loop takes the
|
|
52
|
+
// eager \`stringifyPlainLoop\` -> \`emitConsolidatedRowEffect\` path — the
|
|
53
|
+
// primary site #2869 reports. Asserted below (\`mapArray(\` present,
|
|
54
|
+
// \`mapArrayLazy(\` absent) so a future eligibility widening fails this test
|
|
55
|
+
// loudly instead of silently moving the repro onto the already-guarded path.
|
|
56
|
+
const LOOP_SOURCE = `'use client'
|
|
57
|
+
import { createSignal, untrack } from '@barefootjs/client'
|
|
58
|
+
|
|
59
|
+
function seedItems() {
|
|
60
|
+
return [{ id: 1, label: 'a' }, { id: 2, label: 'b' }]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function docFor(id: number): string {
|
|
64
|
+
return 'doc-' + id
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function Repro() {
|
|
68
|
+
const [items, setItems] = createSignal(seedItems())
|
|
69
|
+
return (
|
|
70
|
+
<div>
|
|
71
|
+
<button onClick={() => setItems(items().map(it => ({ id: it.id, label: it.label + '!' })))}>bump</button>
|
|
72
|
+
<ul>
|
|
73
|
+
{items().map(item => (
|
|
74
|
+
<li key={item.id} title={item.label} data-doc={untrack(() => docFor(item.id))}>{item.label}</li>
|
|
75
|
+
))}
|
|
76
|
+
</ul>
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
`
|
|
81
|
+
|
|
82
|
+
describe('#2869 — every eager reactive-attr write goes through emitDedupedAttrUpdate', () => {
|
|
83
|
+
test('non-loop reactive attrs: one shared __l store, sequential ordinals, guarded writes', () => {
|
|
84
|
+
const clientJs = clientJsFor(NON_LOOP_SOURCE)
|
|
85
|
+
|
|
86
|
+
expect(clientJs).toContain('const __l = []')
|
|
87
|
+
expect(clientJs).toContain('!(0 in __l) || !Object.is(__l[0], __x)')
|
|
88
|
+
expect(clientJs).toContain('!(1 in __l) || !Object.is(__l[1], __x)')
|
|
89
|
+
expect(clientJs).toContain('__l[0] = __x')
|
|
90
|
+
expect(clientJs).toContain('__l[1] = __x')
|
|
91
|
+
|
|
92
|
+
// Exactly one store for this element's effect — not one per attr.
|
|
93
|
+
const storeCount = clientJs.split('const __l = []').length - 1
|
|
94
|
+
expect(storeCount).toBe(1)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('fused row effect (emitConsolidatedRowEffect): eager mapArray path, guarded per binding', () => {
|
|
98
|
+
const clientJs = clientJsFor(LOOP_SOURCE)
|
|
99
|
+
|
|
100
|
+
// Pin the eager path — the whole point of this fixture is exercising
|
|
101
|
+
// `emitConsolidatedRowEffect`, not the already-guarded lazy row graph.
|
|
102
|
+
expect(clientJs).toContain('mapArray(')
|
|
103
|
+
expect(clientJs).not.toContain('mapArrayLazy(')
|
|
104
|
+
|
|
105
|
+
expect(clientJs).toContain('const __l = []')
|
|
106
|
+
expect(clientJs).toContain('!(0 in __l) || !Object.is(__l[0], __x)')
|
|
107
|
+
expect(clientJs).toContain('!(1 in __l) || !Object.is(__l[1], __x)')
|
|
108
|
+
|
|
109
|
+
const storeCount = clientJs.split('const __l = []').length - 1
|
|
110
|
+
expect(storeCount).toBe(1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('profile mode keeps one createEffect per attr, still guarded', () => {
|
|
114
|
+
const clientJs = clientJsFor(LOOP_SOURCE, { adapter, profile: true })
|
|
115
|
+
|
|
116
|
+
// Granularity preserved: two attrs on the row's slot -> two createEffect
|
|
117
|
+
// calls (module docstring in reactive-effects.ts explains why profile
|
|
118
|
+
// mode cannot consolidate — the profiler needs one bfId per binding).
|
|
119
|
+
const effectCount = clientJs.split('createEffect(() => {').length - 1
|
|
120
|
+
expect(effectCount).toBeGreaterThanOrEqual(2)
|
|
121
|
+
expect(clientJs).toContain('const __l = []')
|
|
122
|
+
expect(clientJs).toContain('!(0 in __l) || !Object.is(__l[0], __x)')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('emitAttrUpdate is never called directly from an effect-body emitter outside emit-reactive.ts', () => {
|
|
126
|
+
// Shrink-only ledger, same idea as binding-scope-ratchet.test.ts: every
|
|
127
|
+
// eager call site must go through emitDedupedAttrUpdate. lazy-row.ts's
|
|
128
|
+
// own dedup mechanism is unified onto the same helper (#2869), so this
|
|
129
|
+
// is a plain, unconditional invariant now — no allowlist needed.
|
|
130
|
+
const glob = new Bun.Glob('**/*.ts')
|
|
131
|
+
const root = new URL('../ir-to-client-js/', import.meta.url).pathname
|
|
132
|
+
const offenders: string[] = []
|
|
133
|
+
for (const file of glob.scanSync({ cwd: root })) {
|
|
134
|
+
if (file.endsWith('emit-reactive.ts')) continue
|
|
135
|
+
if (file.includes('__tests__')) continue
|
|
136
|
+
const text = require('node:fs').readFileSync(`${root}${file}`, 'utf8')
|
|
137
|
+
if (/\bemitAttrUpdate\(/.test(text)) offenders.push(file)
|
|
138
|
+
}
|
|
139
|
+
expect(offenders).toEqual([])
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2861: a `.map()` row's index-derived output stays live even when the
|
|
3
|
+
* expression reads NOTHING else reactive — no item, no signal, no memo, no
|
|
4
|
+
* function call. `String(i + 1)`-shaped expressions were already fixed by
|
|
5
|
+
* #2859/#2860 (they trip the AST-flag function-call fallback); this file
|
|
6
|
+
* covers the residual gap `classifyReactivity` had no case for at all: a
|
|
7
|
+
* bare `{i}` text, an `i % 2 === 0 ? … : …` class/conditional, and the
|
|
8
|
+
* same shapes one level deeper when the index in question belongs to an
|
|
9
|
+
* OUTER loop, not the row's own.
|
|
10
|
+
*
|
|
11
|
+
* Root cause (see `reactivity.ts`'s `ReactivitySource`/`classifyReactivity`
|
|
12
|
+
* doc comments): Phase 1 (`jsx-to-ir.ts`'s `referencesLoopParam`, via
|
|
13
|
+
* `BindingScope.valueBoundNames()`) already grants such an expression a
|
|
14
|
+
* patchable slot — item/index/destructure are graneted a slot together —
|
|
15
|
+
* but Phase 2's `classifyReactivity` had no `loop-index` case mirroring its
|
|
16
|
+
* existing `loop-param` one, so the slot was written once at row-creation
|
|
17
|
+
* time and never revisited.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, test, expect } from 'bun:test'
|
|
21
|
+
import { compileJSX } from '../compiler'
|
|
22
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
23
|
+
|
|
24
|
+
const adapter = new TestAdapter()
|
|
25
|
+
|
|
26
|
+
function clientJsFor(source: string): string {
|
|
27
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
28
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
29
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
30
|
+
expect(clientJs).toBeDefined()
|
|
31
|
+
return clientJs!.content
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('plain loop: pure index-only expressions (#2861)', () => {
|
|
35
|
+
test('bare {i} text wires reactively through the lazy row graph', () => {
|
|
36
|
+
const content = clientJsFor(`
|
|
37
|
+
'use client'
|
|
38
|
+
import { createSignal } from '@barefootjs/client'
|
|
39
|
+
export function Repro() {
|
|
40
|
+
const [items] = createSignal([{ id: 'a' }, { id: 'b' }, { id: 'c' }])
|
|
41
|
+
return (
|
|
42
|
+
<ul>
|
|
43
|
+
{items().map((item, i) => (
|
|
44
|
+
<li key={item.id}>{i}</li>
|
|
45
|
+
))}
|
|
46
|
+
</ul>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
`)
|
|
50
|
+
|
|
51
|
+
// No refs/child components/inner loops — this row is lazy-eligible, and
|
|
52
|
+
// the widened `classifyLazyBinding` (#2859/#2860 stacked work) already
|
|
53
|
+
// routes an index-only binding into applyItem/indexDriven once it's
|
|
54
|
+
// collected at all — which is exactly the part #2861 fixes.
|
|
55
|
+
expect(content).toContain('mapArrayLazy(')
|
|
56
|
+
expect(content).toContain('indexDriven: true')
|
|
57
|
+
expect(content).toMatch(/applyItem: \(__e\) => \{[\s\S]*?const i = __e\.index/)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('class={i % 2 === 0 ? … : …} wires a createEffect on the eager path', () => {
|
|
61
|
+
const content = clientJsFor(`
|
|
62
|
+
'use client'
|
|
63
|
+
import { createSignal } from '@barefootjs/client'
|
|
64
|
+
export function Repro() {
|
|
65
|
+
const [items] = createSignal([{ id: 'a' }, { id: 'b' }])
|
|
66
|
+
return (
|
|
67
|
+
<ul>
|
|
68
|
+
{items().map((item, i) => (
|
|
69
|
+
<li key={item.id} class={i % 2 === 0 ? 'even' : 'odd'} ref={(el: HTMLElement | null) => {}}>{item.id}</li>
|
|
70
|
+
))}
|
|
71
|
+
</ul>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
`)
|
|
75
|
+
|
|
76
|
+
// A ref forces the eager `mapArray` path (no lazy row graph for rows
|
|
77
|
+
// that own an imperative ref) — the class binding must still be wired
|
|
78
|
+
// through a per-item `createEffect` reading the index ACCESSOR (#2859).
|
|
79
|
+
// The expression is read into `__x` first (#2869's dedup-guard shape,
|
|
80
|
+
// `emitDedupedAttrUpdate`) before the write reads it back as `__v`.
|
|
81
|
+
expect(content).toContain('mapArray(')
|
|
82
|
+
expect(content).toMatch(
|
|
83
|
+
/createEffect\(\(\) => \{[\s\S]*?const __x = `\$\{i\(\) % 2 === 0 \? 'even' : 'odd'\}`[\s\S]*?const __v = __x;/
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('an index-only conditional ({i % 2 === 0 ? <b/> : <em/>}) reconciles reactively on the lazy path', () => {
|
|
88
|
+
const content = clientJsFor(`
|
|
89
|
+
'use client'
|
|
90
|
+
import { createSignal } from '@barefootjs/client'
|
|
91
|
+
export function Repro() {
|
|
92
|
+
const [items] = createSignal([{ id: 'a' }, { id: 'b' }])
|
|
93
|
+
return (
|
|
94
|
+
<ul>
|
|
95
|
+
{items().map((item, i) => (
|
|
96
|
+
<li key={item.id}>{i % 2 === 0 ? <b>even</b> : <em>odd</em>}</li>
|
|
97
|
+
))}
|
|
98
|
+
</ul>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
`)
|
|
102
|
+
|
|
103
|
+
// Before #2861 this pre-gate (`collectLoopChildConditionals`'s
|
|
104
|
+
// `refsAnyBindingViaFreeIds`) never even reached `classifyReactivity` —
|
|
105
|
+
// an index-only condition died at the AST-`reactive`-flag short-circuit
|
|
106
|
+
// and baked into `createRow` only, once, forever.
|
|
107
|
+
expect(content).toContain('mapArrayLazy(')
|
|
108
|
+
expect(content).toMatch(/applyItem: \(__e\) => \{[\s\S]*?const i = __e\.index/)
|
|
109
|
+
expect(content).toContain('!!(i % 2 === 0)')
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('nested loop: referencing an OUTER loop\'s own index (#2861)', () => {
|
|
114
|
+
test('composite path: inner row text reads the outer index through its accessor', () => {
|
|
115
|
+
const content = clientJsFor(`
|
|
116
|
+
'use client'
|
|
117
|
+
import { createSignal } from '@barefootjs/client'
|
|
118
|
+
interface Item { id: string; label: string }
|
|
119
|
+
interface Group { id: string; items: Item[] }
|
|
120
|
+
export function Repro() {
|
|
121
|
+
const [groups] = createSignal<Group[]>([])
|
|
122
|
+
return (
|
|
123
|
+
<div>
|
|
124
|
+
{groups().map((g, gi) => (
|
|
125
|
+
<div key={g.id}>
|
|
126
|
+
{g.items.map((it) => (
|
|
127
|
+
<span key={it.id}>{gi}-{it.label}</span>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
`)
|
|
135
|
+
|
|
136
|
+
// The inner mapArray's own renderItem has no `gi` param at all — the
|
|
137
|
+
// outer accessor must be read directly by name, exactly as `item()`
|
|
138
|
+
// reads the outer ITEM in the sibling case #2865 already covers.
|
|
139
|
+
expect(content).toMatch(
|
|
140
|
+
/mapArray\(\(\) => g\(\)\.items \|\| \[\], .*?, \(it, __innerIdx\d+_\d+, __existing\) => \{[\s\S]*?createEffect\(\(\) => \{ __bfw_s0\('s0', String\(gi\(\)\)\) \}\)/
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('composite path: inner row class attr reads the outer index through its accessor', () => {
|
|
145
|
+
const content = clientJsFor(`
|
|
146
|
+
'use client'
|
|
147
|
+
import { createSignal } from '@barefootjs/client'
|
|
148
|
+
interface Item { id: string }
|
|
149
|
+
interface Group { id: string; items: Item[] }
|
|
150
|
+
export function Repro() {
|
|
151
|
+
const [groups] = createSignal<Group[]>([])
|
|
152
|
+
return (
|
|
153
|
+
<div>
|
|
154
|
+
{groups().map((g, gi) => (
|
|
155
|
+
<div key={g.id}>
|
|
156
|
+
{g.items.map((it) => (
|
|
157
|
+
<span key={it.id} class={gi % 2 === 0 ? 'even' : 'odd'}>{it.id}</span>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
`)
|
|
165
|
+
|
|
166
|
+
// The expression is read into `__x` first (#2869's dedup-guard shape,
|
|
167
|
+
// `emitDedupedAttrUpdate`) before the write reads it back as `__v`.
|
|
168
|
+
expect(content).toMatch(
|
|
169
|
+
/createEffect\(\(\) => \{[\s\S]*?const __x = `\$\{gi\(\) % 2 === 0 \? 'even' : 'odd'\}`[\s\S]*?const __v = __x;/
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('conditional-branch-arm path: a loop nested inside a branch reads the OUTER (pre-branch) loop\'s index', () => {
|
|
174
|
+
const content = clientJsFor(`
|
|
175
|
+
'use client'
|
|
176
|
+
import { createSignal } from '@barefootjs/client'
|
|
177
|
+
interface Item { id: string; text: string }
|
|
178
|
+
interface Group { id: string; flag: boolean; items: Item[] }
|
|
179
|
+
export function Repro() {
|
|
180
|
+
const [outer] = createSignal<Group[]>([])
|
|
181
|
+
return (
|
|
182
|
+
<div>
|
|
183
|
+
{outer().map((o, oi) => (
|
|
184
|
+
<div key={o.id}>
|
|
185
|
+
{o.flag ? (
|
|
186
|
+
<div>
|
|
187
|
+
{o.items.map((item) => (
|
|
188
|
+
<span key={item.id}>{oi}: {item.text}</span>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
) : null}
|
|
192
|
+
</div>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
`)
|
|
198
|
+
|
|
199
|
+
// Before #2861, `collectInnerLoops` (called from `summarizeLoopChildBranch`
|
|
200
|
+
// for a loop nested inside a conditional arm) only ever built a scope
|
|
201
|
+
// from the INNER loop's own param/index — a row referencing the loop
|
|
202
|
+
// ONE LEVEL UP (the one whose conditional this branch belongs to) was
|
|
203
|
+
// classified 'none' and baked once into the branch's initial template.
|
|
204
|
+
expect(content).toMatch(
|
|
205
|
+
/mapArray\(\(\) => o\(\)\.items \|\| \[\], .*?, \(item, __bidxbr_\d+, __existing\) => \{[\s\S]*?createEffect\(\(\) => \{ __bfw_s1\('s1', String\(oi\(\)\)\) \}\)/
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
})
|