@barefootjs/jsx 0.34.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/analyzer.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1072 -903
- 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-branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-component-loop.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 +11 -0
- 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-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts +52 -0
- package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +5 -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/lazy-conditional.d.ts +6 -7
- package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +5 -7
- package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/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/plan/loop.d.ts +10 -6
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/shared.d.ts +11 -9
- package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/composite-loop.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/csr-substitute.d.ts +16 -0
- package/dist/ir-to-client-js/csr-substitute.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/emit-registration.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +2 -2
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/index.d.ts.map +1 -1
- package/dist/ir-to-client-js/markup-slots.d.ts +21 -0
- package/dist/ir-to-client-js/markup-slots.d.ts.map +1 -0
- 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/safe-html.d.ts +158 -0
- package/dist/ir-to-client-js/safe-html.d.ts.map +1 -0
- 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/ir-to-client-js/utils.d.ts +34 -1
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/prop-rewrite.d.ts +8 -3
- package/dist/prop-rewrite.d.ts.map +1 -1
- package/dist/props-binding.d.ts +22 -1
- package/dist/props-binding.d.ts.map +1 -1
- package/dist/reactivity-checker.d.ts +18 -0
- package/dist/reactivity-checker.d.ts.map +1 -1
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/types.d.ts +5 -10
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +138 -22
- package/src/__tests__/binding-scope-ratchet.test.ts +25 -2
- package/src/__tests__/build-plain-row-consolidation.test.ts +120 -0
- package/src/__tests__/child-prop-fallback-wrap.test.ts +4 -2
- package/src/__tests__/client-template-escape-soundness.test.ts +538 -0
- package/src/__tests__/compiler-stress-1244.test.ts +8 -8
- package/src/__tests__/csr-template-loop-shadowing.test.ts +4 -2
- package/src/__tests__/flatmap-segments.test.ts +1 -1
- 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__/lazy-conditional.test.ts +52 -12
- package/src/__tests__/lazy-preamble.test.ts +11 -11
- package/src/__tests__/lazy-row-eligibility.test.ts +8 -3
- package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +25 -3
- package/src/__tests__/loop-index-reactivity.test.ts +208 -0
- package/src/__tests__/loop-item-conditional-codegen.test.ts +4 -2
- package/src/__tests__/map-arbitrary-body.test.ts +1 -1
- package/src/__tests__/map-body-brand-checker.test.ts +176 -0
- package/src/__tests__/nested-loop-index-param.test.ts +41 -8
- 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 +16 -6
- package/src/__tests__/preamble-branch-arm-rerun.test.ts +135 -0
- package/src/__tests__/reactive-attrs-in-map.test.ts +13 -10
- package/src/__tests__/rewrite-destructured-props.test.ts +37 -0
- package/src/__tests__/safe-html-splice-door.test.ts +61 -0
- package/src/__tests__/ssr-defaults.test.ts +59 -0
- package/src/__tests__/style-css-var-reactive.test.ts +4 -1
- package/src/analyzer-context.ts +12 -10
- package/src/analyzer.ts +4 -0
- package/src/index.ts +8 -2
- package/src/ir-to-client-js/collect-elements.ts +126 -32
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +20 -28
- package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +9 -7
- package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +12 -6
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +36 -59
- package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +25 -4
- package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +36 -9
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +35 -35
- package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +116 -0
- package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +23 -8
- package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +24 -45
- package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +11 -10
- package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +11 -13
- package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +13 -3
- package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +14 -0
- package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -6
- package/src/ir-to-client-js/control-flow/shared.ts +40 -17
- package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +2 -1
- 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 +22 -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/csr-substitute.ts +29 -0
- package/src/ir-to-client-js/emit-reactive.ts +92 -6
- package/src/ir-to-client-js/emit-registration.ts +2 -4
- package/src/ir-to-client-js/html-template.ts +67 -245
- package/src/ir-to-client-js/imports.ts +3 -0
- package/src/ir-to-client-js/index.ts +4 -4
- package/src/ir-to-client-js/markup-slots.ts +25 -0
- package/src/ir-to-client-js/reactivity.ts +73 -27
- package/src/ir-to-client-js/safe-html.ts +243 -0
- package/src/ir-to-client-js/types.ts +19 -3
- package/src/ir-to-client-js/utils.ts +58 -10
- package/src/jsx-to-ir.ts +136 -24
- package/src/prop-rewrite.ts +8 -4
- package/src/props-binding.ts +37 -1
- package/src/reactivity-checker.ts +60 -0
- package/src/ssr-defaults.ts +45 -0
- package/src/types.ts +5 -10
- package/src/__tests__/text-slot-escaping.test.ts +0 -65
|
@@ -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
|
+
})
|
|
@@ -51,13 +51,19 @@ const armsOf = (c: LoopChildConditional) => ({
|
|
|
51
51
|
describe('analyzeLazyConditional — accepted', () => {
|
|
52
52
|
test('two wiring-free static element arms', () => {
|
|
53
53
|
const c = cond()
|
|
54
|
-
const r = analyzeLazyConditional(c,
|
|
54
|
+
const r = analyzeLazyConditional(c, armsOf(c))
|
|
55
55
|
expect(r.lazySafe).toBe(true)
|
|
56
56
|
if (r.lazySafe) {
|
|
57
57
|
expect(r.facts.whenTrueHtml).toContain('bf-c="s0"')
|
|
58
58
|
expect(r.facts.condition).toBe('row.done')
|
|
59
59
|
}
|
|
60
60
|
})
|
|
61
|
+
|
|
62
|
+
test('a condition reading the loop index is accepted (#2859 follow-up) — the caller classifies it like any other binding', () => {
|
|
63
|
+
const c = cond({ conditionFreeIdentifiers: new Set(['row', '__idx']) })
|
|
64
|
+
const r = analyzeLazyConditional(c, armsOf(c))
|
|
65
|
+
expect(r.lazySafe).toBe(true)
|
|
66
|
+
})
|
|
61
67
|
})
|
|
62
68
|
|
|
63
69
|
describe('analyzeLazyConditional — refusals', () => {
|
|
@@ -73,7 +79,7 @@ describe('analyzeLazyConditional — refusals', () => {
|
|
|
73
79
|
for (const [name, over, reason] of wiring) {
|
|
74
80
|
test(`the true arm owns ${name}`, () => {
|
|
75
81
|
const c = cond({ whenTrue: bare(over) })
|
|
76
|
-
const r = analyzeLazyConditional(c,
|
|
82
|
+
const r = analyzeLazyConditional(c, armsOf(c))
|
|
77
83
|
expect(r.lazySafe).toBe(false)
|
|
78
84
|
if (!r.lazySafe) expect(r.reason).toMatch(reason)
|
|
79
85
|
})
|
|
@@ -81,14 +87,14 @@ describe('analyzeLazyConditional — refusals', () => {
|
|
|
81
87
|
|
|
82
88
|
test('the FALSE arm is checked too, not just the true one', () => {
|
|
83
89
|
const c = cond({ whenFalse: bare({ events: [{} as never] }) })
|
|
84
|
-
const r = analyzeLazyConditional(c,
|
|
90
|
+
const r = analyzeLazyConditional(c, armsOf(c))
|
|
85
91
|
expect(r.lazySafe).toBe(false)
|
|
86
92
|
if (!r.lazySafe) expect(r.reason).toMatch(/its false arm owns events/)
|
|
87
93
|
})
|
|
88
94
|
|
|
89
95
|
test('a fragment conditional has no single node to replace', () => {
|
|
90
96
|
const c = cond()
|
|
91
|
-
const r = analyzeLazyConditional(c,
|
|
97
|
+
const r = analyzeLazyConditional(c, {
|
|
92
98
|
whenTrueHtml: asFragment('text only'),
|
|
93
99
|
whenFalseHtml: asElement(c.whenFalseHtml),
|
|
94
100
|
})
|
|
@@ -98,24 +104,18 @@ describe('analyzeLazyConditional — refusals', () => {
|
|
|
98
104
|
|
|
99
105
|
test('an arm that interpolates a value cannot be hoisted once per loop', () => {
|
|
100
106
|
const c = cond({ whenTrueHtml: '<span>${row().label}</span>' })
|
|
101
|
-
const r = analyzeLazyConditional(c,
|
|
107
|
+
const r = analyzeLazyConditional(c, armsOf(c))
|
|
102
108
|
expect(r.lazySafe).toBe(false)
|
|
103
109
|
if (!r.lazySafe) expect(r.reason).toMatch(/interpolates a value/)
|
|
104
110
|
})
|
|
105
111
|
|
|
106
112
|
test('a condition with no analyzable identifier set refuses rather than assumes', () => {
|
|
107
113
|
const c = cond({ conditionFreeIdentifiers: undefined })
|
|
108
|
-
const r = analyzeLazyConditional(c,
|
|
114
|
+
const r = analyzeLazyConditional(c, armsOf(c))
|
|
109
115
|
expect(r.lazySafe).toBe(false)
|
|
110
116
|
if (!r.lazySafe) expect(r.reason).toMatch(/no analyzable identifier set/)
|
|
111
117
|
})
|
|
112
118
|
|
|
113
|
-
test('a condition reading the loop index refuses — the apply bodies have none', () => {
|
|
114
|
-
const c = cond({ conditionFreeIdentifiers: new Set(['row', '__idx']) })
|
|
115
|
-
const r = analyzeLazyConditional(c, '__idx', armsOf(c))
|
|
116
|
-
expect(r.lazySafe).toBe(false)
|
|
117
|
-
if (!r.lazySafe) expect(r.reason).toMatch(/reads the loop index parameter '__idx'/)
|
|
118
|
-
})
|
|
119
119
|
})
|
|
120
120
|
|
|
121
121
|
// --- emission ---------------------------------------------------------------
|
|
@@ -175,6 +175,46 @@ describe('lazy row emission — wiring-free row conditional', () => {
|
|
|
175
175
|
})
|
|
176
176
|
})
|
|
177
177
|
|
|
178
|
+
describe('lazy row emission — a conditional reading the loop index (#2859 follow-up)', () => {
|
|
179
|
+
// Mixed item+index condition — a PURE index-only reactive condition/attr is
|
|
180
|
+
// a separate, pre-existing, broader gap (the analyzer never classifies an
|
|
181
|
+
// expression that reads ONLY the loop index as reactive AT ALL, in either
|
|
182
|
+
// the eager or lazy path — filed separately, not this widening's job to
|
|
183
|
+
// fix). `row.done` is what makes the analyzer treat this condition as
|
|
184
|
+
// reactive in the first place; `i % 2 === 0` then rides along, which is
|
|
185
|
+
// exactly what this widening needs to prove.
|
|
186
|
+
const source = `
|
|
187
|
+
'use client'
|
|
188
|
+
import { createSignal } from '@barefootjs/client'
|
|
189
|
+
type Row = { id: number; label: string; done: boolean }
|
|
190
|
+
export function CondRowsIndex(props: { rows: Row[] }) {
|
|
191
|
+
const [rows, setRows] = createSignal<Row[]>(props.rows)
|
|
192
|
+
return (
|
|
193
|
+
<ul>
|
|
194
|
+
{rows().map((row, i) => (
|
|
195
|
+
<li key={row.id}>{(row.done || i % 2 === 0) ? <span class="even">even</span> : <em class="odd">odd</em>}</li>
|
|
196
|
+
))}
|
|
197
|
+
</ul>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
`
|
|
201
|
+
const js = clientJs(source, 'CondRowsIndex.tsx')
|
|
202
|
+
|
|
203
|
+
test('the loop is still lazy — the index reference no longer forces eager', () => {
|
|
204
|
+
expect(js).toContain('mapArrayLazy(')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('the condition is emitted into applyItem, reading the live entry.index', () => {
|
|
208
|
+
const applyItem = js.slice(js.indexOf('applyItem:'), js.indexOf("}, 'l0')"))
|
|
209
|
+
expect(applyItem).toMatch(/const i = __e\.index/)
|
|
210
|
+
expect(applyItem).toContain('i % 2 === 0')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('the plan is marked indexDriven so a pure reorder re-evaluates it', () => {
|
|
214
|
+
expect(js).toMatch(/indexDriven:\s*true/)
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
178
218
|
describe('lazy row emission — conditionals that keep the eager path', () => {
|
|
179
219
|
const cases: Array<[string, string]> = [
|
|
180
220
|
['an arm with an event', '{row.done ? <button onClick={() => {}}>x</button> : <em>open</em>}'],
|
|
@@ -33,11 +33,11 @@ const SIGNALS: ReadonlySet<string> = new Set(['selected', 'filter'])
|
|
|
33
33
|
|
|
34
34
|
describe('analyzeLazyPreamble — accepted', () => {
|
|
35
35
|
test('no preamble at all', () => {
|
|
36
|
-
expect(analyzeLazyPreamble(undefined,
|
|
36
|
+
expect(analyzeLazyPreamble(undefined, SIGNALS).lazySafe).toBe(true)
|
|
37
37
|
})
|
|
38
38
|
|
|
39
39
|
test('const declarations reading only the item', () => {
|
|
40
|
-
const r = analyzeLazyPreamble(preamble("const full = `${row.a} ${row.b}`;", ['full']),
|
|
40
|
+
const r = analyzeLazyPreamble(preamble("const full = `${row.a} ${row.b}`;", ['full']), SIGNALS)
|
|
41
41
|
expect(r.lazySafe).toBe(true)
|
|
42
42
|
if (r.lazySafe) expect([...r.facts.declaredNames]).toEqual(['full'])
|
|
43
43
|
})
|
|
@@ -45,17 +45,22 @@ describe('analyzeLazyPreamble — accepted', () => {
|
|
|
45
45
|
test('a zero-arg signal read is allowed — the krausest shape', () => {
|
|
46
46
|
const r = analyzeLazyPreamble(
|
|
47
47
|
preamble("const cls = selected() === row.id ? 'danger' : '';", ['cls']),
|
|
48
|
-
'__idx',
|
|
49
48
|
SIGNALS,
|
|
50
49
|
)
|
|
51
50
|
expect(r.lazySafe).toBe(true)
|
|
52
51
|
})
|
|
53
52
|
|
|
54
53
|
test('a destructuring const contributes every bound name, not the property keys', () => {
|
|
55
|
-
const r = analyzeLazyPreamble(preamble('const { a, b: [c] } = row;', ['a', 'c']),
|
|
54
|
+
const r = analyzeLazyPreamble(preamble('const { a, b: [c] } = row;', ['a', 'c']), SIGNALS)
|
|
56
55
|
expect(r.lazySafe).toBe(true)
|
|
57
56
|
if (r.lazySafe) expect([...r.facts.declaredNames].sort()).toEqual(['a', 'c'])
|
|
58
57
|
})
|
|
58
|
+
|
|
59
|
+
test('a preamble reading the loop index is accepted (#2859 follow-up) — the name survives into freeNames', () => {
|
|
60
|
+
const r = analyzeLazyPreamble(preamble('const n = __idx + 1;', ['n']), SIGNALS)
|
|
61
|
+
expect(r.lazySafe).toBe(true)
|
|
62
|
+
if (r.lazySafe) expect(r.facts.freeNames.has('__idx')).toBe(true)
|
|
63
|
+
})
|
|
59
64
|
})
|
|
60
65
|
|
|
61
66
|
describe('analyzeLazyPreamble — refusals', () => {
|
|
@@ -112,16 +117,11 @@ describe('analyzeLazyPreamble — refusals', () => {
|
|
|
112
117
|
preamble('const selected = row.sel;', ['selected']),
|
|
113
118
|
/shadows the signal\/memo getter 'selected'/,
|
|
114
119
|
],
|
|
115
|
-
[
|
|
116
|
-
'a preamble reading the loop index',
|
|
117
|
-
preamble('const n = __idx + 1;', ['n']),
|
|
118
|
-
/reads the loop index parameter '__idx'/,
|
|
119
|
-
],
|
|
120
120
|
]
|
|
121
121
|
|
|
122
122
|
for (const [name, p, reason] of cases) {
|
|
123
123
|
test(name, () => {
|
|
124
|
-
const r = analyzeLazyPreamble(p,
|
|
124
|
+
const r = analyzeLazyPreamble(p, SIGNALS)
|
|
125
125
|
expect(r.lazySafe).toBe(false)
|
|
126
126
|
if (!r.lazySafe) expect(r.reason).toMatch(reason)
|
|
127
127
|
})
|
|
@@ -134,7 +134,7 @@ describe('analyzeLazyPreamble — refusals', () => {
|
|
|
134
134
|
declaredNames: [],
|
|
135
135
|
builderNames: [],
|
|
136
136
|
}
|
|
137
|
-
const r = analyzeLazyPreamble(p,
|
|
137
|
+
const r = analyzeLazyPreamble(p, SIGNALS)
|
|
138
138
|
expect(r.lazySafe).toBe(false)
|
|
139
139
|
if (!r.lazySafe) expect(r.reason).toMatch(/JSX leaf/)
|
|
140
140
|
})
|
|
@@ -152,12 +152,17 @@ describe('lazyRowEligibility — profile mode', () => {
|
|
|
152
152
|
})
|
|
153
153
|
|
|
154
154
|
describe('lazyRowEligibility — binding refusals', () => {
|
|
155
|
-
|
|
155
|
+
// #2859 follow-up, LIFTED: a binding referencing the loop index no longer
|
|
156
|
+
// sinks the loop. `classifyLazyBinding` forces `readsItem: true` for it
|
|
157
|
+
// (the row's position is delivered on `entry.index` exactly like the item
|
|
158
|
+
// is on `entry.item`), so the row stays eligible and the binding lands in
|
|
159
|
+
// `applyItem`. Inverted from the refusal it replaces so a gate that starts
|
|
160
|
+
// refusing again fails here by name.
|
|
161
|
+
test('a binding referencing the loop index parameter is ELIGIBLE (index tracked via entry.index)', () => {
|
|
156
162
|
const decision = lazyRowEligibility(args({
|
|
157
163
|
bindings: [{ ...itemBinding(), referencesIndex: true }],
|
|
158
164
|
}))
|
|
159
|
-
expect(decision.eligible).toBe(
|
|
160
|
-
expect((decision as { reason: string }).reason).toMatch(/index parameter/)
|
|
165
|
+
expect(decision.eligible).toBe(true)
|
|
161
166
|
})
|
|
162
167
|
|
|
163
168
|
// §9.3a, LIFTED: an outer name the emitter cannot prime no longer sinks
|
|
@@ -36,6 +36,23 @@
|
|
|
36
36
|
* `collectLoopChildBindings` / `collectLoopChildConditionals` /
|
|
37
37
|
* `summarizeLoopChildBranch` / `collectLoopChildReactiveAttrs` /
|
|
38
38
|
* `collectLoopChildReactiveTexts` into `buildLoopRowScope`.
|
|
39
|
+
*
|
|
40
|
+
* #2859: `i` is now ALSO rewritten to `i()` (`wrapLoopParamAsAccessor`,
|
|
41
|
+
* `ir-to-client-js/utils.ts`) on the EAGER path — `mapArray` hands
|
|
42
|
+
* `renderItem` an index ACCESSOR (mirroring the item accessor) and pushes
|
|
43
|
+
* each row's current position through it on every same-key reconcile, so
|
|
44
|
+
* a row surviving a reorder keeps reading its live index instead of the
|
|
45
|
+
* value frozen at creation. The #2595 fix above only prevented the
|
|
46
|
+
* module-const shadow; it still left `i` reading a plain, never-updated
|
|
47
|
+
* closure variable.
|
|
48
|
+
*
|
|
49
|
+
* #2859 follow-on (lazy-row widening): this exact shape has no ref/child
|
|
50
|
+
* component/inner loop, so it is now LAZY-eligible — the index reference
|
|
51
|
+
* no longer forces eager on its own. On the lazy path there is no `i()`
|
|
52
|
+
* accessor call at all: `applyItem` binds `const i = __e.index` (a plain
|
|
53
|
+
* value read off the reconciler-tracked entry) and the attribute reads
|
|
54
|
+
* that bare `i`, which is exactly as live across a reorder as the eager
|
|
55
|
+
* accessor was.
|
|
39
56
|
*/
|
|
40
57
|
|
|
41
58
|
import { describe, test, expect } from 'bun:test'
|
|
@@ -99,9 +116,14 @@ describe('loop-child reactive attr vs a loop INDEX param shadowing a module cons
|
|
|
99
116
|
}
|
|
100
117
|
`)
|
|
101
118
|
|
|
102
|
-
// The per-item attribute effect must read the row's OWN index
|
|
103
|
-
//
|
|
104
|
-
|
|
119
|
+
// The per-item attribute effect must read the row's OWN, LIVE index
|
|
120
|
+
// (#2859 follow-on: `entry.index`, tracked by the lazy-row reconciler
|
|
121
|
+
// the same way it tracks `entry.item`) — never the outer module
|
|
122
|
+
// const's baked literal, and never a frozen closure variable that
|
|
123
|
+
// would go stale after a reorder.
|
|
124
|
+
expect(js).toContain('const i = __e.index')
|
|
125
|
+
expect(js).toContain('const __x = i')
|
|
105
126
|
expect(js).not.toContain("const __v = 'MODULE_CONST';")
|
|
127
|
+
expect(js).not.toContain("const __x = 'MODULE_CONST'")
|
|
106
128
|
})
|
|
107
129
|
})
|