@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.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/compiler.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +335 -247
- package/dist/ir-to-client-js/imports.d.ts +24 -0
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/value-references.d.ts +48 -0
- package/dist/value-references.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/ir-to-client-js/imports.test.ts +98 -1
- package/src/__tests__/type-only-import-leak.test.ts +60 -0
- package/src/analyzer.ts +3 -0
- package/src/compiler.ts +6 -2
- package/src/errors.ts +11 -0
- package/src/index.ts +4 -0
- package/src/ir-to-client-js/imports.ts +45 -2
- package/src/value-references.ts +108 -0
|
@@ -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;
|
|
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.
|
|
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.
|
|
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 &&
|
|
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
|
-
|
|
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 (
|
|
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
|
+
}
|