@barefootjs/jsx 0.29.0 → 0.30.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/child-scope.d.ts +17 -0
- package/dist/adapters/child-scope.d.ts.map +1 -0
- package/dist/adapters/loop-bound-names.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +990 -367
- package/dist/ir-to-client-js/collect-elements.d.ts +5 -25
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +66 -2
- package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +92 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +134 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts +46 -11
- package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
- package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +2 -3
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts +1 -10
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +12 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/prop-rewrite.d.ts +24 -7
- package/dist/prop-rewrite.d.ts.map +1 -1
- package/dist/rich-type-evidence.d.ts.map +1 -1
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/to-locale-date-lowering.d.ts.map +1 -1
- package/dist/types.d.ts +78 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +6 -24
- package/src/__tests__/branch-loop-plain.test.ts +28 -45
- package/src/__tests__/csr-substitution-safety-divergence.test.ts +9 -3
- package/src/__tests__/csr-template-scope-soundness.test.ts +167 -0
- package/src/__tests__/early-return-branch-switch.test.ts +96 -0
- package/src/__tests__/form-control-value-ssr.test.ts +102 -0
- package/src/__tests__/lazy-conditional.test.ts +190 -0
- package/src/__tests__/lazy-preamble.test.ts +297 -0
- package/src/__tests__/lazy-row-eligibility.test.ts +97 -28
- package/src/__tests__/loop-item-root-scope.test.ts +213 -0
- package/src/__tests__/map-multi-return-body.test.ts +30 -3
- package/src/__tests__/nested-loop-conditional.test.ts +27 -6
- package/src/__tests__/preamble-attr-reactivity.test.ts +217 -0
- package/src/__tests__/preamble-declarations.test.ts +207 -0
- package/src/__tests__/ssr-defaults.test.ts +44 -0
- package/src/adapters/child-scope.ts +22 -0
- package/src/adapters/loop-bound-names.ts +6 -0
- package/src/analyzer-context.ts +43 -0
- package/src/analyzer.ts +33 -3
- package/src/index.ts +1 -0
- package/src/ir-to-client-js/collect-elements.ts +51 -10
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +1 -1
- package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +2 -2
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +3 -3
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -2
- package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +147 -5
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -2
- package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
- package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +151 -0
- package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +287 -0
- package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +85 -22
- package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
- package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +133 -35
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +24 -4
- package/src/ir-to-client-js/csr-substitute.ts +6 -1
- package/src/ir-to-client-js/emit-registration.ts +1 -1
- package/src/ir-to-client-js/html-template.ts +51 -40
- package/src/ir-to-client-js/index.ts +1 -1
- package/src/ir-to-client-js/plan/build-declaration-emit.ts +2 -0
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +1 -1
- package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
- package/src/ir-to-client-js/reactivity.ts +25 -0
- package/src/ir-to-client-js/stringify/declaration-emit.ts +9 -4
- package/src/ir-to-client-js/types.ts +12 -0
- package/src/jsx-to-ir.ts +409 -24
- package/src/prop-rewrite.ts +149 -19
- package/src/rich-type-evidence.ts +6 -7
- package/src/ssr-defaults.ts +7 -2
- package/src/to-locale-date-lowering.ts +8 -2
- package/src/types.ts +80 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `analyzeLazyConditional` — the §9.5 "row contains a reactive conditional"
|
|
3
|
+
* widening (`plan/lazy-conditional.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Unit half over hand-built `LoopChildConditional` values so each refusal is
|
|
6
|
+
* pinned to the exact shape that produces it, plus emission assertions that an
|
|
7
|
+
* accepted conditional is driven from the apply bodies with its arms hoisted
|
|
8
|
+
* once per loop. The DOM behaviour (does the swap actually land, both ways)
|
|
9
|
+
* lives in `packages/client/__tests__/runtime/lazy-row-conditional.test.ts` —
|
|
10
|
+
* emitted shape and emitted behaviour are different claims.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, test, expect } from 'bun:test'
|
|
14
|
+
import { compileJSX } from '../index.ts'
|
|
15
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
16
|
+
import { analyzeLazyConditional } from '../ir-to-client-js/control-flow/plan/lazy-conditional.ts'
|
|
17
|
+
import type { LoopChildBranchSummary, LoopChildConditional } from '../ir-to-client-js/types.ts'
|
|
18
|
+
|
|
19
|
+
const bare = (over: Partial<LoopChildBranchSummary> = {}): LoopChildBranchSummary => ({
|
|
20
|
+
childComponents: [],
|
|
21
|
+
...over,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
function cond(over: Partial<LoopChildConditional> = {}): LoopChildConditional {
|
|
25
|
+
return {
|
|
26
|
+
slotId: 's0',
|
|
27
|
+
condition: 'row.done',
|
|
28
|
+
whenTrueHtml: '<span class="yes">done</span>',
|
|
29
|
+
whenFalseHtml: '<em class="no">open</em>',
|
|
30
|
+
whenTrue: bare(),
|
|
31
|
+
whenFalse: bare(),
|
|
32
|
+
conditionFreeIdentifiers: new Set(['row']),
|
|
33
|
+
...over,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The caller prepares each arm through `addCondAttrToTemplate` before handing it
|
|
39
|
+
* over — that is where `bf-c` comes from, and where the element-vs-fragment
|
|
40
|
+
* decision is made. These helpers stand in for it.
|
|
41
|
+
*/
|
|
42
|
+
const asElement = (html: string, slotId = 's0') => html.replace(/^(<\w+)/, `$1 bf-c="${slotId}"`)
|
|
43
|
+
const asFragment = (html: string, slotId = 's0') =>
|
|
44
|
+
`<!--bf-cond-start:${slotId}-->${html}<!--bf-cond-end:${slotId}-->`
|
|
45
|
+
|
|
46
|
+
const armsOf = (c: LoopChildConditional) => ({
|
|
47
|
+
whenTrueHtml: asElement(c.whenTrueHtml, c.slotId),
|
|
48
|
+
whenFalseHtml: asElement(c.whenFalseHtml, c.slotId),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('analyzeLazyConditional — accepted', () => {
|
|
52
|
+
test('two wiring-free static element arms', () => {
|
|
53
|
+
const c = cond()
|
|
54
|
+
const r = analyzeLazyConditional(c, '__idx', armsOf(c))
|
|
55
|
+
expect(r.lazySafe).toBe(true)
|
|
56
|
+
if (r.lazySafe) {
|
|
57
|
+
expect(r.facts.whenTrueHtml).toContain('bf-c="s0"')
|
|
58
|
+
expect(r.facts.condition).toBe('row.done')
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('analyzeLazyConditional — refusals', () => {
|
|
64
|
+
const wiring: Array<[string, Partial<LoopChildBranchSummary>, RegExp]> = [
|
|
65
|
+
['a child component', { childComponents: [{ name: 'X', slotId: null, props: [], children: [] }] }, /child components/],
|
|
66
|
+
['an inner loop', { innerLoops: [{} as never] }, /an inner loop/],
|
|
67
|
+
['a nested conditional', { conditionals: [{} as never] }, /a nested conditional/],
|
|
68
|
+
['events', { events: [{} as never] }, /events/],
|
|
69
|
+
['a reactive attr', { reactiveAttrs: [{} as never] }, /reactive attrs/],
|
|
70
|
+
['reactive text', { reactiveTexts: [{} as never] }, /reactive text/],
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
for (const [name, over, reason] of wiring) {
|
|
74
|
+
test(`the true arm owns ${name}`, () => {
|
|
75
|
+
const c = cond({ whenTrue: bare(over) })
|
|
76
|
+
const r = analyzeLazyConditional(c, '__idx', armsOf(c))
|
|
77
|
+
expect(r.lazySafe).toBe(false)
|
|
78
|
+
if (!r.lazySafe) expect(r.reason).toMatch(reason)
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
test('the FALSE arm is checked too, not just the true one', () => {
|
|
83
|
+
const c = cond({ whenFalse: bare({ events: [{} as never] }) })
|
|
84
|
+
const r = analyzeLazyConditional(c, '__idx', armsOf(c))
|
|
85
|
+
expect(r.lazySafe).toBe(false)
|
|
86
|
+
if (!r.lazySafe) expect(r.reason).toMatch(/its false arm owns events/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('a fragment conditional has no single node to replace', () => {
|
|
90
|
+
const c = cond()
|
|
91
|
+
const r = analyzeLazyConditional(c, '__idx', {
|
|
92
|
+
whenTrueHtml: asFragment('text only'),
|
|
93
|
+
whenFalseHtml: asElement(c.whenFalseHtml),
|
|
94
|
+
})
|
|
95
|
+
expect(r.lazySafe).toBe(false)
|
|
96
|
+
if (!r.lazySafe) expect(r.reason).toMatch(/fragment conditional/)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('an arm that interpolates a value cannot be hoisted once per loop', () => {
|
|
100
|
+
const c = cond({ whenTrueHtml: '<span>${row().label}</span>' })
|
|
101
|
+
const r = analyzeLazyConditional(c, '__idx', armsOf(c))
|
|
102
|
+
expect(r.lazySafe).toBe(false)
|
|
103
|
+
if (!r.lazySafe) expect(r.reason).toMatch(/interpolates a value/)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('a condition with no analyzable identifier set refuses rather than assumes', () => {
|
|
107
|
+
const c = cond({ conditionFreeIdentifiers: undefined })
|
|
108
|
+
const r = analyzeLazyConditional(c, '__idx', armsOf(c))
|
|
109
|
+
expect(r.lazySafe).toBe(false)
|
|
110
|
+
if (!r.lazySafe) expect(r.reason).toMatch(/no analyzable identifier set/)
|
|
111
|
+
})
|
|
112
|
+
|
|
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
|
+
})
|
|
120
|
+
|
|
121
|
+
// --- emission ---------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
function clientJs(source: string, file: string): string {
|
|
124
|
+
const result = compileJSX(source, file, { adapter: new TestAdapter() })
|
|
125
|
+
const js = result.files.find(f => f.path.endsWith('.client.js'))
|
|
126
|
+
if (!js) throw new Error(`no client JS emitted for ${file}`)
|
|
127
|
+
return js.content
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const ROWS = (arm: string) => `
|
|
131
|
+
'use client'
|
|
132
|
+
import { createSignal } from '@barefootjs/client'
|
|
133
|
+
type Row = { id: number; label: string; done: boolean }
|
|
134
|
+
export function CondRows(props: { rows: Row[] }) {
|
|
135
|
+
const [rows, setRows] = createSignal<Row[]>(props.rows)
|
|
136
|
+
return (
|
|
137
|
+
<ul>
|
|
138
|
+
{rows().map(row => (
|
|
139
|
+
<li key={row.id}>${arm}</li>
|
|
140
|
+
))}
|
|
141
|
+
</ul>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
`
|
|
145
|
+
|
|
146
|
+
describe('lazy row emission — wiring-free row conditional', () => {
|
|
147
|
+
const js = clientJs(
|
|
148
|
+
ROWS('{row.done ? <span class="yes">done</span> : <em class="no">open</em>}'),
|
|
149
|
+
'CondRows.tsx',
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
test('the loop is lazy and calls no per-row insert()', () => {
|
|
153
|
+
expect(js).toContain('mapArrayLazy(')
|
|
154
|
+
expect(js).not.toMatch(/\bmapArray\(/)
|
|
155
|
+
expect(js).not.toContain('insert(')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('both arms are parsed once per LOOP, outside every plan body', () => {
|
|
159
|
+
const beforeCall = js.slice(0, js.indexOf('mapArrayLazy('))
|
|
160
|
+
expect(beforeCall).toMatch(/const __cbt_l0_s\d+ = document\.createElement\('template'\)/)
|
|
161
|
+
expect(beforeCall).toMatch(/const __cbf_l0_s\d+ = document\.createElement\('template'\)/)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('createRow only records the dedup boolean — the clone already has the right arm', () => {
|
|
165
|
+
const createRow = js.slice(js.indexOf('createRow:'), js.indexOf('applyItem:'))
|
|
166
|
+
expect(createRow).toMatch(/__l\[\d\] = !!\(row\(\)\.done\)/)
|
|
167
|
+
expect(createRow).not.toContain('replaceWith')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('applyItem swaps by replaceWith and reassigns the ref', () => {
|
|
171
|
+
const applyItem = js.slice(js.indexOf('applyItem:'), js.indexOf("}, 'l0')"))
|
|
172
|
+
expect(applyItem).toContain('__c.replaceWith(__n)')
|
|
173
|
+
// Without this the next flip writes into the node the previous one detached.
|
|
174
|
+
expect(applyItem).toMatch(/__r\[\d\] = __n/)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('lazy row emission — conditionals that keep the eager path', () => {
|
|
179
|
+
const cases: Array<[string, string]> = [
|
|
180
|
+
['an arm with an event', '{row.done ? <button onClick={() => {}}>x</button> : <em>open</em>}'],
|
|
181
|
+
['an arm reading the item', '{row.done ? <span>{row.label}</span> : <em>open</em>}'],
|
|
182
|
+
['a bare-value ternary (fragment form)', '{row.done ? "yes" : "no"}'],
|
|
183
|
+
]
|
|
184
|
+
for (const [name, arm] of cases) {
|
|
185
|
+
test(name, () => {
|
|
186
|
+
const js = clientJs(ROWS(arm), 'EagerCond.tsx')
|
|
187
|
+
expect(js).not.toContain('mapArrayLazy(')
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
})
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `analyzeLazyPreamble` — the §9.5 "row has a map-callback preamble"
|
|
3
|
+
* widening (`plan/lazy-preamble.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Two layers, same split as `lazy-row-eligibility.test.ts`:
|
|
6
|
+
*
|
|
7
|
+
* 1. Unit tests over hand-built `MapCallbackPreamble` values, so each
|
|
8
|
+
* refusal reason is pinned to the exact source shape that produces it —
|
|
9
|
+
* a refusal that silently changes wording or scope fails here.
|
|
10
|
+
* 2. End-to-end emission: a component whose loop has a value-only preamble
|
|
11
|
+
* must now emit `mapArrayLazy`, with the preamble in `createRow` and
|
|
12
|
+
* nowhere else. That placement is the whole soundness argument (the apply
|
|
13
|
+
* bodies must never reference a preamble local), so it is asserted
|
|
14
|
+
* directly rather than inferred from "it compiled".
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, test, expect } from 'bun:test'
|
|
18
|
+
import { compileJSX } from '../index.ts'
|
|
19
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
20
|
+
import { analyzeLazyPreamble } from '../ir-to-client-js/control-flow/plan/lazy-preamble.ts'
|
|
21
|
+
import { tsxSourceText, type MapCallbackPreamble } from '../types.ts'
|
|
22
|
+
|
|
23
|
+
function preamble(text: string, declaredNames: string[], builderNames: string[] = []): MapCallbackPreamble {
|
|
24
|
+
return {
|
|
25
|
+
segments: [{ kind: 'js', text }],
|
|
26
|
+
ssrText: tsxSourceText(text),
|
|
27
|
+
declaredNames,
|
|
28
|
+
builderNames,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SIGNALS: ReadonlySet<string> = new Set(['selected', 'filter'])
|
|
33
|
+
|
|
34
|
+
describe('analyzeLazyPreamble — accepted', () => {
|
|
35
|
+
test('no preamble at all', () => {
|
|
36
|
+
expect(analyzeLazyPreamble(undefined, '__idx', SIGNALS).lazySafe).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('const declarations reading only the item', () => {
|
|
40
|
+
const r = analyzeLazyPreamble(preamble("const full = `${row.a} ${row.b}`;", ['full']), '__idx', SIGNALS)
|
|
41
|
+
expect(r.lazySafe).toBe(true)
|
|
42
|
+
if (r.lazySafe) expect([...r.facts.declaredNames]).toEqual(['full'])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('a zero-arg signal read is allowed — the krausest shape', () => {
|
|
46
|
+
const r = analyzeLazyPreamble(
|
|
47
|
+
preamble("const cls = selected() === row.id ? 'danger' : '';", ['cls']),
|
|
48
|
+
'__idx',
|
|
49
|
+
SIGNALS,
|
|
50
|
+
)
|
|
51
|
+
expect(r.lazySafe).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('a destructuring const contributes every bound name, not the property keys', () => {
|
|
55
|
+
const r = analyzeLazyPreamble(preamble('const { a, b: [c] } = row;', ['a', 'c']), '__idx', SIGNALS)
|
|
56
|
+
expect(r.lazySafe).toBe(true)
|
|
57
|
+
if (r.lazySafe) expect([...r.facts.declaredNames].sort()).toEqual(['a', 'c'])
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('analyzeLazyPreamble — refusals', () => {
|
|
62
|
+
const cases: Array<[string, MapCallbackPreamble, RegExp]> = [
|
|
63
|
+
[
|
|
64
|
+
'a signal declared per row',
|
|
65
|
+
preamble('const [x, setX] = createSignal(0);', ['x', 'setX']),
|
|
66
|
+
/call to createSignal/,
|
|
67
|
+
],
|
|
68
|
+
[
|
|
69
|
+
'a memo declared per row',
|
|
70
|
+
preamble('const total = createMemo(() => row.a + row.b);', ['total']),
|
|
71
|
+
/call to createMemo/,
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
'a non-signal call — could hide a reactive accessor',
|
|
75
|
+
preamble('const on = isSelected(row.id);', ['on']),
|
|
76
|
+
/call to isSelected/,
|
|
77
|
+
],
|
|
78
|
+
[
|
|
79
|
+
'a method call on the item',
|
|
80
|
+
preamble("const tags = row.tags.join(', ');", ['tags']),
|
|
81
|
+
/call to row\.tags\.join/,
|
|
82
|
+
],
|
|
83
|
+
[
|
|
84
|
+
'a nondeterministic call would differ between createRow and applyItem',
|
|
85
|
+
preamble('const n = Math.random();', ['n']),
|
|
86
|
+
/call to Math\.random/,
|
|
87
|
+
],
|
|
88
|
+
[
|
|
89
|
+
'a signal getter called with arguments is not a plain read',
|
|
90
|
+
preamble('const v = selected(row.id);', ['v']),
|
|
91
|
+
/call to selected/,
|
|
92
|
+
],
|
|
93
|
+
['a mutable binding', preamble("let cls = '';", ['cls']), /mutable binding/],
|
|
94
|
+
[
|
|
95
|
+
'a statement that is not a declaration',
|
|
96
|
+
preamble("const a = row.a; console.log(a);", ['a']),
|
|
97
|
+
/non-declaration statement/,
|
|
98
|
+
],
|
|
99
|
+
[
|
|
100
|
+
'an arrow expression — fresh identity per run',
|
|
101
|
+
preamble('const fn = (x) => x + row.a;', ['fn']),
|
|
102
|
+
/function or class expression/,
|
|
103
|
+
],
|
|
104
|
+
['a new expression', preamble('const d = new Foo(row.a);', ['d']), /new expression/],
|
|
105
|
+
[
|
|
106
|
+
'a JSX-leaf accumulator',
|
|
107
|
+
preamble('const cells = [];', ['cells'], ['cells']),
|
|
108
|
+
/accumulates JSX leaves/,
|
|
109
|
+
],
|
|
110
|
+
[
|
|
111
|
+
'a preamble local shadowing a signal getter',
|
|
112
|
+
preamble('const selected = row.sel;', ['selected']),
|
|
113
|
+
/shadows the signal\/memo getter 'selected'/,
|
|
114
|
+
],
|
|
115
|
+
[
|
|
116
|
+
'a preamble reading the loop index',
|
|
117
|
+
preamble('const n = __idx + 1;', ['n']),
|
|
118
|
+
/reads the loop index parameter '__idx'/,
|
|
119
|
+
],
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
for (const [name, p, reason] of cases) {
|
|
123
|
+
test(name, () => {
|
|
124
|
+
const r = analyzeLazyPreamble(p, '__idx', SIGNALS)
|
|
125
|
+
expect(r.lazySafe).toBe(false)
|
|
126
|
+
if (!r.lazySafe) expect(r.reason).toMatch(reason)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
test('a JSX segment is refused even with no builder name', () => {
|
|
131
|
+
const p: MapCallbackPreamble = {
|
|
132
|
+
segments: [{ kind: 'jsx', ir: { type: 'text', value: 'x' } as never }],
|
|
133
|
+
ssrText: tsxSourceText(''),
|
|
134
|
+
declaredNames: [],
|
|
135
|
+
builderNames: [],
|
|
136
|
+
}
|
|
137
|
+
const r = analyzeLazyPreamble(p, '__idx', SIGNALS)
|
|
138
|
+
expect(r.lazySafe).toBe(false)
|
|
139
|
+
if (!r.lazySafe) expect(r.reason).toMatch(/JSX leaf/)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// --- emission ---------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
function clientJs(source: string, file: string): string {
|
|
146
|
+
const result = compileJSX(source, file, { adapter: new TestAdapter() })
|
|
147
|
+
const js = result.files.find(f => f.path.endsWith('.client.js'))
|
|
148
|
+
if (!js) throw new Error(`no client JS emitted for ${file}`)
|
|
149
|
+
return js.content
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The preamble computes an attribute value from the item and an outer signal.
|
|
154
|
+
* An attribute reading a preamble local is not classified as reactive, so it
|
|
155
|
+
* lands in the row TEMPLATE — which is exactly why `createRow` needs the
|
|
156
|
+
* preamble and the apply bodies do not.
|
|
157
|
+
*/
|
|
158
|
+
const VALUE_PREAMBLE = `
|
|
159
|
+
'use client'
|
|
160
|
+
import { createSignal } from '@barefootjs/client'
|
|
161
|
+
type Row = { id: number; label: string; done: boolean }
|
|
162
|
+
export function PreambleRows() {
|
|
163
|
+
const [rows, setRows] = createSignal<Row[]>([])
|
|
164
|
+
const [selected, setSelected] = createSignal(0)
|
|
165
|
+
return (
|
|
166
|
+
<tbody>
|
|
167
|
+
{rows().map(row => {
|
|
168
|
+
const cls = selected() === row.id ? 'danger' : (row.done ? 'done' : '')
|
|
169
|
+
return (
|
|
170
|
+
<tr key={row.id} className={cls}>
|
|
171
|
+
<td>{row.label}</td>
|
|
172
|
+
</tr>
|
|
173
|
+
)
|
|
174
|
+
})}
|
|
175
|
+
</tbody>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
`
|
|
179
|
+
|
|
180
|
+
describe('lazy row emission — value-only preamble', () => {
|
|
181
|
+
const js = clientJs(VALUE_PREAMBLE, 'PreambleRows.tsx')
|
|
182
|
+
|
|
183
|
+
test('the loop is lazy — a preamble no longer refuses on sight', () => {
|
|
184
|
+
expect(js).toContain('mapArrayLazy(')
|
|
185
|
+
expect(js).not.toMatch(/\bmapArray\(/)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('rows still carry no per-row reactive resources', () => {
|
|
189
|
+
const planBody = js.slice(js.indexOf('mapArrayLazy('), js.indexOf("}, 'l0')"))
|
|
190
|
+
expect(planBody).not.toContain('createEffect')
|
|
191
|
+
expect(planBody).not.toContain('createRoot')
|
|
192
|
+
expect(planBody).not.toContain('createSignal')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('createRow runs the preamble, before the clone the template needs it for', () => {
|
|
196
|
+
const createRow = js.slice(js.indexOf('createRow:'), js.indexOf('applyItem:'))
|
|
197
|
+
expect(createRow).toContain('const cls = selected() === row().id')
|
|
198
|
+
expect(createRow.indexOf('const cls =')).toBeLessThan(createRow.indexOf('const __el ='))
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('applyOuter re-runs the preamble AND primes what it reads', () => {
|
|
202
|
+
// `className={cls}` is a binding as of the #2447 follow-up, and `cls`
|
|
203
|
+
// reads `selected()` — an OUTER signal the local names nowhere. The
|
|
204
|
+
// classifier substitutes the preamble's free identifiers, so `selected`
|
|
205
|
+
// reaches the prime list; without that the loop-level effect would
|
|
206
|
+
// subscribe to nothing and the class would never update.
|
|
207
|
+
// Bounded at the plan's closing `}, 'l0')` — past that sits the
|
|
208
|
+
// `hydrate` template, which legitimately contains the preamble.
|
|
209
|
+
const plan = js.slice(js.indexOf('mapArrayLazy('), js.indexOf("}, 'l0')"))
|
|
210
|
+
const applyOuter = plan.slice(plan.indexOf('applyOuter:'))
|
|
211
|
+
expect(applyOuter).toContain('const cls = selected() === row().id')
|
|
212
|
+
// The prime statement sits ABOVE the per-entry loop, so the effect
|
|
213
|
+
// subscribes even while the entry list is empty.
|
|
214
|
+
expect(applyOuter.indexOf('selected()')).toBeLessThan(applyOuter.indexOf('for (const __e of __es)'))
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('applyItem re-runs it too — this binding reads the item as well', () => {
|
|
218
|
+
// `selected() === row().id ? … : (row().done ? …)` reads BOTH, so the
|
|
219
|
+
// binding lands in both bodies and both need the local. Asserted
|
|
220
|
+
// explicitly rather than left implicit: "which bodies" is the whole
|
|
221
|
+
// per-binding accounting.
|
|
222
|
+
const plan = js.slice(js.indexOf('mapArrayLazy('), js.indexOf("}, 'l0')"))
|
|
223
|
+
const applyItem = plan.slice(plan.indexOf('applyItem:'), plan.indexOf('applyOuter:'))
|
|
224
|
+
expect(applyItem).toContain('const cls = selected() === row().id')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('a preamble NO binding reads is emitted in createRow only', () => {
|
|
228
|
+
// The counterpart: on-demand means a body with no reader pays nothing.
|
|
229
|
+
const js2 = clientJs(`
|
|
230
|
+
'use client'
|
|
231
|
+
import { createSignal } from '@barefootjs/client'
|
|
232
|
+
type Row = { id: number; label: string }
|
|
233
|
+
export function UnreadPreamble() {
|
|
234
|
+
const [rows, setRows] = createSignal<Row[]>([])
|
|
235
|
+
return (
|
|
236
|
+
<tbody>
|
|
237
|
+
{rows().map(row => {
|
|
238
|
+
const unused = row.label
|
|
239
|
+
return <tr key={row.id}><td>{row.label}</td></tr>
|
|
240
|
+
})}
|
|
241
|
+
</tbody>
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
`, 'UnreadPreamble.tsx')
|
|
245
|
+
const plan = js2.slice(js2.indexOf('mapArrayLazy('), js2.indexOf("}, 'l0')"))
|
|
246
|
+
expect(plan.slice(plan.indexOf('createRow:'), plan.indexOf('applyItem:'))).toContain('const unused =')
|
|
247
|
+
expect(plan.slice(plan.indexOf('applyItem:'))).not.toContain('const unused =')
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Each of these keeps the eager emission. Grouped rather than split so the
|
|
253
|
+
* list reads as "what still refuses", the counterpart to the widening above.
|
|
254
|
+
*/
|
|
255
|
+
describe('lazy row emission — preambles that keep the eager path', () => {
|
|
256
|
+
const rows = (body: string) => `
|
|
257
|
+
'use client'
|
|
258
|
+
import { createSignal, createMemo } from '@barefootjs/client'
|
|
259
|
+
type Row = { id: number; label: string }
|
|
260
|
+
export function EagerRows() {
|
|
261
|
+
const [rows, setRows] = createSignal<Row[]>([])
|
|
262
|
+
return (
|
|
263
|
+
<tbody>
|
|
264
|
+
{rows().map(row => {
|
|
265
|
+
${body}
|
|
266
|
+
})}
|
|
267
|
+
</tbody>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
`
|
|
271
|
+
|
|
272
|
+
const cases: Array<[string, string]> = [
|
|
273
|
+
[
|
|
274
|
+
'a per-row signal',
|
|
275
|
+
` const [open, setOpen] = createSignal(false)
|
|
276
|
+
return <tr key={row.id}><td>{row.label}</td></tr>`,
|
|
277
|
+
],
|
|
278
|
+
[
|
|
279
|
+
'a mutable local',
|
|
280
|
+
` let cls = ''
|
|
281
|
+
return <tr key={row.id} className={cls}><td>{row.label}</td></tr>`,
|
|
282
|
+
],
|
|
283
|
+
[
|
|
284
|
+
'a preamble local read in child position (a preamble-patched region)',
|
|
285
|
+
` const full = row.label + '!'
|
|
286
|
+
return <tr key={row.id}><td>{full}</td></tr>`,
|
|
287
|
+
],
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
for (const [name, body] of cases) {
|
|
291
|
+
test(name, () => {
|
|
292
|
+
const js = clientJs(rows(body), 'EagerRows.tsx')
|
|
293
|
+
expect(js).not.toContain('mapArrayLazy(')
|
|
294
|
+
expect(js).toMatch(/\bmapArray\(/)
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
})
|
|
@@ -48,12 +48,12 @@ function makeShape(overrides: Partial<LazyRowShapeFacts> = {}): LazyRowShapeFact
|
|
|
48
48
|
anchored: false,
|
|
49
49
|
bodyIsMultiRoot: false,
|
|
50
50
|
hasExplicitKey: true,
|
|
51
|
-
|
|
51
|
+
conditionalRefusal: null,
|
|
52
52
|
childRefCount: 0,
|
|
53
53
|
nestedComponentCount: 0,
|
|
54
54
|
innerLoopCount: 0,
|
|
55
55
|
hasChildComponent: false,
|
|
56
|
-
|
|
56
|
+
mapPreambleRefusal: null,
|
|
57
57
|
preambleRegionCount: 0,
|
|
58
58
|
hasParamUnwrap: false,
|
|
59
59
|
...overrides,
|
|
@@ -78,6 +78,7 @@ const itemBinding = (kind: 'attr' | 'text' = 'text') => ({
|
|
|
78
78
|
reactiveOuterNames: [] as string[],
|
|
79
79
|
opaqueOuterNames: [] as string[],
|
|
80
80
|
referencesIndex: false,
|
|
81
|
+
readsPreamble: false,
|
|
81
82
|
})
|
|
82
83
|
|
|
83
84
|
// --- eligibility ------------------------------------------------------------
|
|
@@ -120,12 +121,16 @@ describe('lazyRowEligibility — shape refusals', () => {
|
|
|
120
121
|
['anchored whole-item conditional', { anchored: true }, /anchored/],
|
|
121
122
|
['multi-root row', { bodyIsMultiRoot: true }, /multi-root/],
|
|
122
123
|
['index-keyed loop', { hasExplicitKey: false }, /index-keyed/],
|
|
123
|
-
[
|
|
124
|
+
[
|
|
125
|
+
'a conditional the apply bodies cannot drive',
|
|
126
|
+
{ conditionalRefusal: 'conditional on slot s9: its true arm owns events' },
|
|
127
|
+
/its true arm owns events/,
|
|
128
|
+
],
|
|
124
129
|
['imperative child ref', { childRefCount: 1 }, /child refs/],
|
|
125
130
|
['child-component body', { hasChildComponent: true }, /child component/],
|
|
126
131
|
['nested child components', { nestedComponentCount: 1 }, /nested child components/],
|
|
127
132
|
['inner loop', { innerLoopCount: 1 }, /inner loop/],
|
|
128
|
-
['map-callback preamble', {
|
|
133
|
+
['unsafe map-callback preamble', { mapPreambleRefusal: 'map-callback preamble declares a mutable binding (let/var)' }, /mutable binding/],
|
|
129
134
|
['preamble-patched regions', { preambleRegionCount: 1 }, /preamble-patched regions/],
|
|
130
135
|
['destructured param without bindings', { hasParamUnwrap: true }, /destructured loop param/],
|
|
131
136
|
]
|
|
@@ -422,7 +427,7 @@ describe('lazy row emission — eligible loop', () => {
|
|
|
422
427
|
|
|
423
428
|
test('applyItem claims refs lazily and dedups against entry.last', () => {
|
|
424
429
|
const applyItem = js.slice(js.indexOf('applyItem:'), js.indexOf('applyOuter:'))
|
|
425
|
-
expect(applyItem).toContain('__e.refs ?? (__e.refs =
|
|
430
|
+
expect(applyItem).toContain('__e.refs ?? (__e.refs = [])')
|
|
426
431
|
expect(applyItem).toContain('!Object.is(__l[1], __x)')
|
|
427
432
|
})
|
|
428
433
|
|
|
@@ -439,9 +444,20 @@ describe('lazy row emission — eligible loop', () => {
|
|
|
439
444
|
expect(applyOuter).toContain("__seed ? (__t.getAttribute('class') !== (__x != null ? String(__x) : null))")
|
|
440
445
|
})
|
|
441
446
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
447
|
+
/**
|
|
448
|
+
* An adopted row claims PER SLOT, on first use, scanning inside that one
|
|
449
|
+
* row. There is no whole-row claim closure: the previous shape made an
|
|
450
|
+
* `applyOuter` driving one attribute run a `qsa` for every OTHER
|
|
451
|
+
* reactive-attr slot in the row, on every row, at hydration.
|
|
452
|
+
*
|
|
453
|
+
* The `in` test rather than `??` is load-bearing — a slot whose scan found
|
|
454
|
+
* nothing must record that and not re-scan on the next tick.
|
|
455
|
+
*/
|
|
456
|
+
test('an adopted row claims each element ref on first use, not the whole row', () => {
|
|
457
|
+
expect(js).not.toContain('__lzc_l0')
|
|
458
|
+
const applyOuter = js.slice(js.indexOf('applyOuter:'))
|
|
459
|
+
// refIndex 0 (the row's only reactive-attr slot), slot id `s2`.
|
|
460
|
+
expect(applyOuter).toContain(`0 in __r ? __r[0] : (__r[0] = qsa(__e.primaryEl, '[bf="s2"]'))`)
|
|
445
461
|
})
|
|
446
462
|
|
|
447
463
|
/**
|
|
@@ -459,19 +475,20 @@ describe('lazy row emission — eligible loop', () => {
|
|
|
459
475
|
})
|
|
460
476
|
|
|
461
477
|
/**
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
478
|
+
* An adopted row builds NOTHING up front — not the element refs and not the
|
|
479
|
+
* content door. `applyOuter` here drives one CLASS and no text, so across a
|
|
480
|
+
* 1,000-row list it claims one element per row and never builds a door
|
|
481
|
+
* (the allocation the deferred door first pinned away: post-hydration heap
|
|
482
|
+
* 1630.6KB -> 1573.2KB), and never scans for a slot it does not write.
|
|
467
483
|
*
|
|
468
|
-
* `createRow` is deliberately excluded: it writes every
|
|
469
|
-
* builds the row, so its
|
|
484
|
+
* `createRow` is deliberately excluded: it writes every binding on the tick
|
|
485
|
+
* it builds the row, so its refs come from known clone paths and its door is
|
|
486
|
+
* used immediately.
|
|
470
487
|
*/
|
|
471
|
-
test('
|
|
472
|
-
const
|
|
473
|
-
expect(
|
|
474
|
-
expect(
|
|
488
|
+
test('an adopted row allocates nothing up front', () => {
|
|
489
|
+
const applyOuter = js.slice(js.indexOf('applyOuter:'))
|
|
490
|
+
expect(applyOuter).toContain('__e.refs ?? (__e.refs = [])')
|
|
491
|
+
expect(applyOuter).not.toContain('lazySlots(')
|
|
475
492
|
})
|
|
476
493
|
|
|
477
494
|
test('an attr-only applyOuter never materializes the door; applyItem does', () => {
|
|
@@ -568,16 +585,68 @@ describe('lazy row emission — outer-involving TEXT binding (§9.5 lifted)', ()
|
|
|
568
585
|
})
|
|
569
586
|
|
|
570
587
|
/**
|
|
571
|
-
* This row has no reactive ATTRIBUTE, so
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
* anything reads it.
|
|
588
|
+
* This row has no reactive ATTRIBUTE, so an adopted row's only claim is the
|
|
589
|
+
* content door — and that is materialized by the first content write, not up
|
|
590
|
+
* front. Pinned because the whole-row claim closure this replaced emitted a
|
|
591
|
+
* dead `const __el = __e.primaryEl` for exactly this shape.
|
|
576
592
|
*/
|
|
577
|
-
test('a text-only adopted
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
expect(
|
|
593
|
+
test('a text-only adopted row claims only the door, on first write', () => {
|
|
594
|
+
expect(js).not.toContain('__lzc_l0')
|
|
595
|
+
const applyItem = js.slice(js.indexOf('applyItem:'), js.indexOf('applyOuter:'))
|
|
596
|
+
expect(applyItem).toContain('__r[0] ?? (__r[0] = lazyClaimSlots(__e.primaryEl, __lzs_l0))')
|
|
597
|
+
})
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Per-slot claiming, counted (§9 W2).
|
|
602
|
+
*
|
|
603
|
+
* A row with THREE reactive-attr slots where `applyOuter` drives exactly one:
|
|
604
|
+
* the whole-row claim closure this replaced resolved all three on every row at
|
|
605
|
+
* hydration, so a 1,000-row list ran 3,000 `qsa` scans to write 1,000
|
|
606
|
+
* attributes. With each binding claiming its own slot, `applyOuter` contains
|
|
607
|
+
* ONE scan and the count is 1,000.
|
|
608
|
+
*
|
|
609
|
+
* Counted rather than timed on purpose: the existing SSR bench's row has a
|
|
610
|
+
* single reactive-attr slot, so it cannot show this — its `applyOuter` already
|
|
611
|
+
* claimed exactly what it wrote. The scan count is the thing that changed, and
|
|
612
|
+
* it is deterministic.
|
|
613
|
+
*/
|
|
614
|
+
const MULTI_SLOT = `
|
|
615
|
+
'use client'
|
|
616
|
+
import { createSignal } from '@barefootjs/client'
|
|
617
|
+
type Row = { id: number; label: string }
|
|
618
|
+
export function MultiSlotRows(props: { rows: Row[] }) {
|
|
619
|
+
const [rows, setRows] = createSignal<Row[]>(props.rows)
|
|
620
|
+
const [selected, setSelected] = createSignal(0)
|
|
621
|
+
return (
|
|
622
|
+
<tbody>
|
|
623
|
+
{rows().map(row => (
|
|
624
|
+
<tr key={row.id} className={selected() === row.id ? 'danger' : ''}>
|
|
625
|
+
<td title={row.label}>{row.id}</td>
|
|
626
|
+
<td data-x={row.id}>{row.label}</td>
|
|
627
|
+
</tr>
|
|
628
|
+
))}
|
|
629
|
+
</tbody>
|
|
630
|
+
)
|
|
631
|
+
}
|
|
632
|
+
`
|
|
633
|
+
|
|
634
|
+
describe('lazy row claiming — one scan per slot actually written', () => {
|
|
635
|
+
const js = clientJs(MULTI_SLOT, 'MultiSlotRows.tsx')
|
|
636
|
+
const section = (from: string, to?: string) =>
|
|
637
|
+
js.slice(js.indexOf(from), to ? js.indexOf(to) : js.indexOf("}, 'l0')"))
|
|
638
|
+
const scans = (text: string) => text.split('qsa(').length - 1
|
|
639
|
+
|
|
640
|
+
test('the row really has three reactive-attr slots', () => {
|
|
641
|
+
expect(scans(section('applyItem:', 'applyOuter:'))).toBe(3)
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
test('applyOuter scans only the slot it writes', () => {
|
|
645
|
+
expect(scans(section('applyOuter:'))).toBe(1)
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
test('createRow resolves refs from clone paths, never by scanning', () => {
|
|
649
|
+
expect(scans(section('createRow:', 'applyItem:'))).toBe(0)
|
|
581
650
|
})
|
|
582
651
|
})
|
|
583
652
|
|