@barefootjs/jsx 0.31.1 → 0.31.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/interface.d.ts +11 -0
- package/dist/adapters/interface.d.ts.map +1 -1
- package/dist/adapters/jsx-adapter.d.ts +92 -1
- package/dist/adapters/jsx-adapter.d.ts.map +1 -1
- package/dist/adapters/test-adapter.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/compiler.d.ts.map +1 -1
- package/dist/css-layer-prefixer.d.ts +16 -0
- package/dist/css-layer-prefixer.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/html-types.d.ts +19 -0
- package/dist/html-types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1402 -1124
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/phases/props-event-handlers.d.ts.map +1 -1
- package/dist/ir-to-client-js/phases/props-extraction.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +6 -4
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/jsx-runtime/index.d.ts +2 -8
- package/dist/jsx-runtime/index.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/module-exports.d.ts +9 -1
- package/dist/module-exports.d.ts.map +1 -1
- package/dist/prop-rewrite.d.ts +7 -2
- package/dist/prop-rewrite.d.ts.map +1 -1
- package/dist/props-binding.d.ts +40 -0
- package/dist/props-binding.d.ts.map +1 -0
- package/dist/relocate.d.ts +9 -0
- package/dist/relocate.d.ts.map +1 -1
- package/dist/ssr-defaults.d.ts +43 -0
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/template-parts.d.ts +53 -0
- package/dist/template-parts.d.ts.map +1 -0
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/adapter-output.test.ts +8 -4
- package/src/__tests__/aliased-destructured-prop-csr.test.ts +112 -0
- package/src/__tests__/css-layer-prefixer.test.ts +72 -0
- package/src/__tests__/form-control-value-ssr.test.ts +48 -3
- package/src/__tests__/memo-deps-comments.test.ts +99 -0
- package/src/__tests__/multi-return-sibling-diagnostic.test.ts +241 -0
- package/src/__tests__/ssr-defaults.test.ts +124 -1
- package/src/__tests__/staged-ir/08-relocate-unit.test.ts +1 -0
- package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +1 -0
- package/src/adapters/interface.ts +11 -0
- package/src/adapters/jsx-adapter.ts +266 -4
- package/src/adapters/test-adapter.ts +13 -10
- package/src/analyzer.ts +50 -8
- package/src/compiler.ts +119 -18
- package/src/css-layer-prefixer.ts +80 -24
- package/src/errors.ts +18 -0
- package/src/html-types.ts +24 -0
- package/src/index.ts +7 -1
- package/src/ir-to-client-js/collect-elements.ts +4 -1
- package/src/ir-to-client-js/emit-reactive.ts +4 -2
- package/src/ir-to-client-js/phases/props-event-handlers.ts +4 -3
- package/src/ir-to-client-js/phases/props-extraction.ts +7 -4
- package/src/ir-to-client-js/plan/build-declaration-emit.ts +6 -3
- package/src/ir-to-client-js/utils.ts +5 -24
- package/src/jsx-runtime/index.ts +2 -7
- package/src/jsx-to-ir.ts +75 -42
- package/src/module-exports.ts +11 -2
- package/src/prop-rewrite.ts +25 -5
- package/src/props-binding.ts +70 -0
- package/src/relocate.ts +19 -2
- package/src/ssr-defaults.ts +70 -0
- package/src/template-parts.ts +81 -0
- package/src/types.ts +10 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aliased (renaming) destructured props on the CSR/client-JS path (#2524).
|
|
3
|
+
*
|
|
4
|
+
* `_p` is uniformly keyed by the caller-facing property name
|
|
5
|
+
* (`sourceName ?? name` — see `ParamInfo.sourceName`'s docstring in
|
|
6
|
+
* `types.ts`) across every producer/consumer. Before this fix, client-JS
|
|
7
|
+
* emission kept reading `_p.<localBinding>` for a renaming destructure
|
|
8
|
+
* (`{ n: count }`), so `_p.count` read a property the caller never sent
|
|
9
|
+
* (the caller passes `n`) and the local binding hydrated to `undefined`.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors `ssr-defaults.test.ts`'s aliased-prop describes (#2460) for the
|
|
12
|
+
* SSR-defaults half; this covers the generated `initXxx` extraction and
|
|
13
|
+
* the CSR `template:` lambda.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, test, expect } from 'bun:test'
|
|
17
|
+
import { compileJSX } from '../compiler'
|
|
18
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
19
|
+
|
|
20
|
+
const adapter = new TestAdapter()
|
|
21
|
+
|
|
22
|
+
describe('aliased destructured props reach client JS under the caller-facing key (#2524)', () => {
|
|
23
|
+
test('{ text, n: count } — init extraction and template lambda both read `_p.n`, not `_p.count`', () => {
|
|
24
|
+
const source = `
|
|
25
|
+
'use client'
|
|
26
|
+
import { createEffect } from '@barefootjs/client'
|
|
27
|
+
export function Badge({ text, n: count }: { text: string; n: number }) {
|
|
28
|
+
createEffect(() => {
|
|
29
|
+
console.log(count)
|
|
30
|
+
})
|
|
31
|
+
return <span>{text}:{count}</span>
|
|
32
|
+
}
|
|
33
|
+
`
|
|
34
|
+
const result = compileJSX(source, 'Badge.tsx', { adapter })
|
|
35
|
+
const errors = result.errors.filter((e) => e.severity === 'error')
|
|
36
|
+
expect(errors).toEqual([])
|
|
37
|
+
|
|
38
|
+
const clientJs = result.files.find((f) => f.type === 'clientJs')
|
|
39
|
+
expect(clientJs).toBeDefined()
|
|
40
|
+
// `initBadge` extracts the local binding `count` from the
|
|
41
|
+
// CALLER-facing key `n` — never the local key `count`.
|
|
42
|
+
expect(clientJs!.content).toContain('const count = _p.n')
|
|
43
|
+
expect(clientJs!.content).not.toContain('_p.count')
|
|
44
|
+
// The CSR `template:` lambda (module-scope SSR fallback) reads the
|
|
45
|
+
// same caller-facing key.
|
|
46
|
+
expect(clientJs!.content).toContain('escapeText(_p.n)')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('{ text, n } — un-aliased: byte-identical to the pre-fix `_p.n` extraction', () => {
|
|
50
|
+
// `sourceName ?? name` is an identity for an un-aliased prop, so this
|
|
51
|
+
// case must be indistinguishable from before the rename-aware fix.
|
|
52
|
+
const source = `
|
|
53
|
+
'use client'
|
|
54
|
+
import { createEffect } from '@barefootjs/client'
|
|
55
|
+
export function Badge({ text, n }: { text: string; n: number }) {
|
|
56
|
+
createEffect(() => {
|
|
57
|
+
console.log(n)
|
|
58
|
+
})
|
|
59
|
+
return <span>{text}:{n}</span>
|
|
60
|
+
}
|
|
61
|
+
`
|
|
62
|
+
const result = compileJSX(source, 'Badge.tsx', { adapter })
|
|
63
|
+
const errors = result.errors.filter((e) => e.severity === 'error')
|
|
64
|
+
expect(errors).toEqual([])
|
|
65
|
+
|
|
66
|
+
const clientJs = result.files.find((f) => f.type === 'clientJs')
|
|
67
|
+
expect(clientJs).toBeDefined()
|
|
68
|
+
expect(clientJs!.content).toContain('const n = _p.n')
|
|
69
|
+
expect(clientJs!.content).toContain('escapeText(_p.n)')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('aliased controlled-signal prop — the accessor reads the caller-facing key', () => {
|
|
73
|
+
// `build-declaration-emit`'s controlled-signal path resolves
|
|
74
|
+
// `controlled.propName` (a LOCAL name) through the prop's
|
|
75
|
+
// `sourceName` before emitting the `_p.` accessor.
|
|
76
|
+
const source = `
|
|
77
|
+
'use client'
|
|
78
|
+
import { createSignal } from '@barefootjs/client'
|
|
79
|
+
export function Toggle({ isOpen: open = false }: { isOpen?: boolean }) {
|
|
80
|
+
const [openState, setOpenState] = createSignal(open)
|
|
81
|
+
return <button onClick={() => setOpenState(!openState())}>{openState() ? 'on' : 'off'}</button>
|
|
82
|
+
}
|
|
83
|
+
`
|
|
84
|
+
const result = compileJSX(source, 'Toggle.tsx', { adapter })
|
|
85
|
+
const errors = result.errors.filter((e) => e.severity === 'error')
|
|
86
|
+
expect(errors).toEqual([])
|
|
87
|
+
|
|
88
|
+
const clientJs = result.files.find((f) => f.type === 'clientJs')
|
|
89
|
+
expect(clientJs).toBeDefined()
|
|
90
|
+
expect(clientJs!.content).toContain('_p.isOpen')
|
|
91
|
+
expect(clientJs!.content).not.toContain('_p.open')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('aliased event-handler prop — the handler const reads the caller-facing key', () => {
|
|
95
|
+
// `props-event-handlers` extracts the handler under its LOCAL name
|
|
96
|
+
// from the CALLER-facing `_p` key.
|
|
97
|
+
const source = `
|
|
98
|
+
'use client'
|
|
99
|
+
export function Clicker({ onPress: handlePress }: { onPress?: () => void }) {
|
|
100
|
+
return <button onClick={handlePress}>go</button>
|
|
101
|
+
}
|
|
102
|
+
`
|
|
103
|
+
const result = compileJSX(source, 'Clicker.tsx', { adapter })
|
|
104
|
+
const errors = result.errors.filter((e) => e.severity === 'error')
|
|
105
|
+
expect(errors).toEqual([])
|
|
106
|
+
|
|
107
|
+
const clientJs = result.files.find((f) => f.type === 'clientJs')
|
|
108
|
+
expect(clientJs).toBeDefined()
|
|
109
|
+
expect(clientJs!.content).toContain('const handlePress = _p.onPress')
|
|
110
|
+
expect(clientJs!.content).not.toContain('_p.handlePress')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
extractIdentifiers,
|
|
12
12
|
} from '../css-layer-prefixer'
|
|
13
13
|
import type { ComponentIR, IRElement, IRMetadata, IRTemplatePart, ConstantInfo, TemplateAttr, LiteralAttr } from '../types'
|
|
14
|
+
import { compileJSX } from '../compiler'
|
|
15
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
16
|
+
import { HonoAdapter } from '../../../../packages/adapter-hono/src/adapter/hono-adapter'
|
|
14
17
|
|
|
15
18
|
describe('prefixClass', () => {
|
|
16
19
|
test('prefixes a simple class', () => {
|
|
@@ -452,4 +455,73 @@ describe('applyCssLayerPrefix', () => {
|
|
|
452
455
|
const base = ir.metadata.localConstants.find(c => c.name === 'baseClasses')
|
|
453
456
|
expect(base?.value).toBe("'layer-components:bg-primary'")
|
|
454
457
|
})
|
|
458
|
+
|
|
459
|
+
test('multi-component file prefixes a shared const identically in every component IR (#2570)', () => {
|
|
460
|
+
// `sharedClasses` is used in CLASS position only by CompA; CompB reads it
|
|
461
|
+
// in a non-class expression. Per-IR application prefixed only CompA's
|
|
462
|
+
// copy, which — now that module-scope constants are hoisted to ONE
|
|
463
|
+
// module declaration — emitted the constant twice with different values
|
|
464
|
+
// ("tabsClasses has already been declared" in the site/ui build). The
|
|
465
|
+
// file-wide union must leave every IR's copy byte-identical.
|
|
466
|
+
const source = `
|
|
467
|
+
const sharedClasses = 'flex flex-col gap-2'
|
|
468
|
+
|
|
469
|
+
export function CompA() {
|
|
470
|
+
return <div className={sharedClasses + ' extra'}>a</div>
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function CompB() {
|
|
474
|
+
return <div data-cls={sharedClasses}>b</div>
|
|
475
|
+
}
|
|
476
|
+
`
|
|
477
|
+
const result = compileJSX(source, '/virtual/Tabs.tsx', {
|
|
478
|
+
adapter: new TestAdapter(),
|
|
479
|
+
cssLayerPrefix: 'components',
|
|
480
|
+
})
|
|
481
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
482
|
+
const template = result.files.find(f => f.type === 'markedTemplate')!.content
|
|
483
|
+
|
|
484
|
+
const declarations = template.match(/^const sharedClasses = .*$/gm) ?? []
|
|
485
|
+
expect(declarations).toHaveLength(1)
|
|
486
|
+
expect(declarations[0]).toContain('layer-components:flex')
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
test('prefixes a `Record`-shaped `as const` class map constant\'s typedValue (#2575)', () => {
|
|
490
|
+
// `preserveTypes` emitters (Hono) prefer `typedValue ?? value` when
|
|
491
|
+
// emitting a module-scope constant so `.tsx` output stays type-checked
|
|
492
|
+
// — see `generateModuleScopeDeclarations` in
|
|
493
|
+
// `packages/adapter-hono/src/adapter/hono-adapter.ts`'s base class.
|
|
494
|
+
// `applyCssLayerPrefixToFile` used to rewrite only `value`, leaving
|
|
495
|
+
// `typedValue` — the string Hono actually emits — unprefixed for any
|
|
496
|
+
// constant whose initializer carries type syntax (`as const`, a
|
|
497
|
+
// `satisfies`/`Record<...>` annotation, …). Regression for the fix:
|
|
498
|
+
// both strings must be prefixed together, and the `as const` assertion
|
|
499
|
+
// must survive the round trip verbatim.
|
|
500
|
+
const source = `
|
|
501
|
+
const toneClasses: Record<'info' | 'warn', string> = {
|
|
502
|
+
info: 'text-blue-500',
|
|
503
|
+
warn: 'text-amber-500',
|
|
504
|
+
} as const
|
|
505
|
+
|
|
506
|
+
export function Banner() {
|
|
507
|
+
return <div className={toneClasses.info}>hi</div>
|
|
508
|
+
}
|
|
509
|
+
`
|
|
510
|
+
const result = compileJSX(source, '/virtual/Banner.tsx', {
|
|
511
|
+
adapter: new HonoAdapter(),
|
|
512
|
+
cssLayerPrefix: 'components',
|
|
513
|
+
})
|
|
514
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
515
|
+
const template = result.files.find(f => f.type === 'markedTemplate')!.content
|
|
516
|
+
|
|
517
|
+
const declarations = [...template.matchAll(/^const toneClasses = [\s\S]*?\} as const$/gm)]
|
|
518
|
+
expect(declarations).toHaveLength(1)
|
|
519
|
+
const declaration = declarations[0][0]
|
|
520
|
+
|
|
521
|
+
// Classes are prefixed...
|
|
522
|
+
expect(declaration).toContain("info: 'layer-components:text-blue-500'")
|
|
523
|
+
expect(declaration).toContain("warn: 'layer-components:text-amber-500'")
|
|
524
|
+
// ...and the `as const` assertion survives the rewrite.
|
|
525
|
+
expect(declaration.trim().endsWith('} as const')).toBe(true)
|
|
526
|
+
})
|
|
455
527
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SSR projection of controlled form-control `value` (#2464 / #2465).
|
|
2
|
+
* SSR projection of controlled form-control `value` (#2464 / #2465 / #2466).
|
|
3
3
|
*
|
|
4
4
|
* `value` is not an attribute on `<textarea>` or `<select>` — emitting it
|
|
5
5
|
* verbatim ships invalid HTML that browsers ignore, so no-JS and
|
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
* and projects the value into element content (textarea) or per-option
|
|
10
10
|
* `selected` comparisons (select — the shape `select-option-selected`
|
|
11
11
|
* already proves across every adapter).
|
|
12
|
+
*
|
|
13
|
+
* `<option>`s rendered by a `.map()` loop get the same `selected`
|
|
14
|
+
* distribution, compared against the option's own value EXPRESSION rather
|
|
15
|
+
* than a literal (#2466). That makes `selected` an ordinary per-row
|
|
16
|
+
* reactive attribute, so it rides the loop's existing `applyItem` /
|
|
17
|
+
* `applyOuter` (or eager `mapArray` per-row effect) machinery — fixing the
|
|
18
|
+
* bug where an INDEX-KEYED reorder left `selected` attached to whichever
|
|
19
|
+
* physical `<option>` happened to have its `value` rewritten in place,
|
|
20
|
+
* instead of following the controlled signal's value.
|
|
12
21
|
*/
|
|
13
22
|
|
|
14
23
|
import { describe, test, expect } from 'bun:test'
|
|
@@ -65,7 +74,7 @@ export function Pick() {
|
|
|
65
74
|
expect(template).toContain(`((v()) === "b")`)
|
|
66
75
|
})
|
|
67
76
|
|
|
68
|
-
test('an authored selected wins;
|
|
77
|
+
test('an authored selected wins; a `.map()` loop option gets a distributed selected binding (#2466)', () => {
|
|
69
78
|
const { template } = compiled(`
|
|
70
79
|
"use client"
|
|
71
80
|
import { createSignal } from '@barefootjs/client'
|
|
@@ -89,7 +98,43 @@ export function Pick() {
|
|
|
89
98
|
expect(template).toContain('selected={(false) || undefined}')
|
|
90
99
|
expect(template).not.toContain('(v()) === "a"')
|
|
91
100
|
expect(template).not.toMatch(/<select[^>]*value=/)
|
|
92
|
-
|
|
101
|
+
// The loop-rendered option compares against its own value EXPRESSION
|
|
102
|
+
// (the loop param `o`), not a literal — this is the #2466 half.
|
|
103
|
+
expect(template).toContain('((v()) === (o))')
|
|
104
|
+
}, 20000)
|
|
105
|
+
|
|
106
|
+
test('index-keyed .map() loop option: selected rides applyItem AND applyOuter (#2466)', () => {
|
|
107
|
+
const { clientJs } = compiled(`
|
|
108
|
+
"use client"
|
|
109
|
+
import { createSignal } from '@barefootjs/client'
|
|
110
|
+
export function ReorderSelect() {
|
|
111
|
+
const [opts, setOpts] = createSignal([{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }])
|
|
112
|
+
const [val, setVal] = createSignal('b')
|
|
113
|
+
return (
|
|
114
|
+
<div>
|
|
115
|
+
<select value={val()} onChange={(e) => setVal(e.target.value)}>
|
|
116
|
+
{opts().map((o, i) => <option key={i} value={o.id}>{o.label}</option>)}
|
|
117
|
+
</select>
|
|
118
|
+
<button onClick={() => setOpts([...opts()].reverse())}>Reverse</button>
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
`)
|
|
123
|
+
// `key={i}` is index-keyed: nothing MOVES on reorder, so `value`/text are
|
|
124
|
+
// rewritten in place by applyItem — `selected` must be recomputed there
|
|
125
|
+
// too (item changed under a stationary row), not just at row creation.
|
|
126
|
+
expect(clientJs).toContain('mapArrayLazy(')
|
|
127
|
+
expect(clientJs).toContain('applyItem:')
|
|
128
|
+
expect(clientJs).toContain('applyOuter:')
|
|
129
|
+
// Every apply body (createRow, applyItem, applyOuter) recomputes
|
|
130
|
+
// `selected` from the item AND the outer controlled signal…
|
|
131
|
+
const computed = clientJs.match(/const __x = \(val\(\)\) === \(o\(\)\.id\)/g) ?? []
|
|
132
|
+
expect(computed.length).toBe(3)
|
|
133
|
+
// …and writes it as a DOM PROPERTY (not just an HTML attribute
|
|
134
|
+
// presence, which would not affect a live, already-rendered <option>).
|
|
135
|
+
const writes = clientJs.match(/\.selected = !!\(__x\)/g) ?? []
|
|
136
|
+
expect(writes.length).toBe(3)
|
|
137
|
+
}, 20000)
|
|
93
138
|
|
|
94
139
|
test('textarea with explicit children is left untouched', () => {
|
|
95
140
|
const { template } = compiled(`
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memo/effect dependency extraction must read the token stream, not the raw
|
|
3
|
+
* body text: a doc comment (or string literal) mentioning `otherSignal()`
|
|
4
|
+
* inside a computation body is not a read and must not become a dependency.
|
|
5
|
+
*
|
|
6
|
+
* Regression pin for the phantom deps surfaced in #2581's review: slider's
|
|
7
|
+
* `percentage` memo gained `internalValue`/`controlledValue` deps in the
|
|
8
|
+
* regenerated `ui/meta/slider.json` purely because an explanatory comment
|
|
9
|
+
* inside the memo body named those getters in call syntax.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, test, expect } from 'bun:test'
|
|
12
|
+
import { analyzeComponent } from '../analyzer'
|
|
13
|
+
|
|
14
|
+
describe('dependency extraction ignores comments and strings', () => {
|
|
15
|
+
test('a comment naming another getter in call syntax is not a dep', () => {
|
|
16
|
+
const source = `
|
|
17
|
+
"use client"
|
|
18
|
+
import { createSignal, createMemo } from '@barefootjs/client'
|
|
19
|
+
export function Slider() {
|
|
20
|
+
const [internalValue, setInternalValue] = createSignal(0)
|
|
21
|
+
const [controlledValue, setControlledValue] = createSignal<number | undefined>(undefined)
|
|
22
|
+
const [min, setMin] = createSignal(0)
|
|
23
|
+
const [max, setMax] = createSignal(100)
|
|
24
|
+
const currentValue = createMemo(() => controlledValue() ?? internalValue())
|
|
25
|
+
const percentage = createMemo(() => {
|
|
26
|
+
if (max() <= min()) return 0
|
|
27
|
+
// \`currentValue()\` mirrors \`controlledValue()\` and \`internalValue()\`
|
|
28
|
+
// in its type, so assert what's runtime-guaranteed.
|
|
29
|
+
return Math.max(0, Math.min(100, ((currentValue()! - min()) / (max() - min())) * 100))
|
|
30
|
+
})
|
|
31
|
+
return <div style={\`width: \${percentage()}%\`}>{currentValue()}</div>
|
|
32
|
+
}
|
|
33
|
+
`
|
|
34
|
+
const ctx = analyzeComponent(source, 'Slider.tsx')
|
|
35
|
+
const percentage = ctx.memos.find(m => m.name === 'percentage')
|
|
36
|
+
expect(percentage).toBeDefined()
|
|
37
|
+
expect(percentage!.deps.sort()).toEqual(['currentValue', 'max', 'min'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('a string literal naming a getter in call syntax is not a dep', () => {
|
|
41
|
+
const source = `
|
|
42
|
+
"use client"
|
|
43
|
+
import { createSignal, createMemo } from '@barefootjs/client'
|
|
44
|
+
export function Label() {
|
|
45
|
+
const [count, setCount] = createSignal(0)
|
|
46
|
+
const [label, setLabel] = createSignal('x')
|
|
47
|
+
const hint = createMemo(() => label() + ' (see count() for the number)')
|
|
48
|
+
return <span>{hint()}</span>
|
|
49
|
+
}
|
|
50
|
+
`
|
|
51
|
+
const ctx = analyzeComponent(source, 'Label.tsx')
|
|
52
|
+
const hint = ctx.memos.find(m => m.name === 'hint')
|
|
53
|
+
expect(hint).toBeDefined()
|
|
54
|
+
expect(hint!.deps).toEqual(['label'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('a read AFTER a template-literal substitution still registers', () => {
|
|
58
|
+
// Trap for token-scan implementations: without a parser driving
|
|
59
|
+
// `reScanTemplateToken`, the template tail after `\${...}` is mis-lexed
|
|
60
|
+
// as a new template opener and swallows the following statement —
|
|
61
|
+
// xyflow's `visibleClass` shape, where `animated()` follows a
|
|
62
|
+
// substitution line and silently lost its dep.
|
|
63
|
+
const source = `
|
|
64
|
+
"use client"
|
|
65
|
+
import { createSignal, createMemo } from '@barefootjs/client'
|
|
66
|
+
export function Edge() {
|
|
67
|
+
const [selected, setSelected] = createSignal(false)
|
|
68
|
+
const [animated, setAnimated] = createSignal(false)
|
|
69
|
+
const visibleClass = createMemo(() => {
|
|
70
|
+
let cls = 'edge'
|
|
71
|
+
if (selected()) cls += \` \${'edge-selected'}\`
|
|
72
|
+
if (animated()) cls += \` \${'edge-animated'}\`
|
|
73
|
+
return cls
|
|
74
|
+
})
|
|
75
|
+
return <div class={visibleClass()}>x</div>
|
|
76
|
+
}
|
|
77
|
+
`
|
|
78
|
+
const ctx = analyzeComponent(source, 'Edge.tsx')
|
|
79
|
+
const visibleClass = ctx.memos.find(m => m.name === 'visibleClass')
|
|
80
|
+
expect(visibleClass).toBeDefined()
|
|
81
|
+
expect(visibleClass!.deps.sort()).toEqual(['animated', 'selected'])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('genuine reads inside template-literal substitutions still register', () => {
|
|
85
|
+
const source = `
|
|
86
|
+
"use client"
|
|
87
|
+
import { createSignal, createMemo } from '@barefootjs/client'
|
|
88
|
+
export function Badge() {
|
|
89
|
+
const [tone, setTone] = createSignal('info')
|
|
90
|
+
const cls = createMemo(() => \`badge badge-\${tone()}\`)
|
|
91
|
+
return <span class={cls()}>x</span>
|
|
92
|
+
}
|
|
93
|
+
`
|
|
94
|
+
const ctx = analyzeComponent(source, 'Badge.tsx')
|
|
95
|
+
const cls = ctx.memos.find(m => m.name === 'cls')
|
|
96
|
+
expect(cls).toBeDefined()
|
|
97
|
+
expect(cls!.deps).toEqual(['tone'])
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for #2556: a `'use client'` file where a component
|
|
3
|
+
* references a same-file sibling whose body is a multi-return JSX dispatch
|
|
4
|
+
* (`switch` / `if`-`else` chain) previously compiled clean with ZERO
|
|
5
|
+
* diagnostics, but the sibling produced no template — so the emitted
|
|
6
|
+
* `renderChild`/`initChild`/`createComponent` call referenced a component
|
|
7
|
+
* name with nothing registered under it, throwing
|
|
8
|
+
* `ReferenceError: <Name> is not defined` at SSR/hydrate time.
|
|
9
|
+
*
|
|
10
|
+
* Root cause: `listComponentFunctions`'s #932 "preserve verbatim helper"
|
|
11
|
+
* bypass is gated on `!hasUseClient` — in a `'use client'` file, a
|
|
12
|
+
* multi-return sibling like `NavIcon` below IS added to `componentNames`
|
|
13
|
+
* and asked to compile as a standalone component. But `visitComponentBody`
|
|
14
|
+
* only folds `if`/`else`-chain multi-return bodies into `conditionalReturns`
|
|
15
|
+
* (#1401); a top-level `switch` statement is preserved as a verbatim init
|
|
16
|
+
* statement instead, so `ctx.jsxReturn` stays null and
|
|
17
|
+
* `compileMultipleComponents`'s Pass-1 loop silently `continue`s past it —
|
|
18
|
+
* no entry, no template, no error. Meanwhile the referencing sibling's IR
|
|
19
|
+
* still holds a `component` reference to the dropped name.
|
|
20
|
+
*
|
|
21
|
+
* Fix: BF048 detects this structurally — after Pass 1, any name in
|
|
22
|
+
* `componentNames` that did not make it into `entries` is cross-referenced
|
|
23
|
+
* against every compiled sibling's IR component-reference graph (the same
|
|
24
|
+
* walk `@bf-child` import markers use, `collectComponentNamesFromIR`). A
|
|
25
|
+
* hit fails the compile instead of shipping a silent `ReferenceError`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, test, expect } from 'bun:test'
|
|
29
|
+
import { compileJSX } from '../compiler'
|
|
30
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
31
|
+
|
|
32
|
+
const adapter = new TestAdapter()
|
|
33
|
+
|
|
34
|
+
describe('Sibling multi-return JSX dispatch produces no template in a client file (#2556)', () => {
|
|
35
|
+
// Higher timeout: `.map()` in the source trips `needsTypeBasedDetection`,
|
|
36
|
+
// and building the one-time `ts.Program` for BF023/BF024's nullable-key
|
|
37
|
+
// check is slow the first time the TS API is touched in a fresh process
|
|
38
|
+
// (e.g. this file run in isolation) — well past bun's 5s default.
|
|
39
|
+
test('BF048: .map() loop child referencing a switch-dispatch sibling fails the compile', () => {
|
|
40
|
+
const source = `
|
|
41
|
+
"use client"
|
|
42
|
+
import { createSignal } from '@barefootjs/client'
|
|
43
|
+
function NavIcon({ name }: { name: string }) {
|
|
44
|
+
switch (name) {
|
|
45
|
+
case 'home': return <svg><path d="M1"/></svg>
|
|
46
|
+
case 'bell': return <svg><path d="M2"/></svg>
|
|
47
|
+
default: return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function Shell() {
|
|
51
|
+
const [n, setN] = createSignal(0)
|
|
52
|
+
return (
|
|
53
|
+
<nav onClick={() => setN(n() + 1)}>
|
|
54
|
+
{['home', 'bell'].map(item => <NavIcon key={item} name={item} />)}
|
|
55
|
+
</nav>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
`
|
|
59
|
+
|
|
60
|
+
const result = compileJSX(source, 'Shell.tsx', { adapter })
|
|
61
|
+
const errs = result.errors.filter(e => e.severity === 'error')
|
|
62
|
+
expect(errs.length).toBeGreaterThan(0)
|
|
63
|
+
const bf048 = errs.find(e => e.code === 'BF048')
|
|
64
|
+
expect(bf048).toBeDefined()
|
|
65
|
+
expect(bf048!.message).toContain('NavIcon')
|
|
66
|
+
expect(bf048!.message).toContain('Shell')
|
|
67
|
+
expect(bf048!.message).toMatch(/did not compile to a template/)
|
|
68
|
+
}, 20000)
|
|
69
|
+
|
|
70
|
+
test('BF048: direct JSX tag (non-loop) referencing a switch-dispatch sibling fails the compile', () => {
|
|
71
|
+
const source = `
|
|
72
|
+
"use client"
|
|
73
|
+
import { createSignal } from '@barefootjs/client'
|
|
74
|
+
function StatusIcon({ status }: { status: string }) {
|
|
75
|
+
switch (status) {
|
|
76
|
+
case 'ok': return <span class="ok">OK</span>
|
|
77
|
+
case 'err': return <span class="err">ERR</span>
|
|
78
|
+
default: return null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function Panel() {
|
|
82
|
+
const [n, setN] = createSignal(0)
|
|
83
|
+
return <div onClick={() => setN(n() + 1)}><StatusIcon status="ok" /></div>
|
|
84
|
+
}
|
|
85
|
+
`
|
|
86
|
+
|
|
87
|
+
const result = compileJSX(source, 'Panel.tsx', { adapter })
|
|
88
|
+
const errs = result.errors.filter(e => e.severity === 'error')
|
|
89
|
+
const bf048 = errs.find(e => e.code === 'BF048')
|
|
90
|
+
expect(bf048).toBeDefined()
|
|
91
|
+
expect(bf048!.message).toContain('StatusIcon')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('legal neighbor: the identical switch-dispatch shape in a non-"use client" file compiles clean (#932)', () => {
|
|
95
|
+
// Same shape as the failing case above, but without the 'use client'
|
|
96
|
+
// directive: `listComponentFunctions`'s #932 bypass keeps NavIcon OFF
|
|
97
|
+
// `componentNames` entirely, so it is preserved verbatim in the marked
|
|
98
|
+
// template rather than compiled (and dropped) as a component. BF048
|
|
99
|
+
// must not fire here.
|
|
100
|
+
const source = `
|
|
101
|
+
function NavIcon({ name }: { name: string }) {
|
|
102
|
+
switch (name) {
|
|
103
|
+
case 'home': return <svg><path d="M1"/></svg>
|
|
104
|
+
case 'bell': return <svg><path d="M2"/></svg>
|
|
105
|
+
default: return null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function Shell() {
|
|
109
|
+
return (
|
|
110
|
+
<nav>
|
|
111
|
+
{['home', 'bell'].map(item => <NavIcon key={item} name={item} />)}
|
|
112
|
+
</nav>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
`
|
|
116
|
+
|
|
117
|
+
const result = compileJSX(source, 'Shell.tsx', { adapter })
|
|
118
|
+
const errs = result.errors.filter(e => e.severity === 'error')
|
|
119
|
+
expect(errs.filter(e => e.code === 'BF048')).toHaveLength(0)
|
|
120
|
+
expect(errs).toHaveLength(0)
|
|
121
|
+
|
|
122
|
+
const markedTemplate = result.files.find(f => f.type === 'markedTemplate')
|
|
123
|
+
expect(markedTemplate).toBeDefined()
|
|
124
|
+
expect(markedTemplate!.content).toContain('function NavIcon')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('legal neighbor: a non-"use client" file never trips BF048, even when a sibling lands in componentNames', () => {
|
|
128
|
+
// The #932 bypass only skips NON-exported multi-return functions, so an
|
|
129
|
+
// exported switch-dispatch sibling in a non-client file IS in
|
|
130
|
+
// `componentNames` and produces no entry — the same set-difference shape
|
|
131
|
+
// BF048 keys on. But the diagnostic's premise (the 'use client' branch
|
|
132
|
+
// of #932, `ReferenceError` at hydrate time) doesn't apply outside
|
|
133
|
+
// client files, so BF048 is gated on a compiled client entry and must
|
|
134
|
+
// stay silent here.
|
|
135
|
+
const source = `
|
|
136
|
+
export function NavIcon({ name }: { name: string }) {
|
|
137
|
+
switch (name) {
|
|
138
|
+
case 'home': return <svg><path d="M1"/></svg>
|
|
139
|
+
case 'bell': return <svg><path d="M2"/></svg>
|
|
140
|
+
default: return null
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export function Shell() {
|
|
144
|
+
return (
|
|
145
|
+
<nav>
|
|
146
|
+
{['home', 'bell'].map(item => <NavIcon key={item} name={item} />)}
|
|
147
|
+
</nav>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
`
|
|
151
|
+
|
|
152
|
+
const result = compileJSX(source, 'Shell.tsx', { adapter })
|
|
153
|
+
const errs = result.errors.filter(e => e.severity === 'error')
|
|
154
|
+
expect(errs.filter(e => e.code === 'BF048')).toHaveLength(0)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('legal neighbor: a "use client" sibling whose multi-return body DOES compile (if/else chain, #1401) stays clean', () => {
|
|
158
|
+
// if/else-if chains fold into `conditionalReturns` (#1401) and produce
|
|
159
|
+
// a real ternary template, so the sibling ends up in `entries` and the
|
|
160
|
+
// reference resolves. BF048 must not fire.
|
|
161
|
+
const source = `
|
|
162
|
+
"use client"
|
|
163
|
+
import { createSignal } from '@barefootjs/client'
|
|
164
|
+
function Badge({ kind }: { kind: string }) {
|
|
165
|
+
if (kind === 'ok') return <span class="ok">ok</span>
|
|
166
|
+
if (kind === 'warn') return <span class="warn">warn</span>
|
|
167
|
+
return <span class="err">err</span>
|
|
168
|
+
}
|
|
169
|
+
export function Panel() {
|
|
170
|
+
const [n, setN] = createSignal(0)
|
|
171
|
+
return (
|
|
172
|
+
<div onClick={() => setN(n() + 1)}>
|
|
173
|
+
<Badge kind="ok" />
|
|
174
|
+
</div>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
`
|
|
178
|
+
|
|
179
|
+
const result = compileJSX(source, 'Panel.tsx', { adapter })
|
|
180
|
+
const errs = result.errors.filter(e => e.severity === 'error')
|
|
181
|
+
expect(errs.filter(e => e.code === 'BF048')).toHaveLength(0)
|
|
182
|
+
|
|
183
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
184
|
+
expect(clientJs).toBeDefined()
|
|
185
|
+
// Both the Panel and Badge components' hydrate() registrations exist
|
|
186
|
+
// with real templates — the sibling reference resolves. Badge is a
|
|
187
|
+
// non-exported sibling so its runtime key is file-scoped
|
|
188
|
+
// (`Badge__<hash>`); match on the `name: 'Badge'` metadata instead of
|
|
189
|
+
// the registry key.
|
|
190
|
+
expect(clientJs!.content).toMatch(/hydrate\('Badge[^']*',\s*\{[^}]*template:/s)
|
|
191
|
+
expect(clientJs!.content).toContain("name: 'Badge'")
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('legal neighbor: single-component "use client" multi-return root (#1401) is unaffected', () => {
|
|
195
|
+
// Only one component in the file, so `compileJSX` never enters the
|
|
196
|
+
// multi-component path where BF048 is computed at all.
|
|
197
|
+
const source = `
|
|
198
|
+
"use client"
|
|
199
|
+
import { createSignal } from '@barefootjs/client'
|
|
200
|
+
export function Toggle(props: { asChild?: boolean }) {
|
|
201
|
+
const [open, setOpen] = createSignal(false)
|
|
202
|
+
if (props.asChild) {
|
|
203
|
+
return <span onClick={() => setOpen(!open())}>child</span>
|
|
204
|
+
}
|
|
205
|
+
return <button onClick={() => setOpen(!open())}>toggle</button>
|
|
206
|
+
}
|
|
207
|
+
`
|
|
208
|
+
|
|
209
|
+
const result = compileJSX(source, 'Toggle.tsx', { adapter })
|
|
210
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
211
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
212
|
+
expect(clientJs).toBeDefined()
|
|
213
|
+
expect(clientJs!.content).toContain('template:')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('legal neighbor: a component-scope local JSX factory does not trip BF048', () => {
|
|
217
|
+
// `listComponentFunctions` recurses into function bodies, so a local
|
|
218
|
+
// factory like `Inner` below lands in `componentNames` and produces no
|
|
219
|
+
// standalone template — but its call sites are handled by the
|
|
220
|
+
// JSX-function-inlining pass, not `createComponent`, so it is NOT the
|
|
221
|
+
// dropped-sibling shape. BF048's uncompiled-sibling set is restricted
|
|
222
|
+
// to module top-level declarations precisely so this stays legal (the
|
|
223
|
+
// first BF048 cut flagged it and broke the ir-dynamic-tag corpus).
|
|
224
|
+
// Two components so the multi-component path (where BF048 runs) engages.
|
|
225
|
+
const source = `
|
|
226
|
+
"use client"
|
|
227
|
+
import { createSignal } from '@barefootjs/client'
|
|
228
|
+
export function Demo() {
|
|
229
|
+
const Inner = () => <span>x</span>
|
|
230
|
+
const [n, setN] = createSignal(0)
|
|
231
|
+
return <div onClick={() => setN(n() + 1)}><Inner /></div>
|
|
232
|
+
}
|
|
233
|
+
export function Other() {
|
|
234
|
+
return <p>other</p>
|
|
235
|
+
}
|
|
236
|
+
`
|
|
237
|
+
|
|
238
|
+
const result = compileJSX(source, 'demo.tsx', { adapter })
|
|
239
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
240
|
+
})
|
|
241
|
+
})
|