@barefootjs/jsx 0.28.0 → 0.28.1

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.
@@ -17,6 +17,30 @@ export declare function detectUsedImports(code: string): Set<string>;
17
17
  * compiler-emitted /runtime subpath).
18
18
  */
19
19
  export declare function collectUserDomImports(ir: ComponentIR): string[];
20
+ /**
21
+ * Build the "is this local name used as a value in the generated code?"
22
+ * test used to decide which imported specifiers survive into the client
23
+ * bundle. Prefers a real value-reference set over the historical
24
+ * `\bname\b` text scan (#2432: an object key or string literal that
25
+ * merely spells an imported name used to emit a phantom import). Falls
26
+ * back to a substring scan when the generated text cannot be parsed
27
+ * cleanly — a partial parse would under-report references and DROP a
28
+ * needed import. The reference set is computed at most once per call.
29
+ *
30
+ * The fallback is a plain `includes()`, not a `\bname\b` regex: `\b` is
31
+ * defined over `[A-Za-z0-9_]`, so a `$`-prefixed name (`$fetch`, as
32
+ * exported by `ofetch`) or a non-ASCII local both sit outside a word
33
+ * boundary and would never match — silently dropping the import, the one
34
+ * failure direction this helper must never take. Worse, splicing
35
+ * `localName` straight into `new RegExp(...)` treated `$` as the
36
+ * end-of-input anchor, so `\b$fetch\b` couldn't match `$fetch` at all.
37
+ * `includes()` is deliberately COARSER than a word-boundary scan (it
38
+ * matches `helper` inside `helperFoo` too) — that's fine here: the
39
+ * fallback's only job is "never under-report", and over-keeping an
40
+ * import whose binding already exists is harmless, while dropping one is
41
+ * fatal.
42
+ */
43
+ export declare function makeValueUsageTest(generatedCode: string): (localName: string) => boolean;
20
44
  /**
21
45
  * Collect external (non-DOM, non-component) imports that are used in generated code.
22
46
  * These are third-party libraries like @barefootjs/form, zod, etc. that need to be
@@ -1 +1 @@
1
- {"version":3,"file":"imports.d.ts","sourceRoot":"","sources":["../../src/ir-to-client-js/imports.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAU,MAAM,aAAa,CAAA;AAItD,eAAO,MAAM,yBAAyB,YACpC,cAAc,EAAE,YAAY,EAAE,cAAc,EAAE,WAAW,EAAE,SAAS,EACpE,SAAS,EAAE,QAAQ,EAAE,iBAAiB,EAAE,cAAc,EAAE,UAAU,EAAE,kBAAkB,EAAE,cAAc,EAAE,WAAW,EAAE,wBAAwB,EAC7I,iBAAiB,EAAE,aAAa,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,WAAW,EAAE,aAAa,EACrG,cAAc,EACd,gBAAgB,EAAE,eAAe,EAAE,YAAY,EAC/C,cAAc,EAAE,gBAAgB,EAAE,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,kBAAkB,EAC3H,KAAK,EAAE,SAAS,EAAE,eAAe,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU,EAUxG,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,YAAY,EAEzD,WAAW,EAAE,SAAS,EAItB,MAAM,EAIN,YAAY,CACJ,CAAA;AAEV,gDAAgD;AAChD,eAAO,MAAM,qBAAqB,wtBAA4B,CAAA;AAE9D,eAAO,MAAM,cAAc,+BAA+B,CAAA;AAE1D,eAAO,MAAM,kBAAkB,qCAAqC,CAAA;AACpE,eAAO,MAAM,4BAA4B,qCAAqC,CAAA;AAE9E;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAqB3D;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,WAAW,GAAG,MAAM,EAAE,CAiB/D;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,EAAE,mBAAmB,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAmCvH"}
1
+ {"version":3,"file":"imports.d.ts","sourceRoot":"","sources":["../../src/ir-to-client-js/imports.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAU,MAAM,aAAa,CAAA;AAKtD,eAAO,MAAM,yBAAyB,YACpC,cAAc,EAAE,YAAY,EAAE,cAAc,EAAE,WAAW,EAAE,SAAS,EACpE,SAAS,EAAE,QAAQ,EAAE,iBAAiB,EAAE,cAAc,EAAE,UAAU,EAAE,kBAAkB,EAAE,cAAc,EAAE,WAAW,EAAE,wBAAwB,EAC7I,iBAAiB,EAAE,aAAa,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,WAAW,EAAE,aAAa,EACrG,cAAc,EACd,gBAAgB,EAAE,eAAe,EAAE,YAAY,EAC/C,cAAc,EAAE,gBAAgB,EAAE,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,kBAAkB,EAC3H,KAAK,EAAE,SAAS,EAAE,eAAe,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU,EAUxG,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,YAAY,EAEzD,WAAW,EAAE,SAAS,EAItB,MAAM,EAIN,YAAY,CACJ,CAAA;AAEV,gDAAgD;AAChD,eAAO,MAAM,qBAAqB,wtBAA4B,CAAA;AAE9D,eAAO,MAAM,cAAc,+BAA+B,CAAA;AAE1D,eAAO,MAAM,kBAAkB,qCAAqC,CAAA;AACpE,eAAO,MAAM,4BAA4B,qCAAqC,CAAA;AAE9E;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAqB3D;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,WAAW,GAAG,MAAM,EAAE,CAoB/D;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,GAAG,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAWxF;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,EAAE,mBAAmB,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAsCvH"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Single door for "is this identifier a VALUE reference in emitted JS".
3
+ *
4
+ * Replaces `\bname\b` text scans at the import-emission sites (#2432): a
5
+ * regex scan can't tell a genuine value reference (`paperColor({ ... })`)
6
+ * from an object key or string literal that merely spells an imported
7
+ * name (`{ Theme: 'テーマ' }`). That false match used to make
8
+ * `collectExternalImports` re-emit a per-specifier type-only import
9
+ * (`import { paperColor, type Theme } from '../lib/theme'`) as a VALUE
10
+ * import, which the CLI's relative-import inliner then placed in the IIFE's
11
+ * `return { … }` with no binding — `ReferenceError: Theme is not defined`
12
+ * at load, killing the whole page's client JS.
13
+ *
14
+ * `packages/cli`'s `detectStrippedReferences` (in `resolve-imports.ts`)
15
+ * shares the same classifier for its own dangling-reference scan, so the
16
+ * two "is this a real use" checks in the pipeline can never drift apart.
17
+ */
18
+ import ts from 'typescript';
19
+ /**
20
+ * Identifier-position classifier: returns `true` when `id` is being USED
21
+ * as a value, `false` when it's a declaration name, property key, member-
22
+ * access name, or other non-reference slot.
23
+ *
24
+ * A ShorthandPropertyAssignment (`{ Theme }`) intentionally counts as a
25
+ * reference — it reads the binding, it doesn't just spell its name.
26
+ *
27
+ * Caveat: this is a syntactic test, not a scope analysis. If a local
28
+ * function parameter happens to share a name with an imported binding,
29
+ * references inside that function's body will count as references to
30
+ * the import (false positive). Acceptable: over-counting a reference
31
+ * just means we keep an import we didn't strictly need, which is a
32
+ * strict improvement over the alternative failure direction (dropping a
33
+ * needed import and producing a `ReferenceError`).
34
+ */
35
+ export declare function isValueReferenceIdentifier(id: ts.Identifier): boolean;
36
+ /**
37
+ * Parse `code` and collect the text of every identifier that is a VALUE
38
+ * reference per `isValueReferenceIdentifier`.
39
+ *
40
+ * Returns `null` when the text did not parse cleanly. `null` means
41
+ * "cannot answer" — callers MUST fall back to their previous (regex-scan)
42
+ * behaviour rather than treating it as an empty set. Narrowing on a
43
+ * partial parse would DROP a needed import, which is the failure
44
+ * direction we must never take (a phantom missing-import build failure
45
+ * is recoverable; a silently dead client bundle is not).
46
+ */
47
+ export declare function collectValueReferencedNames(code: string): Set<string> | null;
48
+ //# sourceMappingURL=value-references.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"value-references.d.ts","sourceRoot":"","sources":["../src/value-references.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,MAAM,YAAY,CAAA;AAE3B;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,0BAA0B,CAAC,EAAE,EAAE,EAAE,CAAC,UAAU,GAAG,OAAO,CAgCrE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CA0B5E"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/jsx",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
4
4
  "description": "JSX compiler for BarefootJS - transforms JSX to server HTML + client JS",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -53,7 +53,7 @@
53
53
  "directory": "packages/jsx"
54
54
  },
55
55
  "dependencies": {
56
- "@barefootjs/shared": "0.28.0"
56
+ "@barefootjs/shared": "0.28.1"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@barefootjs/client": ">=0.2.0",
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from 'bun:test'
2
- import { collectExternalImports } from '../../ir-to-client-js/imports'
2
+ import { collectExternalImports, collectUserDomImports } from '../../ir-to-client-js/imports'
3
3
  import type { ComponentIR, ImportInfo, SourceLocation } from '../../types'
4
4
 
5
5
  const dummyLoc: SourceLocation = { file: 'test.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } }
@@ -229,4 +229,101 @@ describe('collectExternalImports', () => {
229
229
  "import { z } from 'zod'",
230
230
  ])
231
231
  })
232
+
233
+ // #2432: `collectExternalImports` used a `\bname\b` text scan to decide
234
+ // which specifiers survive into the client bundle. That scan never
235
+ // consulted the per-specifier `isTypeOnly` flag, so a per-specifier
236
+ // `import { paperColor, type Theme } from '../lib/theme'` re-emitted
237
+ // `Theme` as a VALUE import whenever the word "Theme" merely appeared in
238
+ // the generated code (e.g. as an object key) — the CLI's relative-import
239
+ // inliner then put `Theme` in the IIFE's `return { … }` with no binding,
240
+ // producing `ReferenceError: Theme is not defined` at load.
241
+ test('per-specifier type-only specifier is not emitted, while its value sibling is (#2432)', () => {
242
+ const ir: ReturnType<typeof makeIR> = makeIR([{
243
+ source: '../lib/theme',
244
+ specifiers: [
245
+ { name: 'paperColor', alias: null, isDefault: false, isNamespace: false },
246
+ { name: 'Theme', alias: null, isDefault: false, isNamespace: false, isTypeOnly: true },
247
+ ],
248
+ isTypeOnly: false,
249
+ loc: dummyLoc,
250
+ }])
251
+ const code = "const labels = { Theme: 'テーマ' };\nconst c = paperColor({ paper: '#fff' });"
252
+ const result = collectExternalImports(ir, code)
253
+ expect(result).toEqual(["import { paperColor } from '../lib/theme'"])
254
+ })
255
+
256
+ test('a value specifier that appears ONLY as an object key is not emitted (#2432)', () => {
257
+ const ir = makeIR([makeImport('./utils', ['helper'])])
258
+ const code = 'const o = { helper: 1 };'
259
+ const result = collectExternalImports(ir, code)
260
+ expect(result).toEqual([])
261
+ })
262
+
263
+ test('a value specifier that appears ONLY inside a string literal is not emitted (#2432)', () => {
264
+ const ir = makeIR([makeImport('./utils', ['helper'])])
265
+ const code = "const s = 'helper is a function';"
266
+ const result = collectExternalImports(ir, code)
267
+ expect(result).toEqual([])
268
+ })
269
+
270
+ test('a value specifier that appears ONLY as a property-access name is not emitted (#2432)', () => {
271
+ const ir = makeIR([makeImport('./utils', ['helper'])])
272
+ const code = 'obj.helper();'
273
+ const result = collectExternalImports(ir, code)
274
+ expect(result).toEqual([])
275
+ })
276
+
277
+ test('a value specifier used via shorthand property IS emitted (#2432)', () => {
278
+ const ir = makeIR([makeImport('./utils', ['helper'])])
279
+ const code = 'const o = { helper };'
280
+ const result = collectExternalImports(ir, code)
281
+ expect(result).toEqual(["import { helper } from './utils'"])
282
+ })
283
+
284
+ test('unparseable generated text falls back to the text scan (#2432)', () => {
285
+ const ir = makeIR([makeImport('some-lib', ['helper'])])
286
+ const code = 'MyComponent helper()'
287
+ const result = collectExternalImports(ir, code)
288
+ expect(result).toEqual(["import { helper } from 'some-lib'"])
289
+ })
290
+
291
+ // These two pin the fallback branch specifically (generated text that does
292
+ // NOT parse cleanly, so `makeValueUsageTest` falls back to a substring
293
+ // scan rather than the AST-based value-reference set). #2432: the
294
+ // fallback used to be a `new RegExp(`\\b${localName}\\b`)` scan, which
295
+ // silently DROPPED the import for either shape below — `\b` isn't defined
296
+ // for `$` or non-ASCII characters, and `$` is also a regex metacharacter
297
+ // (end-of-input anchor) when spliced unescaped into `new RegExp(...)`, so
298
+ // `\b$fetch\b` could never match `$fetch` at all. Dropping the import is
299
+ // the one failure direction this helper must never take.
300
+ test('fallback matches a $-prefixed specifier (#2432)', () => {
301
+ const ir = makeIR([makeImport('ofetch', ['$fetch'])])
302
+ const code = 'MyComponent $fetch()'
303
+ const result = collectExternalImports(ir, code)
304
+ expect(result).toEqual(["import { $fetch } from 'ofetch'"])
305
+ })
306
+
307
+ test('fallback matches a non-ASCII specifier (#2432)', () => {
308
+ const ir = makeIR([makeImport('some-lib', ['日本語'])])
309
+ const code = 'MyComponent 日本語()'
310
+ const result = collectExternalImports(ir, code)
311
+ expect(result).toEqual(["import { 日本語 } from 'some-lib'"])
312
+ })
313
+ })
314
+
315
+ describe('collectUserDomImports', () => {
316
+ test('per-specifier type-only specifier is not emitted from the runtime subpath (#2432)', () => {
317
+ const ir = makeIR([{
318
+ source: '@barefootjs/client',
319
+ specifiers: [
320
+ { name: 'createSignal', alias: null, isDefault: false, isNamespace: false },
321
+ { name: 'Signal', alias: null, isDefault: false, isNamespace: false, isTypeOnly: true },
322
+ ],
323
+ isTypeOnly: false,
324
+ loc: dummyLoc,
325
+ }])
326
+ const result = collectUserDomImports(ir)
327
+ expect(result).toEqual(['createSignal'])
328
+ })
232
329
  })
@@ -0,0 +1,60 @@
1
+ /**
2
+ * End-to-end compiler pin for issue #2432's minimal reproduction.
3
+ *
4
+ * `collectExternalImports` used to decide which imported specifiers to
5
+ * re-emit into generated client JS with a `\bname\b` text scan over the
6
+ * generated code, never consulting the PER-SPECIFIER `isTypeOnly` flag
7
+ * (only the whole-declaration `import type { ... }` flag). So
8
+ * `import { paperColor, type Theme } from '../lib/theme'` re-emitted
9
+ * `Theme` as a VALUE import whenever the word "Theme" merely appeared in
10
+ * the emitted code — here, as an object key (`{ Theme: 'テーマ' }`).
11
+ *
12
+ * The CLI's relative-import inliner then places every requested name in
13
+ * the target module's top-level IIFE `return { … }`. `Theme` is a
14
+ * type-only export with no runtime binding, so the `return` referenced an
15
+ * undeclared name — `ReferenceError: Theme is not defined` at load,
16
+ * which kills the *entire* page's client JS before hydrate ever runs.
17
+ * The failure is silent from the user's perspective: the SSR-rendered
18
+ * markup is already on screen, so the page looks fine even though no
19
+ * interactivity ever wires up. See piconic-ai/barefootjs#2432.
20
+ */
21
+
22
+ import { describe, test, expect } from 'bun:test'
23
+ import { compileJSX } from '../compiler'
24
+ import { TestAdapter } from '../adapters/test-adapter'
25
+
26
+ const adapter = new TestAdapter()
27
+
28
+ describe('type-only import leak (#2432)', () => {
29
+ test('a per-specifier type-only import is never re-emitted into client JS', () => {
30
+ const source = `
31
+ 'use client'
32
+ import { createSignal } from '@barefootjs/client'
33
+ import { paperColor, type Theme } from '../lib/theme'
34
+
35
+ export function ThemedBadge() {
36
+ const labels = { Theme: 'テーマ' }
37
+ const [color, setColor] = createSignal(paperColor({ paper: '#fff' }))
38
+ return <div title={labels.Theme}>{color()}</div>
39
+ }
40
+ `
41
+
42
+ const result = compileJSX(source, 'ThemedBadge.tsx', { adapter })
43
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
44
+
45
+ const clientJs = result.files.find(f => f.type === 'clientJs')
46
+ expect(clientJs).toBeDefined()
47
+ const code = clientJs!.content
48
+
49
+ // The value specifier survives as a real import.
50
+ expect(code).toContain("import { paperColor } from '../lib/theme'")
51
+
52
+ // No import statement anywhere in the bundle mentions `Theme` — the
53
+ // only legal destination for a value import of that name would be a
54
+ // binding that doesn't exist in the compiled `../lib/theme` module.
55
+ const importLines = code.split('\n').filter(line => line.trim().startsWith('import'))
56
+ for (const line of importLines) {
57
+ expect(line).not.toContain('Theme')
58
+ }
59
+ })
60
+ })
package/src/analyzer.ts CHANGED
@@ -3974,6 +3974,9 @@ function importsBrowserOnlyClientApi(ctx: AnalyzerContext): boolean {
3974
3974
  if (imp.source !== '@barefootjs/client') continue
3975
3975
  if (imp.isTypeOnly) continue
3976
3976
  for (const spec of imp.specifiers) {
3977
+ // A per-specifier type-only import (`import { type onMount }`) is not
3978
+ // a browser-only API use — it has no runtime binding (#2432).
3979
+ if (spec.isTypeOnly) continue
3977
3980
  const importedName = spec.name
3978
3981
  if (BROWSER_ONLY_CLIENT_APIS.has(importedName)) return true
3979
3982
  }
package/src/compiler.ts CHANGED
@@ -18,7 +18,7 @@ import { stripClientBuiltinImports } from './builtins.ts'
18
18
  import { generateClientJs, generateClientJsWithSourceMap, analyzeClientNeeds } from './ir-to-client-js/index.ts'
19
19
  import { decideClientOnlyElision } from './ir-to-client-js/client-only-elision.ts'
20
20
  import { emitModuleLevelDeclarations } from './ir-to-client-js/emit-module-level.ts'
21
- import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode } from './ir-to-client-js/imports.ts'
21
+ import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode, makeValueUsageTest } from './ir-to-client-js/imports.ts'
22
22
  import { setActiveComponentScope, computeFileScope } from './ir-to-client-js/component-scope.ts'
23
23
  import { generateModuleExports, collectInlineExportedNames } from './module-exports.ts'
24
24
  import { applyCssLayerPrefix } from './css-layer-prefixer.ts'
@@ -583,6 +583,7 @@ export function compileJSX(
583
583
  // in the generated body (e.g. an initializer that calls an imported
584
584
  // helper: `createSignal(defaultValue())`).
585
585
  const externalImportLines: string[] = []
586
+ const isUsedAsValue = makeValueUsageTest(body)
586
587
  for (const imp of ctx.imports) {
587
588
  if (imp.isTypeOnly) continue
588
589
  if (imp.source === '@barefootjs/client' || imp.source === RUNTIME_MODULE) continue
@@ -591,7 +592,7 @@ export function compileJSX(
591
592
  continue
592
593
  }
593
594
  const used = imp.specifiers
594
- .filter(s => !s.isDefault && !s.isNamespace && new RegExp(`\\b${s.alias || s.name}\\b`).test(body))
595
+ .filter(s => !s.isDefault && !s.isNamespace && !s.isTypeOnly && isUsedAsValue(s.alias || s.name))
595
596
  .map(s => s.alias ? `${s.name} as ${s.alias}` : s.name)
596
597
  if (used.length > 0) {
597
598
  externalImportLines.push(`import { ${used.join(', ')} } from '${imp.source}'`)
@@ -638,6 +639,9 @@ export function compileJSX(
638
639
  if (imp.isTypeOnly) continue
639
640
  if (!imp.source.startsWith('./') && !imp.source.startsWith('../')) continue
640
641
  for (const spec of imp.specifiers) {
642
+ // A type-only specifier must not force a `.client.js` source
643
+ // rewrite — it has no runtime binding (#2432).
644
+ if (spec.isTypeOnly) continue
641
645
  if (ctx.importedClientSignalNames.has(spec.alias ?? spec.name)) {
642
646
  sources.add(imp.source)
643
647
  break
package/src/errors.ts CHANGED
@@ -53,6 +53,14 @@ export const ErrorCodes = {
53
53
  // or an undeclared component — fail loud with the import to add.
54
54
  BUILTIN_REQUIRES_IMPORT: 'BF054',
55
55
 
56
+ // A relative `.ts` module inlined into a client bundle (`resolveRelativeImports`'s
57
+ // top-level IIFE wrap) was asked for a name it does not export. The IIFE's
58
+ // `return { … }` has no binding for that name, so the reference throws
59
+ // `ReferenceError: <name> is not defined` at load — killing the page's
60
+ // client JS before hydrate. Fail the build instead of shipping the
61
+ // dangling reference (#2432).
62
+ INLINED_IMPORT_MISSING_EXPORT: 'BF055',
63
+
56
64
  // Init statement errors (BF052)
57
65
  UNDECLARED_INIT_STATEMENT_REFERENCE: 'BF052',
58
66
 
@@ -155,6 +163,9 @@ const errorMessages: Record<ErrorCode, string> = {
155
163
  [ErrorCodes.STRIPPED_CLIENT_IMPORT_REFERENCED]:
156
164
  "Import was stripped from the client bundle but its binding is still referenced. Client components ('use client' .tsx) are not callable as plain functions from imperative .ts modules — render them as JSX from a 'use client' parent instead. If the flagged name is a local shadow rather than the stripped import, please file an issue.",
157
165
 
166
+ [ErrorCodes.INLINED_IMPORT_MISSING_EXPORT]:
167
+ 'An inlined relative import requests a name the target module does not export. The client bundle would throw ReferenceError at load.',
168
+
158
169
  [ErrorCodes.STAGE_REACTIVE_IN_TEMPLATE]:
159
170
  'Reactive binding (signal getter or memo) referenced from template scope. The template lambda runs at module scope without the reactive context, so the value cannot be evaluated at SSR. Wrap the JSX expression in /* @client */ to defer it to hydrate, or restructure so the template uses a prop or static value.',
160
171
 
package/src/index.ts CHANGED
@@ -320,6 +320,10 @@ export {
320
320
  // Errors
321
321
  export { ErrorCodes, createError, formatError, generateCodeFrame } from './errors.ts'
322
322
 
323
+ // Value-reference classifier (#2432) — shared "is this a real value use"
324
+ // door for import-emission sites and the CLI's stripped-reference scan.
325
+ export { isValueReferenceIdentifier, collectValueReferencedNames } from './value-references.ts'
326
+
323
327
  // Expression Parser
324
328
  export { parseExpression, tsNodeToParsedExpr, asCallbackMethodCall, CALLBACK_METHODS, sortComparatorFromArrow, serializeParsedExpr, freeVarsInBody, freeIdentifiers, materializeGetterCalls, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, parseBlockBodyTolerant, foldBlockToExpr, predicateTernaryToLogical, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, hasUnsafeStyleValue, parseProviderObjectLiteral, type ProviderObjectMember, type FoldBlockOptions } from './expression-parser.ts'
325
329
  export type { StyleObjectEntry } from './expression-parser.ts'
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { ComponentIR, IRNode } from '../types.ts'
6
6
  import { isClientBuiltinName } from '../builtins.ts'
7
+ import { collectValueReferencedNames } from '../value-references.ts'
7
8
 
8
9
  // All exports from @barefootjs/client/runtime that may be used in generated code
9
10
  export const RUNTIME_IMPORT_CANDIDATES = [
@@ -80,7 +81,10 @@ export function collectUserDomImports(ir: ComponentIR): string[] {
80
81
  for (const imp of ir.metadata.imports) {
81
82
  if (runtimeSources.has(imp.source) && !imp.isTypeOnly) {
82
83
  for (const spec of imp.specifiers) {
83
- if (!spec.isDefault && !spec.isNamespace) {
84
+ // Per-specifier type-only (`import { createSignal, type Signal }
85
+ // from '@barefootjs/client'`) must not emit `Signal` from the
86
+ // runtime subpath, which does not export it (#2432).
87
+ if (!spec.isDefault && !spec.isNamespace && !spec.isTypeOnly) {
84
88
  // Compile-away built-ins (`<Async>` / `<Region>`) are lowered into
85
89
  // the template — never emit their import into the client bundle,
86
90
  // where it would be a phantom runtime import (#1915).
@@ -93,6 +97,42 @@ export function collectUserDomImports(ir: ComponentIR): string[] {
93
97
  return userImports
94
98
  }
95
99
 
100
+ /**
101
+ * Build the "is this local name used as a value in the generated code?"
102
+ * test used to decide which imported specifiers survive into the client
103
+ * bundle. Prefers a real value-reference set over the historical
104
+ * `\bname\b` text scan (#2432: an object key or string literal that
105
+ * merely spells an imported name used to emit a phantom import). Falls
106
+ * back to a substring scan when the generated text cannot be parsed
107
+ * cleanly — a partial parse would under-report references and DROP a
108
+ * needed import. The reference set is computed at most once per call.
109
+ *
110
+ * The fallback is a plain `includes()`, not a `\bname\b` regex: `\b` is
111
+ * defined over `[A-Za-z0-9_]`, so a `$`-prefixed name (`$fetch`, as
112
+ * exported by `ofetch`) or a non-ASCII local both sit outside a word
113
+ * boundary and would never match — silently dropping the import, the one
114
+ * failure direction this helper must never take. Worse, splicing
115
+ * `localName` straight into `new RegExp(...)` treated `$` as the
116
+ * end-of-input anchor, so `\b$fetch\b` couldn't match `$fetch` at all.
117
+ * `includes()` is deliberately COARSER than a word-boundary scan (it
118
+ * matches `helper` inside `helperFoo` too) — that's fine here: the
119
+ * fallback's only job is "never under-report", and over-keeping an
120
+ * import whose binding already exists is harmless, while dropping one is
121
+ * fatal.
122
+ */
123
+ export function makeValueUsageTest(generatedCode: string): (localName: string) => boolean {
124
+ let referenced: Set<string> | null | undefined
125
+ return (localName: string) => {
126
+ if (referenced === undefined) {
127
+ referenced = collectValueReferencedNames(generatedCode)
128
+ }
129
+ if (referenced !== null) {
130
+ return referenced.has(localName)
131
+ }
132
+ return generatedCode.includes(localName)
133
+ }
134
+ }
135
+
96
136
  /**
97
137
  * Collect external (non-DOM, non-component) imports that are used in generated code.
98
138
  * These are third-party libraries like @barefootjs/form, zod, etc. that need to be
@@ -101,6 +141,7 @@ export function collectUserDomImports(ir: ComponentIR): string[] {
101
141
  export function collectExternalImports(ir: ComponentIR, generatedCode: string, localImportPrefixes?: string[]): string[] {
102
142
  const componentNames = collectComponentNames(ir.root)
103
143
  const importLines: string[] = []
144
+ const isUsedAsValue = makeValueUsageTest(generatedCode)
104
145
  for (const imp of ir.metadata.imports) {
105
146
  if (imp.isTypeOnly) continue
106
147
  if (imp.source === '@barefootjs/client' || imp.source === RUNTIME_MODULE) continue
@@ -117,9 +158,11 @@ export function collectExternalImports(ir: ComponentIR, generatedCode: string, l
117
158
  // Skip component names — they are rendered via initChild(), not imported directly.
118
159
  const usedSpecs: string[] = []
119
160
  for (const spec of imp.specifiers) {
161
+ // Per-specifier `import { type Foo }` has no value binding — #2432.
162
+ if (spec.isTypeOnly) continue
120
163
  const localName = spec.alias || spec.name
121
164
  if (componentNames.has(localName)) continue
122
- if (new RegExp(`\\b${localName}\\b`).test(generatedCode)) {
165
+ if (isUsedAsValue(localName)) {
123
166
  usedSpecs.push(spec.alias ? `${spec.name} as ${spec.alias}` : spec.name)
124
167
  }
125
168
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Single door for "is this identifier a VALUE reference in emitted JS".
3
+ *
4
+ * Replaces `\bname\b` text scans at the import-emission sites (#2432): a
5
+ * regex scan can't tell a genuine value reference (`paperColor({ ... })`)
6
+ * from an object key or string literal that merely spells an imported
7
+ * name (`{ Theme: 'テーマ' }`). That false match used to make
8
+ * `collectExternalImports` re-emit a per-specifier type-only import
9
+ * (`import { paperColor, type Theme } from '../lib/theme'`) as a VALUE
10
+ * import, which the CLI's relative-import inliner then placed in the IIFE's
11
+ * `return { … }` with no binding — `ReferenceError: Theme is not defined`
12
+ * at load, killing the whole page's client JS.
13
+ *
14
+ * `packages/cli`'s `detectStrippedReferences` (in `resolve-imports.ts`)
15
+ * shares the same classifier for its own dangling-reference scan, so the
16
+ * two "is this a real use" checks in the pipeline can never drift apart.
17
+ */
18
+
19
+ import ts from 'typescript'
20
+
21
+ /**
22
+ * Identifier-position classifier: returns `true` when `id` is being USED
23
+ * as a value, `false` when it's a declaration name, property key, member-
24
+ * access name, or other non-reference slot.
25
+ *
26
+ * A ShorthandPropertyAssignment (`{ Theme }`) intentionally counts as a
27
+ * reference — it reads the binding, it doesn't just spell its name.
28
+ *
29
+ * Caveat: this is a syntactic test, not a scope analysis. If a local
30
+ * function parameter happens to share a name with an imported binding,
31
+ * references inside that function's body will count as references to
32
+ * the import (false positive). Acceptable: over-counting a reference
33
+ * just means we keep an import we didn't strictly need, which is a
34
+ * strict improvement over the alternative failure direction (dropping a
35
+ * needed import and producing a `ReferenceError`).
36
+ */
37
+ export function isValueReferenceIdentifier(id: ts.Identifier): boolean {
38
+ const parent = id.parent
39
+ if (!parent) return false
40
+ if (ts.isPropertyAccessExpression(parent) && parent.name === id) return false
41
+ if (ts.isPropertyAssignment(parent) && parent.name === id) return false
42
+ if (
43
+ (ts.isMethodDeclaration(parent) ||
44
+ ts.isGetAccessorDeclaration(parent) ||
45
+ ts.isSetAccessorDeclaration(parent)) &&
46
+ parent.name === id
47
+ ) {
48
+ return false
49
+ }
50
+ if (ts.isVariableDeclaration(parent) && parent.name === id) return false
51
+ if (ts.isFunctionDeclaration(parent) && parent.name === id) return false
52
+ if (ts.isFunctionExpression(parent) && parent.name === id) return false
53
+ if (ts.isClassDeclaration(parent) && parent.name === id) return false
54
+ if (ts.isClassExpression(parent) && parent.name === id) return false
55
+ if (ts.isParameter(parent) && parent.name === id) return false
56
+ if (ts.isBindingElement(parent) && (parent.name === id || parent.propertyName === id)) return false
57
+ if (ts.isLabeledStatement(parent) && parent.label === id) return false
58
+ if (ts.isBreakOrContinueStatement(parent) && parent.label === id) return false
59
+ // ImportSpecifier (`{ X }` or `{ X as Y }`) and ExportSpecifier have
60
+ // only `name`/`propertyName` as Identifier children — written as an
61
+ // explicit slot check for stylistic consistency with the other
62
+ // branches above.
63
+ if (ts.isImportSpecifier(parent) && (parent.name === id || parent.propertyName === id)) return false
64
+ if (ts.isExportSpecifier(parent) && (parent.name === id || parent.propertyName === id)) return false
65
+ if (ts.isImportClause(parent) && parent.name === id) return false
66
+ if (ts.isNamespaceImport(parent) && parent.name === id) return false
67
+ if (ts.isQualifiedName(parent) && parent.right === id) return false
68
+ return true
69
+ }
70
+
71
+ /**
72
+ * Parse `code` and collect the text of every identifier that is a VALUE
73
+ * reference per `isValueReferenceIdentifier`.
74
+ *
75
+ * Returns `null` when the text did not parse cleanly. `null` means
76
+ * "cannot answer" — callers MUST fall back to their previous (regex-scan)
77
+ * behaviour rather than treating it as an empty set. Narrowing on a
78
+ * partial parse would DROP a needed import, which is the failure
79
+ * direction we must never take (a phantom missing-import build failure
80
+ * is recoverable; a silently dead client bundle is not).
81
+ */
82
+ export function collectValueReferencedNames(code: string): Set<string> | null {
83
+ let sourceFile: ts.SourceFile
84
+ try {
85
+ sourceFile = ts.createSourceFile(
86
+ 'generated.js',
87
+ code,
88
+ ts.ScriptTarget.Latest,
89
+ /*setParentNodes*/ true,
90
+ ts.ScriptKind.JS,
91
+ )
92
+ } catch {
93
+ return null
94
+ }
95
+
96
+ const diagnostics = (sourceFile as unknown as { parseDiagnostics?: readonly unknown[] }).parseDiagnostics
97
+ if (diagnostics && diagnostics.length > 0) return null
98
+
99
+ const names = new Set<string>()
100
+ function visit(node: ts.Node): void {
101
+ if (ts.isIdentifier(node) && isValueReferenceIdentifier(node)) {
102
+ names.add(node.text)
103
+ }
104
+ ts.forEachChild(node, visit)
105
+ }
106
+ visit(sourceFile)
107
+ return names
108
+ }