@barefootjs/jsx 0.33.2 → 0.33.3
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.d.ts +17 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/compiler.d.ts +21 -5
- package/dist/compiler.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +707 -431
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +60 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/prop-handling.d.ts +4 -7
- package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +26 -2
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/props-binding.d.ts +35 -0
- package/dist/props-binding.d.ts.map +1 -1
- package/dist/types.d.ts +50 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +145 -97
- package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
- package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
- package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
- package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
- package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
- package/src/__tests__/merge-template-imports.test.ts +41 -1
- package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
- package/src/__tests__/root-key-relay.test.ts +170 -0
- package/src/__tests__/signal-getter-not-called.test.ts +149 -0
- package/src/__tests__/state-only-file-default-import.test.ts +47 -0
- package/src/analyzer.ts +36 -0
- package/src/compiler.ts +94 -104
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/collect-elements.ts +27 -5
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
- package/src/ir-to-client-js/html-template.ts +122 -12
- package/src/ir-to-client-js/imports.ts +178 -5
- package/src/ir-to-client-js/index.ts +5 -0
- package/src/ir-to-client-js/prop-handling.ts +6 -17
- package/src/ir-to-client-js/utils.ts +30 -2
- package/src/jsx-to-ir.ts +480 -52
- package/src/props-binding.ts +51 -0
- package/src/types.ts +47 -13
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2767 follow-up: two sibling components in the SAME multi-component file
|
|
3
|
+
* compile independently from the same module-scope default import — each
|
|
4
|
+
* component's compiled client JS only lists the specifiers IT actually
|
|
5
|
+
* uses, so one component can emit `import cfg from './config'` while
|
|
6
|
+
* another emits `import cfg, { helper } from './config'` for the exact
|
|
7
|
+
* same source declaration. `compileMultipleComponents`'s client-JS merge
|
|
8
|
+
* used to dedupe import lines by EXACT STRING, which kept both — a hard
|
|
9
|
+
* `SyntaxError: Identifier 'cfg' has already been declared` in the merged
|
|
10
|
+
* `.client.js`, with zero compile diagnostics. This is an end-to-end test
|
|
11
|
+
* through the real `compileJSX` multi-component path (not the unit-level
|
|
12
|
+
* `mergeTemplateImports`/`collectExternalImports` tests, which are correct
|
|
13
|
+
* per-component but can't see this cross-component merge on their own).
|
|
14
|
+
*/
|
|
15
|
+
import { describe, test, expect } from 'bun:test'
|
|
16
|
+
import { compileJSX } from '../compiler'
|
|
17
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
18
|
+
|
|
19
|
+
const adapter = new TestAdapter()
|
|
20
|
+
|
|
21
|
+
describe('multi-component file sharing a default import (#2767 follow-up)', () => {
|
|
22
|
+
test('merges into one import line instead of redeclaring the default binding', () => {
|
|
23
|
+
const source = `
|
|
24
|
+
'use client'
|
|
25
|
+
import cfg, { helper } from './config'
|
|
26
|
+
import { createSignal } from '@barefootjs/client'
|
|
27
|
+
|
|
28
|
+
export function CompA() {
|
|
29
|
+
const [n, setN] = createSignal(cfg.start)
|
|
30
|
+
return <button onClick={() => setN(n() + 1)}>{n()}</button>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function CompB() {
|
|
34
|
+
const [m, setM] = createSignal(cfg.start + helper())
|
|
35
|
+
return <button onClick={() => setM(m() + 1)}>{m()}</button>
|
|
36
|
+
}
|
|
37
|
+
`
|
|
38
|
+
const result = compileJSX(source, 'shared-default.tsx', { adapter })
|
|
39
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
40
|
+
expect(errors).toEqual([])
|
|
41
|
+
|
|
42
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
43
|
+
expect(clientJs).toBeDefined()
|
|
44
|
+
|
|
45
|
+
const importLines = clientJs!.content
|
|
46
|
+
.split('\n')
|
|
47
|
+
.filter(l => l.includes("from './config'"))
|
|
48
|
+
// Exactly one declaration for './config' — not one per component.
|
|
49
|
+
expect(importLines).toEqual(["import cfg, { helper } from './config'"])
|
|
50
|
+
|
|
51
|
+
// The binding is declared exactly once, not once per component.
|
|
52
|
+
const declarationCount = (clientJs!.content.match(/\bimport\s+cfg\b/g) ?? []).length
|
|
53
|
+
expect(declarationCount).toBe(1)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-root row-key relay (`IRElement.keyAttr` with no `value`) — #2753's
|
|
3
|
+
* "mechanism 2".
|
|
4
|
+
*
|
|
5
|
+
* A component's rendered root is whatever element carries `bf-s`
|
|
6
|
+
* (`needsScope`). When that component is used as a caller's keyed loop row,
|
|
7
|
+
* the caller's key has to land on THAT element: `mapArray` reconciles rows
|
|
8
|
+
* by reading the key attribute off the row's primary element, and the CSR
|
|
9
|
+
* half of the contract (`renderChild` / `materializeComponent` in
|
|
10
|
+
* `@barefootjs/client`) splices `data-key` onto the rendered markup's first
|
|
11
|
+
* element regardless of what wrapper nodes sit above it. `resolveRootKeyAttr`
|
|
12
|
+
* is the SSR half, and it must agree.
|
|
13
|
+
*
|
|
14
|
+
* The regression these tests exist for: resolving the relay by walking DOWN
|
|
15
|
+
* from the IR root and stopping at the first node that is not an
|
|
16
|
+
* element/fragment/if-statement. `<Ctx.Provider>` is neither, but
|
|
17
|
+
* `transformProviderElement` passes `ctx.isRoot` through to its children — so
|
|
18
|
+
* a provider-rooted component (select, popover, accordion, carousel,
|
|
19
|
+
* combobox, command, dropdown-menu, radio-group) has a `needsScope` element
|
|
20
|
+
* the walk never reaches, and every adapter silently stopped relaying its
|
|
21
|
+
* caller's key.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, test, expect } from 'bun:test'
|
|
24
|
+
import { analyzeComponent } from '../analyzer'
|
|
25
|
+
import { jsxToIR } from '../jsx-to-ir'
|
|
26
|
+
import type { IRElement, IRNode } from '../types'
|
|
27
|
+
|
|
28
|
+
function compile(source: string, name = 'Test'): IRNode {
|
|
29
|
+
const ir = jsxToIR(analyzeComponent(source, `${name}.tsx`))
|
|
30
|
+
expect(ir).not.toBeNull()
|
|
31
|
+
return ir!
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every `element` node in the tree, in document order. */
|
|
35
|
+
function allElements(node: IRNode): IRElement[] {
|
|
36
|
+
const out: IRElement[] = []
|
|
37
|
+
const visit = (n: IRNode | null | undefined): void => {
|
|
38
|
+
if (!n) return
|
|
39
|
+
if (n.type === 'element') out.push(n)
|
|
40
|
+
switch (n.type) {
|
|
41
|
+
case 'element':
|
|
42
|
+
case 'fragment':
|
|
43
|
+
case 'component':
|
|
44
|
+
case 'provider':
|
|
45
|
+
case 'loop':
|
|
46
|
+
for (const c of n.children) visit(c)
|
|
47
|
+
return
|
|
48
|
+
case 'async':
|
|
49
|
+
visit(n.fallback)
|
|
50
|
+
for (const c of n.children) visit(c)
|
|
51
|
+
return
|
|
52
|
+
case 'conditional':
|
|
53
|
+
visit(n.whenTrue)
|
|
54
|
+
visit(n.whenFalse)
|
|
55
|
+
return
|
|
56
|
+
case 'if-statement':
|
|
57
|
+
visit(n.consequent)
|
|
58
|
+
visit(n.alternate)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
visit(node)
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const PROVIDER_ROOT = `
|
|
67
|
+
'use client'
|
|
68
|
+
import { createContext, createSignal } from '@barefootjs/client'
|
|
69
|
+
|
|
70
|
+
const SelectContext = createContext({ open: false })
|
|
71
|
+
|
|
72
|
+
export function Select(props: { children?: unknown }) {
|
|
73
|
+
const [open, setOpen] = createSignal(false)
|
|
74
|
+
return (
|
|
75
|
+
<SelectContext.Provider value={{ open: open(), setOpen }}>
|
|
76
|
+
<div data-slot="select">{props.children}</div>
|
|
77
|
+
</SelectContext.Provider>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
`
|
|
81
|
+
|
|
82
|
+
describe('render-root row-key relay (#2753)', () => {
|
|
83
|
+
test('a provider-rooted component relays the key on the element under the provider', () => {
|
|
84
|
+
const ir = compile(PROVIDER_ROOT, 'Select')
|
|
85
|
+
|
|
86
|
+
expect(ir.type).toBe('provider')
|
|
87
|
+
const div = allElements(ir).find(e => e.tag === 'div')
|
|
88
|
+
expect(div).toBeDefined()
|
|
89
|
+
// The element under the provider IS the rendered root: `ctx.isRoot`
|
|
90
|
+
// survives `transformProviderElement`.
|
|
91
|
+
expect(div!.needsScope).toBe(true)
|
|
92
|
+
// Relay marker: a name and no value — the value arrives at runtime from
|
|
93
|
+
// whoever renders this component as a keyed row.
|
|
94
|
+
expect(div!.keyAttr).toEqual({ name: 'data-key' })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test.each([
|
|
98
|
+
['plain element root', `
|
|
99
|
+
export function C() { return <div class="root"><span>x</span></div> }
|
|
100
|
+
`],
|
|
101
|
+
['early-return (if-statement) root — every branch top element', `
|
|
102
|
+
export function C(props: { on?: boolean }) {
|
|
103
|
+
if (props.on) return <section>on</section>
|
|
104
|
+
return <article>off</article>
|
|
105
|
+
}
|
|
106
|
+
`],
|
|
107
|
+
['provider root', PROVIDER_ROOT],
|
|
108
|
+
['provider wrapping an early-return root', `
|
|
109
|
+
'use client'
|
|
110
|
+
import { createContext } from '@barefootjs/client'
|
|
111
|
+
const Ctx = createContext(0)
|
|
112
|
+
export function C(props: { on?: boolean }) {
|
|
113
|
+
if (props.on) return <Ctx.Provider value={1}><section>on</section></Ctx.Provider>
|
|
114
|
+
return <Ctx.Provider value={0}><article>off</article></Ctx.Provider>
|
|
115
|
+
}
|
|
116
|
+
`],
|
|
117
|
+
['nested providers around the root element', `
|
|
118
|
+
'use client'
|
|
119
|
+
import { createContext } from '@barefootjs/client'
|
|
120
|
+
const A = createContext(0)
|
|
121
|
+
const B = createContext(0)
|
|
122
|
+
export function C() {
|
|
123
|
+
return <A.Provider value={1}><B.Provider value={2}><div>x</div></B.Provider></A.Provider>
|
|
124
|
+
}
|
|
125
|
+
`],
|
|
126
|
+
])('invariant: %s — every needsScope element carries a relay keyAttr', (_label, source) => {
|
|
127
|
+
const roots = allElements(compile(source)).filter(e => e.needsScope)
|
|
128
|
+
expect(roots.length).toBeGreaterThan(0)
|
|
129
|
+
for (const el of roots) {
|
|
130
|
+
expect({ tag: el.tag, keyAttr: el.keyAttr }).toEqual({
|
|
131
|
+
tag: el.tag,
|
|
132
|
+
keyAttr: { name: 'data-key' },
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('an inline .map() row root keeps its own resolved key expression', () => {
|
|
138
|
+
const ir = compile(`
|
|
139
|
+
export function List(props: { items: { id: number; name: string }[] }) {
|
|
140
|
+
return <ul>{props.items.map(i => <li key={i.id}>{i.name}</li>)}</ul>
|
|
141
|
+
}
|
|
142
|
+
`, 'List')
|
|
143
|
+
|
|
144
|
+
const li = allElements(ir).find(e => e.tag === 'li')
|
|
145
|
+
expect(li).toBeDefined()
|
|
146
|
+
// Mechanism 1 (a concretely-known local expression) wins over the relay
|
|
147
|
+
// marker; the relay pass must not overwrite it with a bare name.
|
|
148
|
+
expect(li!.needsScope).toBe(false)
|
|
149
|
+
expect(li!.keyAttr).toEqual({ name: 'data-key', value: 'i.id' })
|
|
150
|
+
|
|
151
|
+
const ul = allElements(ir).find(e => e.tag === 'ul')!
|
|
152
|
+
expect(ul.needsScope).toBe(true)
|
|
153
|
+
expect(ul.keyAttr).toEqual({ name: 'data-key' })
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('a scope-comment fragment root marks exactly one carrier, not every child', () => {
|
|
157
|
+
const ir = compile(`
|
|
158
|
+
export function C() {
|
|
159
|
+
return <><h1>title</h1><p>body</p></>
|
|
160
|
+
}
|
|
161
|
+
`)
|
|
162
|
+
|
|
163
|
+
expect(ir.type).toBe('fragment')
|
|
164
|
+
const els = allElements(ir)
|
|
165
|
+
// #2732: hydration markers moved to the wrapping comment, so no child is
|
|
166
|
+
// a `needsScope` element and the relay pass adds nothing of its own.
|
|
167
|
+
expect(els.every(e => !e.needsScope)).toBe(true)
|
|
168
|
+
expect(els.filter(e => e.keyAttr !== undefined).map(e => e.tag)).toEqual(['h1'])
|
|
169
|
+
})
|
|
170
|
+
})
|
|
@@ -266,4 +266,153 @@ describe('Signal Getter Not Called (BF044)', () => {
|
|
|
266
266
|
expect(bf044[0].severity).toBe('error')
|
|
267
267
|
})
|
|
268
268
|
})
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Nested descent (#2755 / #2751 upstream fix).
|
|
272
|
+
*
|
|
273
|
+
* The gate used to open with `if (!ts.isIdentifier(expr)) return`, so it saw
|
|
274
|
+
* only an expression's TOP-LEVEL node. Every shape below reaches a rendered
|
|
275
|
+
* position through some wrapper, and every one of them used to compile
|
|
276
|
+
* silently and then miscompile downstream — the accessor stringified into a
|
|
277
|
+
* DOM property (#2755) or referenced from a module-scope template thunk that
|
|
278
|
+
* cannot see it (#2751).
|
|
279
|
+
*
|
|
280
|
+
* The negative cases are the load-bearing half: descending EVERYWHERE would
|
|
281
|
+
* break the Context-Provider idiom, where handing a descendant an uncalled
|
|
282
|
+
* accessor is the whole point. The rule is "rendered position", not "nested".
|
|
283
|
+
*/
|
|
284
|
+
describe('nested descent into rendered positions', () => {
|
|
285
|
+
// `Child` is declared AFTER `Counter` deliberately: `analyzeComponent`
|
|
286
|
+
// analyzes the FIRST function in the module, so hoisting the child above
|
|
287
|
+
// would silently analyze `Child` instead and make every case below report
|
|
288
|
+
// zero diagnostics — the negative cases would then pass for the wrong
|
|
289
|
+
// reason. The positive block is the control that proves the walk is
|
|
290
|
+
// actually live in this exact module shape.
|
|
291
|
+
const wrap = (body: string) => `
|
|
292
|
+
'use client'
|
|
293
|
+
import { createSignal } from '@barefootjs/client'
|
|
294
|
+
|
|
295
|
+
export function Counter() {
|
|
296
|
+
const [count, setCount] = createSignal(0)
|
|
297
|
+
const [items, setItems] = createSignal([1, 2])
|
|
298
|
+
const obj: Record<string, unknown> = {}
|
|
299
|
+
return ${body}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function Child(props: { value?: unknown }) { return <span /> }
|
|
303
|
+
`
|
|
304
|
+
const bf044Of = (body: string) =>
|
|
305
|
+
compileToIR(wrap(body)).errors.filter(e => e.code === ErrorCodes.SIGNAL_GETTER_NOT_CALLED)
|
|
306
|
+
|
|
307
|
+
describe('fires — the getter reaches a rendered position', () => {
|
|
308
|
+
test.each([
|
|
309
|
+
['ternary condition', '<div className={count ? "on" : "off"} />'],
|
|
310
|
+
['template literal span', '<div className={`x-${count}`} />'],
|
|
311
|
+
['call argument', '<div className={String(count)} />'],
|
|
312
|
+
['array literal member', '<div className={[count].join("")} />'],
|
|
313
|
+
['style object property value', '<div style={{ color: count }} />'],
|
|
314
|
+
['JSX text child', '<div>{count ? "a" : "b"}</div>'],
|
|
315
|
+
])('%s', (_label, body) => {
|
|
316
|
+
const bf044 = bf044Of(body)
|
|
317
|
+
expect(bf044).toHaveLength(1)
|
|
318
|
+
expect(bf044[0].message).toContain("'count'")
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
describe('stays silent — the getter is handed onward, not rendered', () => {
|
|
323
|
+
test.each([
|
|
324
|
+
// The `<SelectContext.Provider value={{ open, ... }}>` shape: every
|
|
325
|
+
// member is an accessor BY CONTRACT. Calling it here would freeze the
|
|
326
|
+
// value at provider-render time and break every consumer.
|
|
327
|
+
['component prop, object literal member', '<Child value={{ x: count }} />'],
|
|
328
|
+
['component prop, ternary', '<Child value={count ? 1 : 2} />'],
|
|
329
|
+
['component prop, call argument', '<Child value={String(count)} />'],
|
|
330
|
+
])('%s', (_label, body) => {
|
|
331
|
+
expect(bf044Of(body)).toHaveLength(0)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('a loop-row param shadowing a same-named signal', () => {
|
|
335
|
+
// `count` here is the row item, not the signal — resolved through the
|
|
336
|
+
// ambient `BindingScope`, which sees bindings introduced OUTSIDE the
|
|
337
|
+
// checked expression.
|
|
338
|
+
expect(bf044Of('<ul>{items().map(count => <li className={count ? "a" : "b"} />)}</ul>')).toHaveLength(0)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('correctly called getter in every nested shape', () => {
|
|
342
|
+
expect(bf044Of('<div className={count() ? "on" : "off"} />')).toHaveLength(0)
|
|
343
|
+
expect(bf044Of('<div style={{ color: count() }} />')).toHaveLength(0)
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
describe('binding and parameter defaults', () => {
|
|
348
|
+
// A default VALUE is an ordinary expression in the enclosing scope, but
|
|
349
|
+
// the walk used to visit only binding NAMES. Measured before the fix:
|
|
350
|
+
// both shapes compiled silently and emitted a module-scope `template`
|
|
351
|
+
// thunk referencing a component-scope binding — `ReferenceError` on CSR
|
|
352
|
+
// mount, i.e. #2751's mechanism surviving inside the very check meant to
|
|
353
|
+
// close it.
|
|
354
|
+
test('destructuring default in a rendered position', () => {
|
|
355
|
+
expect(bf044Of('<div className={(() => { const { x = count } = ({} as { x?: unknown }); return String(x) })()} />')).toHaveLength(1)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('parameter default in a rendered position', () => {
|
|
359
|
+
expect(bf044Of('<div className={((f = count) => String(f))()} />')).toHaveLength(1)
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test('a later default reading an EARLIER parameter stays silent', () => {
|
|
363
|
+
// JS binds parameters left to right: `(count, x = count) => …` reads
|
|
364
|
+
// the already-bound parameter, not the signal it shadows (verified
|
|
365
|
+
// against V8). Visiting every default before binding any parameter
|
|
366
|
+
// would flag this — a false positive on working code.
|
|
367
|
+
expect(bf044Of('<div className={((count2: unknown, x = count2) => String(x))(1)} />')).toHaveLength(0)
|
|
368
|
+
expect(bf044Of('<div className={((count: unknown, x = count) => String(x))(1)} />')).toHaveLength(0)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test('a later default reading an EARLIER pattern element stays silent', () => {
|
|
372
|
+
// The same left-to-right rule applies WITHIN a pattern.
|
|
373
|
+
//
|
|
374
|
+
// The declaration sits inside a NESTED block on purpose. At a function
|
|
375
|
+
// body's top level, `collectBlockDeclarations` pre-scans the whole
|
|
376
|
+
// `VariableStatement` and binds every name in the pattern up front,
|
|
377
|
+
// regardless of order — so a top-level version of this case passes
|
|
378
|
+
// even with the sequential threading removed, and pins nothing.
|
|
379
|
+
// `collectBlockDeclarations` does not descend into an `if` block, so
|
|
380
|
+
// here the only thing that can bind `c` before `x`'s default is read
|
|
381
|
+
// is `visitBindingDefaults` itself.
|
|
382
|
+
expect(bf044Of('<div className={(() => { if (obj) { const { count, x = count } = obj; return String(x) } return "" })()} />')).toHaveLength(0)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test('a literal default stays silent', () => {
|
|
386
|
+
// The overwhelmingly common shape (`{ size = 'md' }`): the default is
|
|
387
|
+
// not a reactive name, so widening the walk must not touch it.
|
|
388
|
+
expect(bf044Of(`<div className={(({ size = 'md' }: { size?: string }) => size)({})} />`)).toHaveLength(0)
|
|
389
|
+
})
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
test('does not misfire on a TYPE position', () => {
|
|
393
|
+
// A type is not a value. `({} as { count?: unknown })` in a rendered
|
|
394
|
+
// position used to read the type literal's property name as a bare
|
|
395
|
+
// reference to the same-named signal and refuse valid code.
|
|
396
|
+
expect(bf044Of('<div className={String(({} as { count?: unknown }).count)} />')).toHaveLength(0)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
test('does not misfire on a nested element ATTRIBUTE NAME', () => {
|
|
400
|
+
// The walk stops at a nested JSX boundary. Without that guard, descending
|
|
401
|
+
// into a `.map()` body that returns JSX read the nested element's own
|
|
402
|
+
// attribute NAME (`checked=`) as a bare reference to the same-named
|
|
403
|
+
// signal — `transformNode` re-walks that element independently anyway.
|
|
404
|
+
const source = `
|
|
405
|
+
'use client'
|
|
406
|
+
import { createSignal } from '@barefootjs/client'
|
|
407
|
+
|
|
408
|
+
export function Boxes() {
|
|
409
|
+
const [checked, setChecked] = createSignal(false)
|
|
410
|
+
const [items, setItems] = createSignal([1, 2])
|
|
411
|
+
return <ul>{items().map(n => <li><input checked={checked()} /></li>)}</ul>
|
|
412
|
+
}
|
|
413
|
+
`
|
|
414
|
+
const bf044 = compileToIR(source).errors.filter(e => e.code === ErrorCodes.SIGNAL_GETTER_NOT_CALLED)
|
|
415
|
+
expect(bf044).toHaveLength(0)
|
|
416
|
+
})
|
|
417
|
+
})
|
|
269
418
|
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2767 follow-up: the state-only-file client-JS path (a `.tsx` with no
|
|
3
|
+
* JSX return but an exported `/* @client *\/` module signal) used to filter
|
|
4
|
+
* OUT every default- or namespace-imported specifier when deciding which
|
|
5
|
+
* external imports to preserve (`s => !s.isDefault && !s.isNamespace`,
|
|
6
|
+
* `compiler.ts`'s single-component early return) — not just render them
|
|
7
|
+
* wrong, but drop them entirely. A signal initializer that references a
|
|
8
|
+
* default- or namespace-imported helper compiled with zero diagnostics
|
|
9
|
+
* into client JS that throws `ReferenceError` in the browser, since the
|
|
10
|
+
* import never made it into the bundle at all.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect } from 'bun:test'
|
|
13
|
+
import { compileJSX } from '../compiler'
|
|
14
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
15
|
+
|
|
16
|
+
const adapter = new TestAdapter()
|
|
17
|
+
|
|
18
|
+
describe('state-only file: default/namespace imports feeding a @client signal', () => {
|
|
19
|
+
test('preserves a default-imported helper referenced by the signal initializer', () => {
|
|
20
|
+
const source = `'use client'
|
|
21
|
+
import defaults from './defaults.json' with { type: 'json' }
|
|
22
|
+
import { createSignal } from '@barefootjs/client'
|
|
23
|
+
/* @client */
|
|
24
|
+
export const [count, setCount] = createSignal(defaults.start)
|
|
25
|
+
`
|
|
26
|
+
const result = compileJSX(source, 'store.tsx', { adapter })
|
|
27
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
28
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
29
|
+
expect(clientJs).toBeDefined()
|
|
30
|
+
expect(clientJs!.content).toContain("import defaults from './defaults.json'")
|
|
31
|
+
expect(clientJs!.content).not.toContain('import { defaults }')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('preserves a namespace-imported helper referenced by the signal initializer', () => {
|
|
35
|
+
const source = `'use client'
|
|
36
|
+
import * as util from './util'
|
|
37
|
+
import { createSignal } from '@barefootjs/client'
|
|
38
|
+
/* @client */
|
|
39
|
+
export const [count, setCount] = createSignal(util.base())
|
|
40
|
+
`
|
|
41
|
+
const result = compileJSX(source, 'store2.tsx', { adapter })
|
|
42
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
43
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
44
|
+
expect(clientJs).toBeDefined()
|
|
45
|
+
expect(clientJs!.content).toContain("import * as util from './util'")
|
|
46
|
+
})
|
|
47
|
+
})
|
package/src/analyzer.ts
CHANGED
|
@@ -4237,6 +4237,42 @@ export function listComponentFunctions(
|
|
|
4237
4237
|
ts.ScriptKind.TSX
|
|
4238
4238
|
)
|
|
4239
4239
|
|
|
4240
|
+
return listComponentFunctionsFromSourceFile(sourceFile)
|
|
4241
|
+
}
|
|
4242
|
+
|
|
4243
|
+
/**
|
|
4244
|
+
* One parse, two structural facts about a component file — the component
|
|
4245
|
+
* names it exports (today's `listComponentFunctions` result) and the
|
|
4246
|
+
* PascalCase JSX tags it instantiates (`collectJsxComponentTags`'s
|
|
4247
|
+
* out-edges). Used by `@barefootjs/vite`'s `discoverComponents` to build the
|
|
4248
|
+
* component-instantiation graph that decides which SERVER files also need a
|
|
4249
|
+
* client bundle because they transitively own a `'use client'` descendant
|
|
4250
|
+
* (issue #2767) — a property no single-file compile can answer, since it
|
|
4251
|
+
* depends on the whole discovered corpus.
|
|
4252
|
+
*/
|
|
4253
|
+
export interface ComponentFileScan {
|
|
4254
|
+
/** Component names this file exports (same result as `listComponentFunctions`). */
|
|
4255
|
+
exports: string[]
|
|
4256
|
+
/** PascalCase JSX tag identifiers this file references. */
|
|
4257
|
+
referencedComponents: string[]
|
|
4258
|
+
}
|
|
4259
|
+
|
|
4260
|
+
export function scanComponentFile(source: string, filePath: string): ComponentFileScan {
|
|
4261
|
+
const sourceFile = ts.createSourceFile(
|
|
4262
|
+
filePath,
|
|
4263
|
+
source,
|
|
4264
|
+
ts.ScriptTarget.Latest,
|
|
4265
|
+
true,
|
|
4266
|
+
ts.ScriptKind.TSX
|
|
4267
|
+
)
|
|
4268
|
+
|
|
4269
|
+
return {
|
|
4270
|
+
exports: listComponentFunctionsFromSourceFile(sourceFile),
|
|
4271
|
+
referencedComponents: [...collectJsxComponentTags(sourceFile)],
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
|
|
4275
|
+
function listComponentFunctionsFromSourceFile(sourceFile: ts.SourceFile): string[] {
|
|
4240
4276
|
const componentNames: string[] = []
|
|
4241
4277
|
|
|
4242
4278
|
// 'use client' directive detection (controls whether multi-return JSX
|