@barefootjs/jsx 0.13.0 → 0.15.0
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/env-signal.d.ts +40 -0
- package/dist/adapters/env-signal.d.ts.map +1 -0
- package/dist/adapters/parsed-expr-emitter.d.ts +2 -1
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/augment-inherited-props.d.ts +42 -1
- package/dist/augment-inherited-props.d.ts.map +1 -1
- package/dist/builtins.d.ts +33 -0
- package/dist/builtins.d.ts.map +1 -0
- package/dist/compiler.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +48 -1
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +411 -26
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/profiler.d.ts +37 -0
- package/dist/profiler.d.ts.map +1 -1
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/compiler-stress-1244.test.ts +4 -2
- package/src/__tests__/expression-parser.test.ts +92 -1
- package/src/__tests__/ir-async.test.ts +8 -0
- package/src/__tests__/ir-builtin-import-scope.test.ts +188 -0
- package/src/__tests__/ir-region.test.ts +86 -0
- package/src/__tests__/profiler.test.ts +69 -0
- package/src/__tests__/ssr-defaults.test.ts +25 -0
- package/src/adapters/env-signal.ts +75 -0
- package/src/adapters/parsed-expr-emitter.ts +11 -0
- package/src/analyzer.ts +9 -0
- package/src/augment-inherited-props.ts +170 -2
- package/src/builtins.ts +63 -0
- package/src/compiler.ts +6 -2
- package/src/errors.ts +10 -0
- package/src/expression-parser.ts +156 -2
- package/src/index.ts +5 -2
- package/src/ir-to-client-js/imports.ts +5 -0
- package/src/jsx-to-ir.ts +189 -8
- package/src/profiler.ts +63 -0
- package/src/ssr-defaults.ts +55 -17
- package/src/types.ts +16 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { analyzeComponent } from '../analyzer'
|
|
3
|
+
import { jsxToIR } from '../jsx-to-ir'
|
|
4
|
+
import { ErrorCodes } from '../errors'
|
|
5
|
+
import { stripClientBuiltinImports } from '../builtins'
|
|
6
|
+
import type { IRAsync, IRElement, IRComponent, ImportInfo } from '../types'
|
|
7
|
+
|
|
8
|
+
// Import-scoped recognition for the compile-away built-ins `<Async>` /
|
|
9
|
+
// `<Region>` (#1915). The compiler recognises them by their
|
|
10
|
+
// `@barefootjs/client` import — never by a bare capitalized tag name — so a
|
|
11
|
+
// user's own component does not collide with the built-in, and the import is
|
|
12
|
+
// elided on emit.
|
|
13
|
+
|
|
14
|
+
function ir(source: string, path = 'Comp.tsx') {
|
|
15
|
+
const ctx = analyzeComponent(source, path)
|
|
16
|
+
return { ir: jsxToIR(ctx), ctx }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('import-scoped recognition for <Async> / <Region>', () => {
|
|
20
|
+
test('<Async> imported from @barefootjs/client lowers to IRAsync', () => {
|
|
21
|
+
const { ir: root, ctx } = ir(`
|
|
22
|
+
import { Async } from '@barefootjs/client'
|
|
23
|
+
export function Page() {
|
|
24
|
+
return <div><Async fallback={<p>Loading</p>}><Body /></Async></div>
|
|
25
|
+
}
|
|
26
|
+
`)
|
|
27
|
+
expect(ctx.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
28
|
+
const div = root as IRElement
|
|
29
|
+
const async = div.children.find(c => c.type === 'async') as IRAsync
|
|
30
|
+
expect(async?.type).toBe('async')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('<Region> imported from @barefootjs/client lowers to a region element', () => {
|
|
34
|
+
const { ir: root } = ir(`
|
|
35
|
+
import { Region } from '@barefootjs/client'
|
|
36
|
+
export function Shell({ children }) {
|
|
37
|
+
return <div><Region>{children}</Region></div>
|
|
38
|
+
}
|
|
39
|
+
`)
|
|
40
|
+
const div = root as IRElement
|
|
41
|
+
const region = div.children.find(
|
|
42
|
+
(c): c is IRElement => c.type === 'element' && c.regionId !== undefined,
|
|
43
|
+
)
|
|
44
|
+
expect(region).toBeDefined()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('aliased import `Async as Boundary` recognises <Boundary> as the built-in', () => {
|
|
48
|
+
const { ir: root } = ir(`
|
|
49
|
+
import { Async as Boundary } from '@barefootjs/client'
|
|
50
|
+
export function Page() {
|
|
51
|
+
return <Boundary fallback={<p>Loading</p>}><Body /></Boundary>
|
|
52
|
+
}
|
|
53
|
+
`)
|
|
54
|
+
expect((root as IRAsync).type).toBe('async')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("a user's own <Async> (imported elsewhere) is NOT lowered and emits no diagnostic", () => {
|
|
58
|
+
const { ir: root, ctx } = ir(`
|
|
59
|
+
import { Async } from './my-async'
|
|
60
|
+
export function Page() {
|
|
61
|
+
return <div><Async fallback={<p>x</p>}><Body /></Async></div>
|
|
62
|
+
}
|
|
63
|
+
`)
|
|
64
|
+
expect(ctx.errors.find(e => e.code === ErrorCodes.BUILTIN_REQUIRES_IMPORT)).toBeUndefined()
|
|
65
|
+
const div = root as IRElement
|
|
66
|
+
expect(div.children.some(c => c.type === 'async')).toBe(false)
|
|
67
|
+
const comp = div.children.find((c): c is IRComponent => c.type === 'component')
|
|
68
|
+
expect(comp?.name).toBe('Async')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('a locally declared Async component is NOT treated as the built-in', () => {
|
|
72
|
+
const { ir: root, ctx } = ir(`
|
|
73
|
+
function Async(props) { return <section>{props.children}</section> }
|
|
74
|
+
export function Page() {
|
|
75
|
+
return <Async fallback={<p>x</p>}><Body /></Async>
|
|
76
|
+
}
|
|
77
|
+
`)
|
|
78
|
+
expect(ctx.errors.find(e => e.code === ErrorCodes.BUILTIN_REQUIRES_IMPORT)).toBeUndefined()
|
|
79
|
+
expect((root as IRElement).type).not.toBe('async')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('bare <Async> with no import and no binding reports BF054', () => {
|
|
83
|
+
const { ctx } = ir(`
|
|
84
|
+
export function Page() {
|
|
85
|
+
return <Async fallback={<p>Loading</p>}><Body /></Async>
|
|
86
|
+
}
|
|
87
|
+
`)
|
|
88
|
+
const err = ctx.errors.find(e => e.code === ErrorCodes.BUILTIN_REQUIRES_IMPORT)
|
|
89
|
+
expect(err).toBeDefined()
|
|
90
|
+
expect(err?.severity).toBe('error')
|
|
91
|
+
expect(err?.message).toContain('@barefootjs/client')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('a type-only import does NOT scope the built-in and does not suppress BF054', () => {
|
|
95
|
+
// `import type { Async }` brings no value binding into scope — the design
|
|
96
|
+
// is import-value-required, so <Async> must still raise BF054.
|
|
97
|
+
const { ir: root, ctx } = ir(`
|
|
98
|
+
import type { Async } from '@barefootjs/client'
|
|
99
|
+
export function Page() {
|
|
100
|
+
return <Async fallback={<p>Loading</p>}><Body /></Async>
|
|
101
|
+
}
|
|
102
|
+
`)
|
|
103
|
+
expect((root as IRElement).type).not.toBe('async')
|
|
104
|
+
expect(ctx.errors.find(e => e.code === ErrorCodes.BUILTIN_REQUIRES_IMPORT)).toBeDefined()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('a per-specifier type-only import (`import { type Async }`) does NOT scope the built-in', () => {
|
|
108
|
+
const { ir: root, ctx } = ir(`
|
|
109
|
+
import { type Async } from '@barefootjs/client'
|
|
110
|
+
export function Page() {
|
|
111
|
+
return <Async fallback={<p>Loading</p>}><Body /></Async>
|
|
112
|
+
}
|
|
113
|
+
`)
|
|
114
|
+
expect((root as IRElement).type).not.toBe('async')
|
|
115
|
+
expect(ctx.errors.find(e => e.code === ErrorCodes.BUILTIN_REQUIRES_IMPORT)).toBeDefined()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('a value specifier alongside a type-only one is still recognised (`import { type Async, Region }`)', () => {
|
|
119
|
+
const { ir: root } = ir(`
|
|
120
|
+
import { type Async, Region } from '@barefootjs/client'
|
|
121
|
+
export function Shell({ children }) {
|
|
122
|
+
return <div><Region>{children}</Region></div>
|
|
123
|
+
}
|
|
124
|
+
`)
|
|
125
|
+
const div = root as IRElement
|
|
126
|
+
expect(div.children.some(c => c.type === 'element' && c.regionId !== undefined)).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('bare <Region /> with no import reports BF054', () => {
|
|
130
|
+
const { ctx } = ir(`
|
|
131
|
+
export function Shell() {
|
|
132
|
+
return <Region />
|
|
133
|
+
}
|
|
134
|
+
`)
|
|
135
|
+
expect(ctx.errors.find(e => e.code === ErrorCodes.BUILTIN_REQUIRES_IMPORT)).toBeDefined()
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('stripClientBuiltinImports (emit-time elision)', () => {
|
|
140
|
+
const loc = { file: 'x.tsx', line: 1, column: 1 }
|
|
141
|
+
const imp = (source: string, names: string[]): ImportInfo => ({
|
|
142
|
+
source,
|
|
143
|
+
isTypeOnly: false,
|
|
144
|
+
loc,
|
|
145
|
+
specifiers: names.map(n => ({ name: n, alias: null, isDefault: false, isNamespace: false })),
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('drops the @barefootjs/client import when it only carried built-ins', () => {
|
|
149
|
+
expect(stripClientBuiltinImports([imp('@barefootjs/client', ['Async', 'Region'])])).toEqual([])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('keeps non-built-in specifiers and drops the built-ins', () => {
|
|
153
|
+
const out = stripClientBuiltinImports([imp('@barefootjs/client', ['createSignal', 'Async'])])
|
|
154
|
+
expect(out).toHaveLength(1)
|
|
155
|
+
expect(out[0].specifiers.map(s => s.name)).toEqual(['createSignal'])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('leaves imports from other sources untouched', () => {
|
|
159
|
+
const other = imp('./my-async', ['Async'])
|
|
160
|
+
expect(stripClientBuiltinImports([other])).toEqual([other])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('does NOT strip type-only imports (never a runtime phantom)', () => {
|
|
164
|
+
const typeOnly: ImportInfo = { ...imp('@barefootjs/client', ['Async', 'Region']), isTypeOnly: true }
|
|
165
|
+
expect(stripClientBuiltinImports([typeOnly])).toEqual([typeOnly])
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('preserves a side-effect import of @barefootjs/client', () => {
|
|
169
|
+
const sideEffect = imp('@barefootjs/client', [])
|
|
170
|
+
expect(stripClientBuiltinImports([sideEffect])).toEqual([sideEffect])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('does NOT strip a per-specifier type-only built-in, but strips the value one', () => {
|
|
174
|
+
const mixed: ImportInfo = {
|
|
175
|
+
source: '@barefootjs/client',
|
|
176
|
+
isTypeOnly: false,
|
|
177
|
+
loc,
|
|
178
|
+
specifiers: [
|
|
179
|
+
{ name: 'Async', alias: null, isDefault: false, isNamespace: false, isTypeOnly: true },
|
|
180
|
+
{ name: 'Region', alias: null, isDefault: false, isNamespace: false, isTypeOnly: false },
|
|
181
|
+
],
|
|
182
|
+
}
|
|
183
|
+
const out = stripClientBuiltinImports([mixed])
|
|
184
|
+
expect(out).toHaveLength(1)
|
|
185
|
+
expect(out[0].specifiers.map(s => s.name)).toEqual(['Async'])
|
|
186
|
+
expect(out[0].specifiers[0].isTypeOnly).toBe(true)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { analyzeComponent } from '../analyzer'
|
|
3
|
+
import { jsxToIR } from '../jsx-to-ir'
|
|
4
|
+
import { compileJSX } from '../compiler'
|
|
5
|
+
import { HonoAdapter } from '../../../../packages/adapter-hono/src/adapter/hono-adapter'
|
|
6
|
+
import type { IRElement } from '../types'
|
|
7
|
+
|
|
8
|
+
const LAYOUT = `
|
|
9
|
+
import { Region } from '@barefootjs/client'
|
|
10
|
+
export function Layout({ title, children }) {
|
|
11
|
+
return (
|
|
12
|
+
<div>
|
|
13
|
+
<h1>{title}</h1>
|
|
14
|
+
<Region>{children}</Region>
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
`
|
|
19
|
+
|
|
20
|
+
function regionEl(source: string, path: string): IRElement {
|
|
21
|
+
const ir = jsxToIR(analyzeComponent(source, path))
|
|
22
|
+
const root = ir as IRElement
|
|
23
|
+
const region = root.children.find(
|
|
24
|
+
(c): c is IRElement => c.type === 'element' && c.regionId !== undefined,
|
|
25
|
+
)
|
|
26
|
+
if (!region) throw new Error('no region element found')
|
|
27
|
+
return region
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function markedTemplate(source: string, path: string): string {
|
|
31
|
+
const result = compileJSX(source, path, { adapter: new HonoAdapter() })
|
|
32
|
+
expect(result.errors).toHaveLength(0)
|
|
33
|
+
const marked = result.files.find(f => f.type === 'markedTemplate')
|
|
34
|
+
if (!marked) throw new Error('no markedTemplate emitted')
|
|
35
|
+
return marked.content
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('<Region> page-lifecycle boundary', () => {
|
|
39
|
+
test('lowers <Region>{children}</Region> to a div carrying a regionId', () => {
|
|
40
|
+
const region = regionEl(LAYOUT, 'Layout.tsx')
|
|
41
|
+
expect(region.tag).toBe('div')
|
|
42
|
+
expect(region.regionId).toMatch(/^[0-9a-f]{8}:0$/)
|
|
43
|
+
// The children passed to the region are preserved inside it.
|
|
44
|
+
expect(region.children.length).toBeGreaterThan(0)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('assigns sequential structural indices within a file', () => {
|
|
48
|
+
const source = `
|
|
49
|
+
import { Region } from '@barefootjs/client'
|
|
50
|
+
export function Split() {
|
|
51
|
+
return (
|
|
52
|
+
<div>
|
|
53
|
+
<Region><aside /></Region>
|
|
54
|
+
<Region><main /></Region>
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
`
|
|
59
|
+
const ir = jsxToIR(analyzeComponent(source, 'Split.tsx')) as IRElement
|
|
60
|
+
const ids = ir.children
|
|
61
|
+
.filter((c): c is IRElement => c.type === 'element' && c.regionId !== undefined)
|
|
62
|
+
.map(c => c.regionId)
|
|
63
|
+
expect(ids).toHaveLength(2)
|
|
64
|
+
expect(ids[0]).toMatch(/:0$/)
|
|
65
|
+
expect(ids[1]).toMatch(/:1$/)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('Hono adapter emits bf-region on the boundary element', () => {
|
|
69
|
+
expect(markedTemplate(LAYOUT, 'Layout.tsx')).toMatch(/bf-region="[0-9a-f]{8}:0"/)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('region id is deterministic across compiles (not per-run random)', () => {
|
|
73
|
+
// The load-bearing requirement: a layout compiled twice — as it would be
|
|
74
|
+
// when two different pages each compose it — must emit the *same* id, so
|
|
75
|
+
// the client router can match the same region across page documents.
|
|
76
|
+
const a = regionEl(LAYOUT, '/app/shell.tsx').regionId
|
|
77
|
+
const b = regionEl(LAYOUT, '/app/shell.tsx').regionId
|
|
78
|
+
expect(a).toBe(b)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('region id differs across layout files (cross-file uniqueness)', () => {
|
|
82
|
+
const a = regionEl(LAYOUT, '/app/shell-a.tsx').regionId
|
|
83
|
+
const b = regionEl(LAYOUT, '/app/shell-b.tsx').regionId
|
|
84
|
+
expect(a).not.toBe(b)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { describe, test, expect } from 'bun:test'
|
|
11
11
|
import {
|
|
12
|
+
PROFILE_SCHEMA_VERSION,
|
|
12
13
|
buildStaticBudget,
|
|
13
14
|
diffStaticBudget,
|
|
14
15
|
formatStaticBudget,
|
|
@@ -179,6 +180,73 @@ describe('buildStaticBudget (SR5)', () => {
|
|
|
179
180
|
expect(b.subscriptions).toBe(0)
|
|
180
181
|
expect(b.crossComponentOnly).toBe(false)
|
|
181
182
|
})
|
|
183
|
+
|
|
184
|
+
test('exposes the handlers --scenario auto would fire, name + loc (#1841 A1)', () => {
|
|
185
|
+
// Two handlers on distinct elements/events. The static list is the coverage
|
|
186
|
+
// gap an agent can read before any run.
|
|
187
|
+
const src = `
|
|
188
|
+
'use client'
|
|
189
|
+
import { createSignal } from '@barefootjs/client'
|
|
190
|
+
export function Form() {
|
|
191
|
+
const [q, setQ] = createSignal('')
|
|
192
|
+
return (
|
|
193
|
+
<div>
|
|
194
|
+
<input onInput={(e) => setQ(e.currentTarget.value)} />
|
|
195
|
+
<button onClick={() => setQ('')}>Clear</button>
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
`
|
|
200
|
+
const b = buildStaticBudget(src, 'Form.tsx', 'Form')
|
|
201
|
+
expect(b.handlers.length).toBe(2)
|
|
202
|
+
// `name` is `<event>@<slotId>` — the slotId joins to dynamic coverage.
|
|
203
|
+
expect(b.handlers.every(h => /^\w+@\w+$/.test(h.name))).toBe(true)
|
|
204
|
+
const events = b.handlers.map(h => h.name.split('@')[0]).sort()
|
|
205
|
+
expect(events).toEqual(['click', 'input'])
|
|
206
|
+
// Each handler carries a source location.
|
|
207
|
+
for (const h of b.handlers) {
|
|
208
|
+
expect(h.loc.file).toContain('Form.tsx')
|
|
209
|
+
expect(h.loc.line).toBeGreaterThan(0)
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('handlers is empty when the component binds none', () => {
|
|
214
|
+
const src = `
|
|
215
|
+
'use client'
|
|
216
|
+
import { createSignal } from '@barefootjs/client'
|
|
217
|
+
export function Display() {
|
|
218
|
+
const [n] = createSignal(0)
|
|
219
|
+
return <div>{n()}</div>
|
|
220
|
+
}
|
|
221
|
+
`
|
|
222
|
+
const b = buildStaticBudget(src, 'Display.tsx', 'Display')
|
|
223
|
+
expect(b.handlers).toEqual([])
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('renders a handlers section in text output when present, omits it otherwise', () => {
|
|
227
|
+
const withHandler = formatStaticBudget(buildStaticBudget(counterSource, 'Counter.tsx', 'Counter'))
|
|
228
|
+
expect(withHandler).toMatch(/handlers \(1\):/)
|
|
229
|
+
expect(withHandler).toMatch(/click@\w+\s+Counter\.tsx:\d+/)
|
|
230
|
+
|
|
231
|
+
const noHandler = `
|
|
232
|
+
'use client'
|
|
233
|
+
import { createSignal } from '@barefootjs/client'
|
|
234
|
+
export function Display() {
|
|
235
|
+
const [n] = createSignal(0)
|
|
236
|
+
return <div>{n()}</div>
|
|
237
|
+
}
|
|
238
|
+
`
|
|
239
|
+
expect(formatStaticBudget(buildStaticBudget(noHandler, 'Display.tsx', 'Display'))).not.toContain('handlers (')
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('schemaVersion (#1841)', () => {
|
|
244
|
+
test('every JSON mode carries the schema version', () => {
|
|
245
|
+
const budget = buildStaticBudget(counterSource, 'Counter.tsx', 'Counter')
|
|
246
|
+
expect(budget.schemaVersion).toBe(PROFILE_SCHEMA_VERSION)
|
|
247
|
+
const diff = diffStaticBudget(budget, buildStaticBudget(memoChainSource, 'Calc.tsx', 'Calc'))
|
|
248
|
+
expect(diff.schemaVersion).toBe(PROFILE_SCHEMA_VERSION)
|
|
249
|
+
})
|
|
182
250
|
})
|
|
183
251
|
|
|
184
252
|
describe('diffStaticBudget (SR6)', () => {
|
|
@@ -345,6 +413,7 @@ describe('buildProfileReport (dynamic, SR1–SR4 + analyses)', () => {
|
|
|
345
413
|
]
|
|
346
414
|
const r = buildProfileReport({ source: src, filePath: 'Calc.tsx', scenario: 'auto', events })
|
|
347
415
|
expect(r.kind).toBe('profile')
|
|
416
|
+
expect(r.schemaVersion).toBe(PROFILE_SCHEMA_VERSION)
|
|
348
417
|
expect(r.componentName).toBe('Calc')
|
|
349
418
|
expect(r.turns).toBe(1)
|
|
350
419
|
expect(r.hotSubscribers.subscribers[0].name).toBe('a')
|
|
@@ -99,6 +99,31 @@ describe('extractSsrDefaults', () => {
|
|
|
99
99
|
expect(defaults?.doubled).toEqual({ value: 10 })
|
|
100
100
|
})
|
|
101
101
|
|
|
102
|
+
test('block-body memo with an early-return guard folds to the default-state branch (#1897)', () => {
|
|
103
|
+
// The data-table `sortedData` shape: a `/* @client */`-guarded sort
|
|
104
|
+
// whose early return yields the unsorted module-const array when the
|
|
105
|
+
// sort-key signal is at its initial (null) value. The SSR default is
|
|
106
|
+
// that early-return array, not `null` — the `if (!key)` guard is
|
|
107
|
+
// taken because `sortKey()` resolves to its seeded `null` initial.
|
|
108
|
+
const metadata = metadataFor(`
|
|
109
|
+
'use client'
|
|
110
|
+
import { createSignal, createMemo } from '@barefootjs/client'
|
|
111
|
+
const rows = [{ id: 'a' }, { id: 'b' }]
|
|
112
|
+
function Table() {
|
|
113
|
+
const [sortKey, setSortKey] = createSignal<string | null>(null)
|
|
114
|
+
const sorted = createMemo(() => {
|
|
115
|
+
const key = sortKey()
|
|
116
|
+
if (!key) return rows
|
|
117
|
+
return /* @client */ [...rows].sort((a, b) => a.id < b.id ? -1 : 1)
|
|
118
|
+
})
|
|
119
|
+
return <ul>{sorted().map(r => <li>{r.id}</li>)}</ul>
|
|
120
|
+
}
|
|
121
|
+
`)
|
|
122
|
+
|
|
123
|
+
const defaults = extractSsrDefaults(metadata)
|
|
124
|
+
expect(defaults?.sorted).toEqual({ value: [{ id: 'a' }, { id: 'b' }] })
|
|
125
|
+
})
|
|
126
|
+
|
|
102
127
|
test('non-evaluable initials yield null (caller falls back at render time)', () => {
|
|
103
128
|
const metadata = metadataFor(`
|
|
104
129
|
'use client'
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Shared recognition of the router v0.5 `searchParams()` environment signal
|
|
2
|
+
// for the template-string adapters (Mojo / Xslate today; Go has its own
|
|
3
|
+
// struct-field path). A request-scoped reactive env signal reads like an
|
|
4
|
+
// ordinary signal getter, but its value is a URLSearchParams-like reader with
|
|
5
|
+
// real methods (`.get(key)`) — not a hashref. The generic `member` lowering
|
|
6
|
+
// would deref `.get` as a property access and drop the call + argument, so the
|
|
7
|
+
// adapters special-case it to a real per-request reader method call. See #1922.
|
|
8
|
+
|
|
9
|
+
import type { ParsedExpr } from '../expression-parser.ts'
|
|
10
|
+
import type { IRMetadata } from '../types.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The local binding name(s) that `searchParams` from `@barefootjs/client` is
|
|
14
|
+
* imported under in this component — the same import the analyzer allow-lists
|
|
15
|
+
* (`CLIENT_EXPORTS`). Usually the single name `searchParams`, but an aliased
|
|
16
|
+
* import (`import { searchParams as sp }`) binds it to `sp`, and the template
|
|
17
|
+
* expression then reads `sp()` — so adapters must gate + match against the
|
|
18
|
+
* LOCAL name(s), not the literal `searchParams`. Empty when the env signal is
|
|
19
|
+
* not imported (the component keeps the generic signal lowering).
|
|
20
|
+
*
|
|
21
|
+
* `ImportSpecifier.name` is the exported name and `alias` the local rebinding
|
|
22
|
+
* (see `collectImport` in analyzer.ts), so the import is detected by `name ===
|
|
23
|
+
* 'searchParams'` and the local binding is `alias ?? name`. Namespace / default
|
|
24
|
+
* specifiers bind a different identifier and are excluded.
|
|
25
|
+
*/
|
|
26
|
+
export function searchParamsLocalNames(metadata: IRMetadata): Set<string> {
|
|
27
|
+
const names = new Set<string>()
|
|
28
|
+
for (const imp of metadata.imports) {
|
|
29
|
+
if (imp.source !== '@barefootjs/client' || imp.isTypeOnly) continue
|
|
30
|
+
for (const s of imp.specifiers) {
|
|
31
|
+
if (s.isTypeOnly || s.isNamespace || s.isDefault) continue
|
|
32
|
+
if (s.name === 'searchParams') names.add(s.alias ?? s.name)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return names
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True when the component imports the `searchParams` env signal under any local
|
|
40
|
+
* name. Convenience for adapters/harnesses that only need to gate on presence
|
|
41
|
+
* (the lowering itself needs the {@link searchParamsLocalNames} set to match the
|
|
42
|
+
* actual binding in the expression).
|
|
43
|
+
*/
|
|
44
|
+
export function importsSearchParams(metadata: IRMetadata): boolean {
|
|
45
|
+
return searchParamsLocalNames(metadata).size > 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Recognise a `<binding>().<method>(<args>)` env-signal method call from a
|
|
50
|
+
* `call` node's callee + args, where `<binding>` is one of the local names
|
|
51
|
+
* `searchParams` was imported under (`localNames`, from
|
|
52
|
+
* {@link searchParamsLocalNames}). Returns the method name and argument nodes
|
|
53
|
+
* when the receiver is the zero-arg env-signal getter, else null. The caller
|
|
54
|
+
* lowers the match to a real method call on its per-request reader object
|
|
55
|
+
* (`$searchParams->get('sort')` in Mojo, `$searchParams.get('sort')` in
|
|
56
|
+
* Xslate — the canonical `$searchParams` var regardless of the JS alias);
|
|
57
|
+
* without it the generic `member` lowering drops the call + arg.
|
|
58
|
+
*/
|
|
59
|
+
export function matchSearchParamsMethodCall(
|
|
60
|
+
callee: ParsedExpr,
|
|
61
|
+
args: ParsedExpr[],
|
|
62
|
+
localNames: ReadonlySet<string>,
|
|
63
|
+
): { method: string; args: ParsedExpr[] } | null {
|
|
64
|
+
if (callee.kind !== 'member' || callee.computed) return null
|
|
65
|
+
const recv = callee.object
|
|
66
|
+
if (
|
|
67
|
+
recv.kind !== 'call' ||
|
|
68
|
+
recv.args.length !== 0 ||
|
|
69
|
+
recv.callee.kind !== 'identifier' ||
|
|
70
|
+
!localNames.has(recv.callee.name)
|
|
71
|
+
) {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
return { method: callee.property, args }
|
|
75
|
+
}
|
|
@@ -66,6 +66,7 @@ export type ArrayMethod =
|
|
|
66
66
|
| 'toLowerCase'
|
|
67
67
|
| 'toUpperCase'
|
|
68
68
|
| 'trim'
|
|
69
|
+
| 'toFixed'
|
|
69
70
|
| 'split'
|
|
70
71
|
| 'startsWith'
|
|
71
72
|
| 'endsWith'
|
|
@@ -115,6 +116,14 @@ export interface ParsedExprEmitter {
|
|
|
115
116
|
computed: boolean,
|
|
116
117
|
emit: (e: ParsedExpr) => string,
|
|
117
118
|
): string
|
|
119
|
+
// Element access with a non-literal index (`arr[index]`). The index
|
|
120
|
+
// is a full `ParsedExpr` (loop variable, arithmetic, etc.); the
|
|
121
|
+
// adapter picks array vs hash deref per target language. #1897.
|
|
122
|
+
indexAccess(
|
|
123
|
+
object: ParsedExpr,
|
|
124
|
+
index: ParsedExpr,
|
|
125
|
+
emit: (e: ParsedExpr) => string,
|
|
126
|
+
): string
|
|
118
127
|
binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string
|
|
119
128
|
unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string
|
|
120
129
|
logical(
|
|
@@ -196,6 +205,8 @@ export function emitParsedExpr(expr: ParsedExpr, emitter: ParsedExprEmitter): st
|
|
|
196
205
|
return emitter.call(expr.callee, expr.args, emit)
|
|
197
206
|
case 'member':
|
|
198
207
|
return emitter.member(expr.object, expr.property, expr.computed, emit)
|
|
208
|
+
case 'index-access':
|
|
209
|
+
return emitter.indexAccess(expr.object, expr.index, emit)
|
|
199
210
|
case 'binary':
|
|
200
211
|
return emitter.binary(expr.op, expr.left, expr.right, emit)
|
|
201
212
|
case 'unary':
|
package/src/analyzer.ts
CHANGED
|
@@ -1648,6 +1648,13 @@ const CLIENT_EXPORTS = new Set([
|
|
|
1648
1648
|
'forwardProps', 'unwrap', '__slot',
|
|
1649
1649
|
'createContext', 'useContext', 'provideContext',
|
|
1650
1650
|
'createPortal', 'isSSRPortal', 'findSiblingSlot', 'cleanupPortalPlaceholder',
|
|
1651
|
+
// Request-scoped environment signal (router v0.5) — a real user-facing
|
|
1652
|
+
// reactive export the compiler lowers like any other `@barefootjs/client`
|
|
1653
|
+
// signal read (SSR: a template binding; client: a `createEffect`).
|
|
1654
|
+
'searchParams',
|
|
1655
|
+
// Compile-away JSX built-ins (#1915) — importing them is what scopes the
|
|
1656
|
+
// compiler's `<Async>` / `<Region>` recognition; the import is elided on emit.
|
|
1657
|
+
'Async', 'Region',
|
|
1651
1658
|
])
|
|
1652
1659
|
|
|
1653
1660
|
/**
|
|
@@ -1748,6 +1755,8 @@ function collectImport(node: ts.ImportDeclaration, ctx: AnalyzerContext): void {
|
|
|
1748
1755
|
alias: element.propertyName ? element.name.text : null,
|
|
1749
1756
|
isDefault: false,
|
|
1750
1757
|
isNamespace: false,
|
|
1758
|
+
// Per-specifier `import { type Foo }` — no value binding (#1915).
|
|
1759
|
+
isTypeOnly: element.isTypeOnly,
|
|
1751
1760
|
})
|
|
1752
1761
|
}
|
|
1753
1762
|
}
|