@barefootjs/jsx 0.30.6 → 0.31.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/interface.d.ts +61 -20
- package/dist/adapters/interface.d.ts.map +1 -1
- package/dist/analyzer.d.ts +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/compiler.d.ts.map +1 -1
- package/dist/index.d.ts +0 -120
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +322 -263
- package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
- package/dist/ir-to-client-js/generate-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/prune-unused-prop-extractions.d.ts +7 -0
- package/dist/ir-to-client-js/prune-unused-prop-extractions.d.ts.map +1 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/value-references.d.ts +7 -7
- package/dist/value-references.d.ts.map +1 -1
- package/package.json +3 -7
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +183 -312
- package/src/__tests__/bf050-single-multi-symmetry.test.ts +118 -0
- package/src/__tests__/client-js-generation.test.ts +5 -1
- package/src/__tests__/doc-examples.test.ts +5 -1
- package/src/__tests__/prune-unused-prop-extractions.test.ts +78 -0
- package/src/adapters/interface.ts +61 -20
- package/src/analyzer.ts +16 -5
- package/src/compiler.ts +41 -3
- package/src/index.ts +0 -123
- package/src/ir-to-client-js/emit-registration.ts +9 -0
- package/src/ir-to-client-js/generate-init.ts +4 -1
- package/src/ir-to-client-js/index.ts +5 -1
- package/src/ir-to-client-js/prune-unused-prop-extractions.ts +108 -0
- package/src/types.ts +25 -0
- package/src/value-references.ts +7 -7
- package/dist/import-map.d.ts +0 -56
- package/dist/import-map.d.ts.map +0 -1
- package/dist/import-map.js +0 -18
- package/src/__tests__/import-map.test.ts +0 -75
- package/src/import-map.ts +0 -72
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BF050 single/multi symmetry (#2537).
|
|
3
|
+
*
|
|
4
|
+
* BF050 ("shared ts.Program required") exists so strict builds fail loudly
|
|
5
|
+
* instead of silently relying on the per-file Program fallback, whose
|
|
6
|
+
* virtual host can fail module resolution and collapse Reactive<T> brands
|
|
7
|
+
* to `any`. The single-component path always emitted it for a
|
|
8
|
+
* brand-package import compiled without `options.program` — but the
|
|
9
|
+
* multi-component path pre-builds a per-file Program to amortize it across
|
|
10
|
+
* siblings and passed it down as if it were shared, suppressing the
|
|
11
|
+
* diagnostic. Same import, opposite verdicts, decided by how many
|
|
12
|
+
* components share the file.
|
|
13
|
+
*
|
|
14
|
+
* Post-fix, BF050 keys off whether the CALLER supplied `options.program`
|
|
15
|
+
* (`analyzeComponent`'s `programIsShared`), in both paths — and a
|
|
16
|
+
* multi-component file reports it once, not once per sibling.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, test, expect } from 'bun:test'
|
|
20
|
+
import ts from 'typescript'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
import { compileJSX } from '../compiler'
|
|
23
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
24
|
+
|
|
25
|
+
const adapter = new TestAdapter()
|
|
26
|
+
|
|
27
|
+
const MULTI_SOURCE = `
|
|
28
|
+
'use client'
|
|
29
|
+
import { createForm } from '@barefootjs/form'
|
|
30
|
+
|
|
31
|
+
export function ProfileForm() {
|
|
32
|
+
const form = createForm()
|
|
33
|
+
return <form><input /></form>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function AccountForm() {
|
|
37
|
+
const form = createForm()
|
|
38
|
+
return <form><button>Save</button></form>
|
|
39
|
+
}
|
|
40
|
+
`
|
|
41
|
+
|
|
42
|
+
const SINGLE_SOURCE = `
|
|
43
|
+
'use client'
|
|
44
|
+
import { createForm } from '@barefootjs/form'
|
|
45
|
+
|
|
46
|
+
export function ProfileForm() {
|
|
47
|
+
const form = createForm()
|
|
48
|
+
return <form><input /></form>
|
|
49
|
+
}
|
|
50
|
+
`
|
|
51
|
+
|
|
52
|
+
function bf050s(source: string, program?: ts.Program) {
|
|
53
|
+
const result = compileJSX(source, '/virtual/forms.tsx', { adapter, program })
|
|
54
|
+
return result.errors.filter(e => e.code === 'BF050')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A minimal Program whose roots include the component file — enough for
|
|
59
|
+
* `analyzeComponent` to accept it as the shared Program (text must match).
|
|
60
|
+
* Brand resolution isn't the point here (the import won't resolve from
|
|
61
|
+
* /virtual); BF050 only keys off whether a shared Program was supplied.
|
|
62
|
+
*/
|
|
63
|
+
function inMemoryProgram(source: string): ts.Program {
|
|
64
|
+
const filePath = path.resolve('/virtual/forms.tsx')
|
|
65
|
+
const compilerOptions: ts.CompilerOptions = {
|
|
66
|
+
target: ts.ScriptTarget.Latest,
|
|
67
|
+
module: ts.ModuleKind.ESNext,
|
|
68
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
69
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
70
|
+
strict: true,
|
|
71
|
+
skipLibCheck: true,
|
|
72
|
+
noEmit: true,
|
|
73
|
+
}
|
|
74
|
+
const defaultHost = ts.createCompilerHost(compilerOptions)
|
|
75
|
+
const host: ts.CompilerHost = {
|
|
76
|
+
...defaultHost,
|
|
77
|
+
getSourceFile(fileName, languageVersion) {
|
|
78
|
+
if (path.resolve(fileName) === filePath) {
|
|
79
|
+
return ts.createSourceFile(fileName, source, languageVersion, true, ts.ScriptKind.TSX)
|
|
80
|
+
}
|
|
81
|
+
return defaultHost.getSourceFile(fileName, languageVersion)
|
|
82
|
+
},
|
|
83
|
+
fileExists(fileName) {
|
|
84
|
+
return path.resolve(fileName) === filePath || defaultHost.fileExists(fileName)
|
|
85
|
+
},
|
|
86
|
+
readFile(fileName) {
|
|
87
|
+
if (path.resolve(fileName) === filePath) return source
|
|
88
|
+
return defaultHost.readFile(fileName)
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
return ts.createProgram([filePath], compilerOptions, host)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('BF050 fires symmetrically for single- and multi-component files', () => {
|
|
95
|
+
test('single-component brand import without options.program: BF050', () => {
|
|
96
|
+
expect(bf050s(SINGLE_SOURCE)).toHaveLength(1)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('multi-component brand import without options.program: BF050 — the per-file amortization no longer masks it', () => {
|
|
100
|
+
expect(bf050s(MULTI_SOURCE)).toHaveLength(1)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('multi-component: exactly ONE BF050 for the file, not one per sibling component', () => {
|
|
104
|
+
// Covered by the length assertion above, but pinned separately so a
|
|
105
|
+
// future "just push ctx.errors" refactor that reintroduces per-sibling
|
|
106
|
+
// duplicates fails a test whose NAME says what broke.
|
|
107
|
+
const errors = bf050s(MULTI_SOURCE)
|
|
108
|
+
expect(errors.length).toBeLessThanOrEqual(1)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('single-component with a caller-supplied shared Program: no BF050', () => {
|
|
112
|
+
expect(bf050s(SINGLE_SOURCE, inMemoryProgram(SINGLE_SOURCE))).toHaveLength(0)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('multi-component with a caller-supplied shared Program: no BF050', () => {
|
|
116
|
+
expect(bf050s(MULTI_SOURCE, inMemoryProgram(MULTI_SOURCE))).toHaveLength(0)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -2845,7 +2845,11 @@ describe('Client JS generation', () => {
|
|
|
2845
2845
|
const result = compileJSX(source, 'Probe.tsx', { adapter })
|
|
2846
2846
|
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
2847
2847
|
const js = result.files.find(f => f.type === 'clientJs')!.content
|
|
2848
|
-
|
|
2848
|
+
// The destructure default rides the signal seed (and its controlled
|
|
2849
|
+
// effect) — the standalone `const size = …` extraction is pruned when
|
|
2850
|
+
// nothing else in the init reads it.
|
|
2851
|
+
expect(js).toContain('_p.size ?? 5')
|
|
2852
|
+
expect(js).not.toContain('_p.size ?? 0')
|
|
2849
2853
|
})
|
|
2850
2854
|
})
|
|
2851
2855
|
})
|
|
@@ -304,7 +304,11 @@ const PAGES: PageSpec[] = [
|
|
|
304
304
|
{ path: 'core/adapters/hono-adapter.md' },
|
|
305
305
|
{ path: 'core/adapters/go-template-adapter.md' },
|
|
306
306
|
{ path: 'core/adapters/custom-adapter.md' },
|
|
307
|
-
|
|
307
|
+
// `core/advanced/code-splitting.md` is deliberately absent: it documents
|
|
308
|
+
// stock Vite/Rollup build config (`manualChunks`) and carries no
|
|
309
|
+
// component code, while this extractor only reads ```tsx fences. Adding
|
|
310
|
+
// a token component there purely to keep the page listed here would
|
|
311
|
+
// hollow out the check rather than extend it.
|
|
308
312
|
{ path: 'core/advanced/compiler-internals.md' },
|
|
309
313
|
// `core/advanced/error-codes.md` is handled by the per-BFxxx
|
|
310
314
|
// matcher (see bottom of file) rather than the general extractor:
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: the init body must not eagerly read props it never uses.
|
|
3
|
+
*
|
|
4
|
+
* Props arrive as getters over the parent's reactive state, and a
|
|
5
|
+
* slot-children getter instantiates child components when read — so a
|
|
6
|
+
* stray `const children = _p.children` in a wrapper's init creates a
|
|
7
|
+
* SECOND child instance next to the parent's own `upsertChild` wiring
|
|
8
|
+
* (double event listeners; a Checkbox toggle that cancels itself out).
|
|
9
|
+
* Surfaced by #2537's site migration on site/ui's form-builder, where
|
|
10
|
+
* `<Label><Checkbox onCheckedChange={…}/></Label>` stopped toggling:
|
|
11
|
+
* Vite's ESM import order registers child components before the parent's
|
|
12
|
+
* init runs, so the eager getter read instantiated eagerly instead of
|
|
13
|
+
* hitting the legacy pipeline's pending-init queue.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from 'bun:test'
|
|
16
|
+
import { compileJSX } from '../index.ts'
|
|
17
|
+
import { HonoAdapter } from '../../../adapter-hono/src/adapter/index.ts'
|
|
18
|
+
|
|
19
|
+
function clientJsOf(source: string, path: string): string {
|
|
20
|
+
const result = compileJSX(source, path, { adapter: new HonoAdapter() })
|
|
21
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
22
|
+
expect(errors).toEqual([])
|
|
23
|
+
return result.files.find(f => f.type === 'clientJs')?.content ?? ''
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('unused prop extractions are pruned from init', () => {
|
|
27
|
+
test('template-only children/className props are not extracted', () => {
|
|
28
|
+
// Label's real shape: className feeds a reactive class effect (read
|
|
29
|
+
// via `_p.className` inside the effect), children render only in the
|
|
30
|
+
// SSR-adopted template — neither local binding is used by init.
|
|
31
|
+
const js = clientJsOf(
|
|
32
|
+
`"use client"
|
|
33
|
+
|
|
34
|
+
interface WrapProps {
|
|
35
|
+
className?: string
|
|
36
|
+
children?: unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function Wrap({ className = '', children, ...props }: WrapProps) {
|
|
40
|
+
return (
|
|
41
|
+
<label data-slot="wrap" className={\`base \${className}\`} {...props}>
|
|
42
|
+
{children}
|
|
43
|
+
</label>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export { Wrap }
|
|
48
|
+
`,
|
|
49
|
+
'/virtual/wrap.tsx',
|
|
50
|
+
)
|
|
51
|
+
expect(js).toContain('export function initWrap')
|
|
52
|
+
expect(js).not.toContain('const children = _p.children')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('props the init genuinely reads keep their extraction', () => {
|
|
56
|
+
// `config` is read by a handler, `items` by an init-scope constant —
|
|
57
|
+
// both local bindings are real and must survive the prune.
|
|
58
|
+
const js = clientJsOf(
|
|
59
|
+
`"use client"
|
|
60
|
+
|
|
61
|
+
import { createSignal } from '@barefootjs/client'
|
|
62
|
+
|
|
63
|
+
interface P { config?: { startOpen?: boolean }, items?: string[] }
|
|
64
|
+
|
|
65
|
+
function Widget({ config = {}, items = [] }: P) {
|
|
66
|
+
const [open, setOpen] = createSignal(false)
|
|
67
|
+
const first = items.length > 0 ? items[0] : 'none'
|
|
68
|
+
return <button onClick={() => setOpen(!!config.startOpen)}>{open() ? first : 'closed'}</button>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export { Widget }
|
|
72
|
+
`,
|
|
73
|
+
'/virtual/widget.tsx',
|
|
74
|
+
)
|
|
75
|
+
expect(js).toContain('const config = _p.config ?? {}')
|
|
76
|
+
expect(js).toContain('const items = _p.items ?? []')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -78,6 +78,67 @@ export interface AdapterGenerateOptions {
|
|
|
78
78
|
* incidentals are unaffected.
|
|
79
79
|
*/
|
|
80
80
|
rewriteRelativeImport?: (importPath: string) => string
|
|
81
|
+
/**
|
|
82
|
+
* Ordered list of fully-resolved, absolute URLs to emit as ES module
|
|
83
|
+
* script registrations, in array order. When present (including the
|
|
84
|
+
* empty array `[]`), this **fully supersedes** the adapter-computed
|
|
85
|
+
* `clientJsBasePath` / `barefootJsPath` / `scriptBaseName` computation
|
|
86
|
+
* that adapters otherwise use to bake exactly two script URLs (the
|
|
87
|
+
* shared runtime, then the component's own `.client.js`) at codegen
|
|
88
|
+
* time.
|
|
89
|
+
*
|
|
90
|
+
* Exists for callers — chiefly the Vite plugin — that only learn the
|
|
91
|
+
* real script URLs after bundling: under Vite, filenames are content-
|
|
92
|
+
* hashed, the runtime is not a separately-registered script (it
|
|
93
|
+
* arrives as an ESM import of a shared chunk pulled in by the
|
|
94
|
+
* component's own entry), and the number of scripts a component needs
|
|
95
|
+
* is not fixed at two. The caller is responsible for all of that
|
|
96
|
+
* resolution — including any dev-server client script and the
|
|
97
|
+
* component's own entry — and hands the adapter a plain, ordered URL
|
|
98
|
+
* list to register verbatim.
|
|
99
|
+
*
|
|
100
|
+
* `undefined` (the default) means "fall back to the adapter-computed
|
|
101
|
+
* paths" — this is a purely additive option; every existing caller
|
|
102
|
+
* that never sets it keeps byte-identical output. An empty array is
|
|
103
|
+
* semantically distinct from `undefined`: it means "this component
|
|
104
|
+
* needs no script registrations at all" (e.g. the caller determined
|
|
105
|
+
* the component has no client interactivity), whereas `undefined`
|
|
106
|
+
* means "adapter, please decide using the computed path options."
|
|
107
|
+
*
|
|
108
|
+
* `skipScriptRegistration: true` still wins over this unconditionally
|
|
109
|
+
* — it means "a parent/caller will register scripts for me", which
|
|
110
|
+
* takes precedence regardless of what `scriptAssets` says.
|
|
111
|
+
*/
|
|
112
|
+
scriptAssets?: string[]
|
|
113
|
+
/**
|
|
114
|
+
* Ordered list of fully-resolved, absolute URLs to emit as
|
|
115
|
+
* `<link rel="modulepreload">` hints, in array order, alongside the
|
|
116
|
+
* `<script type="module">` registrations driven by `scriptAssets`.
|
|
117
|
+
*
|
|
118
|
+
* These are the chunks the entry pulls in **transitively** — not the
|
|
119
|
+
* entry's own file, which is already covered by `scriptAssets`. Under
|
|
120
|
+
* Vite, a component's entry chunk is rarely a leaf: it imports the
|
|
121
|
+
* shared `@barefootjs/client` runtime chunk and, for a parent that
|
|
122
|
+
* renders a child island, the child's own entry chunk. Left alone, the
|
|
123
|
+
* browser only discovers those imports after it has fetched and parsed
|
|
124
|
+
* the entry — a second sequential round trip before the component can
|
|
125
|
+
* hydrate. A preload hint issued up front collapses that into one wave.
|
|
126
|
+
*
|
|
127
|
+
* `undefined` (the default) means "the caller has no preload
|
|
128
|
+
* information" — adapters emit nothing, exactly like the
|
|
129
|
+
* `clientJsBasePath`-computed path never emitted preloads before this
|
|
130
|
+
* option existed. An empty array is semantically distinct: it means
|
|
131
|
+
* "resolved, and there is nothing to preload" (e.g. the entry is a
|
|
132
|
+
* leaf with no transitive imports) — also emits nothing, but for a
|
|
133
|
+
* different reason, mirroring the `undefined`/`[]` distinction on
|
|
134
|
+
* `scriptAssets`.
|
|
135
|
+
*
|
|
136
|
+
* Only meaningful alongside a non-empty `scriptAssets` — there is
|
|
137
|
+
* nothing to preload ahead of if nothing is being registered.
|
|
138
|
+
* `skipScriptRegistration: true` still wins over this unconditionally,
|
|
139
|
+
* exactly as it does over `scriptAssets`.
|
|
140
|
+
*/
|
|
141
|
+
preloadAssets?: string[]
|
|
81
142
|
}
|
|
82
143
|
|
|
83
144
|
/**
|
|
@@ -170,26 +231,6 @@ export interface TemplateAdapter {
|
|
|
170
231
|
* Required for adapters that look up templates by filename (e.g. Mojolicious).
|
|
171
232
|
*/
|
|
172
233
|
templatesPerComponent?: boolean
|
|
173
|
-
/**
|
|
174
|
-
* How the application author injects the externals importmap (and any
|
|
175
|
-
* `<link rel="modulepreload">` hints) into the page `<head>` when
|
|
176
|
-
* `externals` / `bundleEntries` are configured.
|
|
177
|
-
*
|
|
178
|
-
* - `'component'` — the adapter ships a render-time component (e.g. Hono's
|
|
179
|
-
* `BfImportMap`) that reads `barefoot-externals.json`; `bf build` emits no
|
|
180
|
-
* static snippet.
|
|
181
|
-
* - `'html-snippet'` — the adapter targets a template-string language (Go
|
|
182
|
-
* html/template, Mojolicious EP) with no component layer, so `bf build`
|
|
183
|
-
* writes a ready-to-include `barefoot-importmap.html` alongside
|
|
184
|
-
* `barefoot-externals.json` (via `renderImportMapHtml`).
|
|
185
|
-
*
|
|
186
|
-
* Optional only for backward compatibility (and internal-only adapters like
|
|
187
|
-
* the CSR test adapter). Every *shipping* adapter must set it — the
|
|
188
|
-
* adapter-tests importmap-injection contract enforces this so a new adapter
|
|
189
|
-
* cannot silently leave configured `externals` with no injection point.
|
|
190
|
-
* See issue #1644.
|
|
191
|
-
*/
|
|
192
|
-
importMapInjection?: 'component' | 'html-snippet'
|
|
193
234
|
/**
|
|
194
235
|
* Module specifier of the SSR shim for `@barefootjs/client` (and
|
|
195
236
|
* `/runtime`). When set, the compiler rewrites client-package imports in
|
package/src/analyzer.ts
CHANGED
|
@@ -160,15 +160,26 @@ export function analyzeComponent(
|
|
|
160
160
|
filePath: string,
|
|
161
161
|
targetComponentName?: string,
|
|
162
162
|
program?: ts.Program,
|
|
163
|
-
acceptsCallbackBody?: CallbackBodyAcceptor
|
|
163
|
+
acceptsCallbackBody?: CallbackBodyAcceptor,
|
|
164
|
+
programIsShared?: boolean
|
|
164
165
|
): AnalyzerContext {
|
|
165
166
|
incrementCounter('filesAnalyzed')
|
|
166
|
-
// Track whether
|
|
167
|
+
// Track whether a shared ts.Program is genuinely in scope. Used downstream
|
|
167
168
|
// to decide whether the silent per-file fallback should also emit a
|
|
168
169
|
// BF050 diagnostic (issue #1248): when the source needs type-based
|
|
169
|
-
// detection but no shared Program is in scope, the
|
|
170
|
-
// misclassify library-getter reactivity
|
|
171
|
-
|
|
170
|
+
// detection but no shared Program is in scope, the per-file fallback may
|
|
171
|
+
// misclassify library-getter reactivity (its virtual host can fail module
|
|
172
|
+
// resolution, silently collapsing brand types to `any`).
|
|
173
|
+
//
|
|
174
|
+
// `programIsShared` lets the caller distinguish "the build supplied a
|
|
175
|
+
// shared corpus Program" from "the compiler pre-built a per-file Program
|
|
176
|
+
// to amortize it across sibling components" — the latter is exactly the
|
|
177
|
+
// fallback BF050 exists to flag, so it must NOT suppress the diagnostic
|
|
178
|
+
// (the old `program !== undefined` inference did, making the same brand
|
|
179
|
+
// import fail in a single-component file but pass in a multi-component
|
|
180
|
+
// one). Defaults to that inference for direct callers who pass a genuine
|
|
181
|
+
// shared Program (tests, site builds).
|
|
182
|
+
const hadSharedProgram = programIsShared ?? program !== undefined
|
|
172
183
|
// Pre-pass: inline calls to same-file reactive factory helpers so the
|
|
173
184
|
// downstream analyzer sees ordinary `createSignal(...)` declarations
|
|
174
185
|
// instead of `const [a, b] = customFactory(...)` (#931). Skipped when
|
package/src/compiler.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { preprocessInlineJsxCallbacks } from './preprocess-inline-jsx-callbacks.
|
|
|
26
26
|
import { extractSsrDefaults } from './ssr-defaults.ts'
|
|
27
27
|
import { computeSsrSeedPlan } from './ssr-seed-plan.ts'
|
|
28
28
|
import { checkRichTypeMethodCalls } from './rich-type-refusal.ts'
|
|
29
|
+
import { ErrorCodes } from './errors.ts'
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* Extended compile options with required adapter
|
|
@@ -116,11 +117,28 @@ function compileMultipleComponents(
|
|
|
116
117
|
// --- Pass 1: analyze + jsxToIR for ALL components ---
|
|
117
118
|
const entries: { componentIR: ComponentIR; ctx: ReturnType<typeof analyzeComponent> }[] = []
|
|
118
119
|
|
|
119
|
-
// Create ts.Program only when the file needs type-based reactivity
|
|
120
|
-
|
|
120
|
+
// Create ts.Program only when the file needs type-based reactivity
|
|
121
|
+
// detection. A caller-supplied Program is only usable while its cached
|
|
122
|
+
// SourceFile still matches `source` — after an upstream rewrite
|
|
123
|
+
// (preprocessInlineJsxCallbacks), the analyzer would silently discard
|
|
124
|
+
// the stale Program PER COMPONENT and rebuild a per-file one each time
|
|
125
|
+
// (measured: 14 rebuilds ≈ 30 s on site/ui's xyflow-demo.tsx, #2537).
|
|
126
|
+
// Detect the staleness here instead, so the rewritten source gets ONE
|
|
127
|
+
// per-file Program shared by every sibling component.
|
|
128
|
+
const callerProgram =
|
|
129
|
+
options.program?.getSourceFile(filePath)?.text === source ? options.program : undefined
|
|
130
|
+
const program = callerProgram ?? (needsTypeBasedDetection(source) ? createProgramForFile(source, filePath)?.program : undefined)
|
|
131
|
+
// Whether a SHARED Program was genuinely supplied by the caller, as
|
|
132
|
+
// opposed to the per-file amortization built on the line above. The
|
|
133
|
+
// distinction feeds BF050: the per-file build is precisely the fallback
|
|
134
|
+
// that diagnostic exists to flag, so it must not suppress it the way a
|
|
135
|
+
// caller-supplied corpus Program does. See `analyzeComponent`'s
|
|
136
|
+
// `programIsShared` docstring — the single-component path gets the same
|
|
137
|
+
// verdict via its default inference from `options.program`.
|
|
138
|
+
const programIsShared = options.program !== undefined
|
|
121
139
|
|
|
122
140
|
for (const componentName of componentNames) {
|
|
123
|
-
const ctx = analyzeComponent(source, filePath, componentName, program, adapter.acceptsCallbackBody)
|
|
141
|
+
const ctx = analyzeComponent(source, filePath, componentName, program, adapter.acceptsCallbackBody, programIsShared)
|
|
124
142
|
|
|
125
143
|
if (!ctx.jsxReturn) {
|
|
126
144
|
errors.push(...ctx.errors)
|
|
@@ -152,6 +170,22 @@ function compileMultipleComponents(
|
|
|
152
170
|
entries.push({ componentIR, ctx })
|
|
153
171
|
}
|
|
154
172
|
|
|
173
|
+
// BF050 is a per-FILE diagnostic (it points at the brand-package import
|
|
174
|
+
// line), but pass 1 runs the analyzer once per component, so a
|
|
175
|
+
// multi-component file accumulates one identical copy per sibling.
|
|
176
|
+
// Keep the first.
|
|
177
|
+
{
|
|
178
|
+
let seenBf050 = false
|
|
179
|
+
const deduped = errors.filter(e => {
|
|
180
|
+
if (e.code !== ErrorCodes.SHARED_PROGRAM_REQUIRED) return true
|
|
181
|
+
if (seenBf050) return false
|
|
182
|
+
seenBf050 = true
|
|
183
|
+
return true
|
|
184
|
+
})
|
|
185
|
+
errors.length = 0
|
|
186
|
+
errors.push(...deduped)
|
|
187
|
+
}
|
|
188
|
+
|
|
155
189
|
// Emit IR files per component when requested. The contract is "if the
|
|
156
190
|
// user asks for IR, they get IR" regardless of `isClientComponent` or
|
|
157
191
|
// adapter (#1297). Single-component files emit `<base>.ir.json`; multi-
|
|
@@ -230,6 +264,8 @@ function compileMultipleComponents(
|
|
|
230
264
|
scriptBaseName,
|
|
231
265
|
siblingTemplatesRegistered: options.siblingTemplatesRegistered,
|
|
232
266
|
rewriteRelativeImport: options.rewriteRelativeImport,
|
|
267
|
+
scriptAssets: options.scriptAssets,
|
|
268
|
+
preloadAssets: options.preloadAssets,
|
|
233
269
|
})
|
|
234
270
|
const moduleExports = generateModuleExports(
|
|
235
271
|
componentIR,
|
|
@@ -671,6 +707,8 @@ export function compileJSX(
|
|
|
671
707
|
scriptBaseName: options.scriptBaseName,
|
|
672
708
|
siblingTemplatesRegistered: options.siblingTemplatesRegistered,
|
|
673
709
|
rewriteRelativeImport: options.rewriteRelativeImport,
|
|
710
|
+
scriptAssets: options.scriptAssets,
|
|
711
|
+
preloadAssets: options.preloadAssets,
|
|
674
712
|
})
|
|
675
713
|
|
|
676
714
|
// `templatesPerComponent` adapters (Mojolicious) emit non-JS template files
|
package/src/index.ts
CHANGED
|
@@ -141,20 +141,6 @@ export type { SourceMapV3 } from './ir-to-client-js/source-map.ts'
|
|
|
141
141
|
// Client JS Combiner (for build scripts)
|
|
142
142
|
export { combineParentChildClientJs } from './combine-client-js.ts'
|
|
143
143
|
|
|
144
|
-
// Externals manifest + importmap snippet renderer (shared by adapters and CLI)
|
|
145
|
-
export { renderImportMapHtml } from './import-map.ts'
|
|
146
|
-
export type { ExternalsManifest, ImportMapManifest } from './import-map.ts'
|
|
147
|
-
|
|
148
|
-
// Build options (shared by adapters and CLI)
|
|
149
|
-
export interface OutputLayout {
|
|
150
|
-
/** Subdirectory for marked templates (default: 'components') */
|
|
151
|
-
templates: string
|
|
152
|
-
/** Subdirectory for client JS files (default: 'components') */
|
|
153
|
-
clientJs: string
|
|
154
|
-
/** Subdirectory for runtime (barefoot.js) (default: same as clientJs) */
|
|
155
|
-
runtime: string
|
|
156
|
-
}
|
|
157
|
-
|
|
158
144
|
export interface PostBuildContext {
|
|
159
145
|
/** Collected types: componentName → types content */
|
|
160
146
|
types: Map<string, string>
|
|
@@ -179,42 +165,6 @@ export interface PostBuildContext {
|
|
|
179
165
|
markChanged?: () => void
|
|
180
166
|
}
|
|
181
167
|
|
|
182
|
-
/**
|
|
183
|
-
* Vendor code-splitting spec for a single package.
|
|
184
|
-
*
|
|
185
|
-
* - `true` / `{ chunk: true }` — locate the package's browser-ready entry
|
|
186
|
-
* (umd → unpkg → jsdelivr → import condition) and copy it to the output dir.
|
|
187
|
-
* - `{ url }` — CDN passthrough: skip local copy, use the URL as-is in the importmap.
|
|
188
|
-
* - `preload: true` — emit a `<link rel="modulepreload">` hint for this entry.
|
|
189
|
-
* - `rebundle: true` — re-bundle the resolved entry with esbuild into a self-contained
|
|
190
|
-
* ESM file, inlining all dependencies. Useful for packages (e.g. `yjs`) whose
|
|
191
|
-
* `dist/*.mjs` files still contain bare external imports that browsers cannot resolve.
|
|
192
|
-
*/
|
|
193
|
-
export type ExternalSpec =
|
|
194
|
-
| true
|
|
195
|
-
| { chunk?: true; preload?: boolean; rebundle?: boolean }
|
|
196
|
-
| { url: string; preload?: boolean }
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* An entry point to bundle directly with esbuild.
|
|
200
|
-
* Externals declared in `BuildOptions.externals` are applied automatically.
|
|
201
|
-
* `@barefootjs/client`, `@barefootjs/client/runtime`, and
|
|
202
|
-
* `@barefootjs/client/reactive` are always kept external, so you never need
|
|
203
|
-
* to list them here. They resolve through the page's import map to the shared
|
|
204
|
-
* `barefoot.js` runtime; inlining them would fork the reactive runtime and
|
|
205
|
-
* duplicate signals (#927).
|
|
206
|
-
* Use this for modules that are not barefoot components (e.g. plain TS entry
|
|
207
|
-
* points that import external vendor packages).
|
|
208
|
-
*/
|
|
209
|
-
export interface BundleEntry {
|
|
210
|
-
/** Entry file path relative to the config file */
|
|
211
|
-
entry: string
|
|
212
|
-
/** Output filename placed in the client JS output directory */
|
|
213
|
-
outfile: string
|
|
214
|
-
/** Additional packages to mark as external beyond those in `externals` */
|
|
215
|
-
externals?: string[]
|
|
216
|
-
}
|
|
217
|
-
|
|
218
168
|
/**
|
|
219
169
|
* Project layout paths used by registry tooling (`bf add`, `search`,
|
|
220
170
|
* `meta:extract`, `tokens`, `inspect`, etc.). These are consumed only by
|
|
@@ -230,79 +180,6 @@ export interface BarefootPaths {
|
|
|
230
180
|
meta: string
|
|
231
181
|
}
|
|
232
182
|
|
|
233
|
-
export interface BuildOptions {
|
|
234
|
-
/**
|
|
235
|
-
* Project layout paths. Consumed by registry tooling, not the build pipeline.
|
|
236
|
-
* Defaults to `{ components: 'components/ui', tokens: 'tokens', meta: 'meta' }`
|
|
237
|
-
* when omitted.
|
|
238
|
-
*/
|
|
239
|
-
paths?: BarefootPaths
|
|
240
|
-
/** Source component directories relative to config file */
|
|
241
|
-
components?: string[]
|
|
242
|
-
/** Output directory relative to config file */
|
|
243
|
-
outDir?: string
|
|
244
|
-
/** Minify client JS output */
|
|
245
|
-
minify?: boolean
|
|
246
|
-
/** Add content hash to client JS filenames */
|
|
247
|
-
contentHash?: boolean
|
|
248
|
-
/** Custom output directory layout */
|
|
249
|
-
outputLayout?: OutputLayout
|
|
250
|
-
/** Post-build hook called after minification, before manifest write */
|
|
251
|
-
postBuild?: (ctx: PostBuildContext) => Promise<void> | void
|
|
252
|
-
/**
|
|
253
|
-
* Vendor packages to split out as separately-cached browser chunks.
|
|
254
|
-
* The CLI copies each package's browser-ready bundle to the output dir,
|
|
255
|
-
* then emits `dist/barefoot-externals.json` with the importmap and
|
|
256
|
-
* `--external` flag list for use in the app's own `bun build`.
|
|
257
|
-
*
|
|
258
|
-
* `@barefootjs/client*` dedup entries are added automatically whenever
|
|
259
|
-
* this field is non-empty, preventing reactive-primitive duplication (#927).
|
|
260
|
-
*/
|
|
261
|
-
externals?: Record<string, ExternalSpec>
|
|
262
|
-
/**
|
|
263
|
-
* URL base path for vendor chunks in the emitted importmap.
|
|
264
|
-
* Defaults to `/<runtimeSubdir>/` (e.g., `/static/components/`).
|
|
265
|
-
*/
|
|
266
|
-
externalsBasePath?: string
|
|
267
|
-
/**
|
|
268
|
-
* Additional entry points to bundle with esbuild directly, bypassing the
|
|
269
|
-
* barefoot component compiler. Useful for plain TS/TSX modules (e.g. canvas
|
|
270
|
-
* init entry points) that import vendor packages listed in `externals`.
|
|
271
|
-
* Each entry is bundled as ESM with all `externals` automatically excluded.
|
|
272
|
-
*/
|
|
273
|
-
bundleEntries?: BundleEntry[]
|
|
274
|
-
/**
|
|
275
|
-
* Import prefixes resolved at build time rather than left as bare
|
|
276
|
-
* specifiers in the emitted client JS. Use this for tsconfig `paths`
|
|
277
|
-
* aliases like `@/`, `@ui/`, `@app/` so the compiler does not emit
|
|
278
|
-
* them as browser imports.
|
|
279
|
-
*
|
|
280
|
-
* Forwarded to `compileJSX` as `CompileOptions.localImportPrefixes`.
|
|
281
|
-
*/
|
|
282
|
-
localImportPrefixes?: string[]
|
|
283
|
-
/**
|
|
284
|
-
* How the CLI produces `barefoot.js` (the client runtime bundle):
|
|
285
|
-
* - `'treeshake'` (default) — bundle only the runtime exports this
|
|
286
|
-
* project's compiled client JS actually imports, plus a small
|
|
287
|
-
* always-kept public mount API (`render`, `hydrate`, etc.).
|
|
288
|
-
* - `'treeshake-exact'` — same collection, but without the always-kept
|
|
289
|
-
* set. Smaller output; a hand-written page script the CLI never
|
|
290
|
-
* compiles must list any runtime names it calls directly in
|
|
291
|
-
* `runtimeKeep`, or they're silently dropped.
|
|
292
|
-
* - `'full'` — copy the entire prebuilt runtime bundle verbatim.
|
|
293
|
-
* See `@barefootjs/cli`'s `runtime-treeshake.ts` for the collector and
|
|
294
|
-
* `ALWAYS_KEEP_RUNTIME_EXPORTS` for the always-kept names.
|
|
295
|
-
*/
|
|
296
|
-
runtimeBundle?: 'treeshake' | 'treeshake-exact' | 'full'
|
|
297
|
-
/**
|
|
298
|
-
* Extra `@barefootjs/client*` export names to force-keep in `barefoot.js`
|
|
299
|
-
* under `runtimeBundle: 'treeshake'` or `'treeshake-exact'` — for names
|
|
300
|
-
* only ever referenced from hand-written page scripts the CLI never
|
|
301
|
-
* compiles.
|
|
302
|
-
*/
|
|
303
|
-
runtimeKeep?: string[]
|
|
304
|
-
}
|
|
305
|
-
|
|
306
183
|
// AttrValue constructors
|
|
307
184
|
export { AttrValueOf } from './types.ts'
|
|
308
185
|
|
|
@@ -182,6 +182,15 @@ export function emitRegistrationAndHydration(
|
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
const registryKey = nameForRegistryRef(name)
|
|
185
|
+
// When the registry key was file-scoped (`Name__<8hex>`, for a
|
|
186
|
+
// non-exported component — see `component-scope.ts`), carry the plain
|
|
187
|
+
// name in the def so the runtime has something to build scope IDs from.
|
|
188
|
+
// The key is an internal disambiguator; `bf-s` is a documented contract
|
|
189
|
+
// (`Name_abc123`) that the SSR adapters honour, and without this the CSR
|
|
190
|
+
// path stamps the hashed key into the attribute instead (#2518).
|
|
191
|
+
if (registryKey !== name) {
|
|
192
|
+
defParts.push(`name: '${name}'`)
|
|
193
|
+
}
|
|
185
194
|
const hydrateLine = `hydrate('${registryKey}', { ${defParts.join(', ')} })`
|
|
186
195
|
|
|
187
196
|
// Emit a callable shim with the original component name so consumers
|
|
@@ -19,6 +19,7 @@ import { computeDeferredChildSlots } from './html-template.ts'
|
|
|
19
19
|
import { emitChildComponentImports } from './child-components.ts'
|
|
20
20
|
import { classifyLocalDeclarations } from './init-declarations.ts'
|
|
21
21
|
import { emitModuleLevelDeclarations, resolveFinalImports } from './emit-module-level.ts'
|
|
22
|
+
import { pruneUnusedPropExtractions } from './prune-unused-prop-extractions.ts'
|
|
22
23
|
import { buildPhaseCtx, PHASES, runPhases } from './phases.ts'
|
|
23
24
|
import { rewritePropsObjectRef } from './rewrite-props-object.ts'
|
|
24
25
|
import { buildInlinableConstants } from './emit-registration.ts'
|
|
@@ -113,7 +114,9 @@ export function generateInitFunction(
|
|
|
113
114
|
// Replacer-function form: a plain replacement string would let literal
|
|
114
115
|
// `$&`/`$1`/`$$` sequences in user helper bodies or import paths be
|
|
115
116
|
// reinterpreted by `String.replace`'s special-pattern handling.
|
|
116
|
-
const codeWithModuleConstants =
|
|
117
|
+
const codeWithModuleConstants = pruneUnusedPropExtractions(
|
|
118
|
+
generatedCode.replace(MODULE_CONSTANTS_PLACEHOLDER, () => moduleConstantsCode),
|
|
119
|
+
)
|
|
117
120
|
const allImportLines = resolveFinalImports(codeWithModuleConstants, ir, localImportPrefixes)
|
|
118
121
|
|
|
119
122
|
return codeWithModuleConstants.replace(IMPORT_PLACEHOLDER, () => allImportLines)
|
|
@@ -283,7 +283,11 @@ function generateTemplateOnlyMount(ir: ComponentIR, ctx: ClientJsContext): strin
|
|
|
283
283
|
lines.push('')
|
|
284
284
|
lines.push(`function init${name}() {}`)
|
|
285
285
|
lines.push('')
|
|
286
|
-
|
|
286
|
+
// `name: '...'` only when the key was file-scoped — same reason as in
|
|
287
|
+
// `emitRegistrationAndHydration`: the hashed key must not reach `bf-s`
|
|
288
|
+
// (#2518).
|
|
289
|
+
const nameField = registryKey !== name ? `, name: '${name}'` : ''
|
|
290
|
+
lines.push(`hydrate('${registryKey}', { init: init${name}, template: (${PROPS_PARAM}) => \`${templateHtml}\`${nameField} })`)
|
|
287
291
|
// See `emitRegistrationAndHydration` (./emit-registration.ts) for the
|
|
288
292
|
// rationale on why the component is also emitted as a callable
|
|
289
293
|
// shim. The same applies for template-only components since they
|