@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.
Files changed (37) hide show
  1. package/dist/adapters/interface.d.ts +61 -20
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/analyzer.d.ts +1 -1
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/compiler.d.ts.map +1 -1
  6. package/dist/index.d.ts +0 -120
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +322 -263
  9. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/generate-init.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/prune-unused-prop-extractions.d.ts +7 -0
  12. package/dist/ir-to-client-js/prune-unused-prop-extractions.d.ts.map +1 -0
  13. package/dist/types.d.ts +25 -0
  14. package/dist/types.d.ts.map +1 -1
  15. package/dist/value-references.d.ts +7 -7
  16. package/dist/value-references.d.ts.map +1 -1
  17. package/package.json +3 -7
  18. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +183 -312
  19. package/src/__tests__/bf050-single-multi-symmetry.test.ts +118 -0
  20. package/src/__tests__/client-js-generation.test.ts +5 -1
  21. package/src/__tests__/doc-examples.test.ts +5 -1
  22. package/src/__tests__/prune-unused-prop-extractions.test.ts +78 -0
  23. package/src/adapters/interface.ts +61 -20
  24. package/src/analyzer.ts +16 -5
  25. package/src/compiler.ts +41 -3
  26. package/src/index.ts +0 -123
  27. package/src/ir-to-client-js/emit-registration.ts +9 -0
  28. package/src/ir-to-client-js/generate-init.ts +4 -1
  29. package/src/ir-to-client-js/index.ts +5 -1
  30. package/src/ir-to-client-js/prune-unused-prop-extractions.ts +108 -0
  31. package/src/types.ts +25 -0
  32. package/src/value-references.ts +7 -7
  33. package/dist/import-map.d.ts +0 -56
  34. package/dist/import-map.d.ts.map +0 -1
  35. package/dist/import-map.js +0 -18
  36. package/src/__tests__/import-map.test.ts +0 -75
  37. 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
- expect(js).toContain('const size = _p.size ?? 5')
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
- { path: 'core/advanced/code-splitting.md' },
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 the caller supplied a shared ts.Program. Used downstream
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 regex fallback may
170
- // misclassify library-getter reactivity.
171
- const hadSharedProgram = program !== undefined
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 detection
120
- const program = options.program ?? (needsTypeBasedDetection(source) ? createProgramForFile(source, filePath)?.program : undefined)
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 = generatedCode.replace(MODULE_CONSTANTS_PLACEHOLDER, () => moduleConstantsCode)
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
- lines.push(`hydrate('${registryKey}', { init: init${name}, template: (${PROPS_PARAM}) => \`${templateHtml}\` })`)
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