@barefootjs/jsx 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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'
@@ -119,6 +119,10 @@ export function stringifyCompositeLoop(lines: string[], plan: CompositeLoopPlan)
119
119
  bodyIsMultiRoot,
120
120
  indent: bodyIndent,
121
121
  singleRootLayout: 'multiline',
122
+ // Composite is exactly the variant that initialises something inside the
123
+ // row — nested components, inner loops, or both — so it is exactly the
124
+ // variant whose tail needs the row already connected.
125
+ mountRow: true,
122
126
  })
123
127
  emitComponentAndEventSetup(lines, bodyIndent, '__el', compsArr, eventsArr, loopParam, loopParamBindings, bodyIsMultiRoot)
124
128
  if (innerLoops.length > 0) {
@@ -251,9 +251,23 @@ export function emitLoopItemElementSetup(
251
251
  indent: string
252
252
  /** Single-root layout: 'inline' (plain / branch-plain) or 'multiline' (composite). */
253
253
  singleRootLayout: 'inline' | 'multiline'
254
+ /**
255
+ * Emit `mountRowRoot(__el)` on the FRESH branch, connecting the row at the
256
+ * mount point `mapArray` handed down before the body's tail runs.
257
+ *
258
+ * Only bodies that initialise something inside the row need it — the tail
259
+ * is where `useContext` would otherwise resolve against a detached element
260
+ * and fall through to the global last-writer-wins store. A row with no
261
+ * nested init has nothing to resolve, so plain loops leave this off and
262
+ * their emission (and the `mapArrayLazy` measurements) are untouched.
263
+ *
264
+ * Never on the hydration branch: that row came from SSR markup and is in
265
+ * the document already.
266
+ */
267
+ mountRow?: boolean
254
268
  },
255
269
  ): void {
256
- const { template, bodyIsMultiRoot, indent, singleRootLayout } = opts
270
+ const { template, bodyIsMultiRoot, indent, singleRootLayout, mountRow } = opts
257
271
  const innerIndent = indent + ' '
258
272
  if (bodyIsMultiRoot) {
259
273
  lines.push(`${indent}let __el, __extras`)
@@ -264,17 +278,22 @@ export function emitLoopItemElementSetup(
264
278
  lines.push(ln)
265
279
  }
266
280
  lines.push(`${innerIndent}__el.__bfExtras = __extras`)
281
+ // After the stash: `mountRowRoot` attaches the primary, and an attached
282
+ // primary makes `itemRootElements`' sibling walk the first thing a lookup
283
+ // sees — it must find the stash already in place behind it.
284
+ if (mountRow) lines.push(`${innerIndent}mountRowRoot(__el)`)
267
285
  lines.push(`${indent}}`)
268
286
  return
269
287
  }
270
288
  if (singleRootLayout === 'inline') {
271
289
  const cloneExpr = emitTemplateCloneInline(template)
272
- lines.push(`${indent}const __el = __existing ?? (() => { ${cloneExpr} })()`)
290
+ const clone = `__existing ?? (() => { ${cloneExpr} })()`
291
+ lines.push(`${indent}const __el = ${mountRow ? `__existing ?? mountRowRoot((() => { ${cloneExpr} })())` : clone}`)
273
292
  return
274
293
  }
275
- lines.push(`${indent}const __el = __existing ?? (() => {`)
294
+ lines.push(`${indent}const __el = __existing ?? ${mountRow ? 'mountRowRoot(' : ''}(() => {`)
276
295
  for (const ln of emitTemplateCloneLines(template, innerIndent)) lines.push(ln)
277
- lines.push(`${indent}})()`)
296
+ lines.push(`${indent}})()${mountRow ? ')' : ''}`)
278
297
  }
279
298
 
280
299
  /**
@@ -4,12 +4,18 @@
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 = [
10
11
  'createSignal', 'createMemo', 'createEffect', 'onCleanup', 'onMount',
11
12
  'hydrate', 'insert', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'mapArrayLazy', 'patchLeaf', 'createDisposableEffect',
12
13
  'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild',
14
+ // Connects a template-clone loop row before the body's tail runs, so a child
15
+ // that inits inside it resolves context against real ancestors rather than
16
+ // falling through to the global store. The clone-root counterpart of the
17
+ // mount point `createComponent` consumes for component-root rows.
18
+ 'mountRowRoot',
13
19
  'createPortal',
14
20
  'provideContext', 'createContext', 'useContext',
15
21
  'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText', 'escapeTextOrNode',
@@ -80,7 +86,10 @@ export function collectUserDomImports(ir: ComponentIR): string[] {
80
86
  for (const imp of ir.metadata.imports) {
81
87
  if (runtimeSources.has(imp.source) && !imp.isTypeOnly) {
82
88
  for (const spec of imp.specifiers) {
83
- if (!spec.isDefault && !spec.isNamespace) {
89
+ // Per-specifier type-only (`import { createSignal, type Signal }
90
+ // from '@barefootjs/client'`) must not emit `Signal` from the
91
+ // runtime subpath, which does not export it (#2432).
92
+ if (!spec.isDefault && !spec.isNamespace && !spec.isTypeOnly) {
84
93
  // Compile-away built-ins (`<Async>` / `<Region>`) are lowered into
85
94
  // the template — never emit their import into the client bundle,
86
95
  // where it would be a phantom runtime import (#1915).
@@ -93,6 +102,42 @@ export function collectUserDomImports(ir: ComponentIR): string[] {
93
102
  return userImports
94
103
  }
95
104
 
105
+ /**
106
+ * Build the "is this local name used as a value in the generated code?"
107
+ * test used to decide which imported specifiers survive into the client
108
+ * bundle. Prefers a real value-reference set over the historical
109
+ * `\bname\b` text scan (#2432: an object key or string literal that
110
+ * merely spells an imported name used to emit a phantom import). Falls
111
+ * back to a substring scan when the generated text cannot be parsed
112
+ * cleanly — a partial parse would under-report references and DROP a
113
+ * needed import. The reference set is computed at most once per call.
114
+ *
115
+ * The fallback is a plain `includes()`, not a `\bname\b` regex: `\b` is
116
+ * defined over `[A-Za-z0-9_]`, so a `$`-prefixed name (`$fetch`, as
117
+ * exported by `ofetch`) or a non-ASCII local both sit outside a word
118
+ * boundary and would never match — silently dropping the import, the one
119
+ * failure direction this helper must never take. Worse, splicing
120
+ * `localName` straight into `new RegExp(...)` treated `$` as the
121
+ * end-of-input anchor, so `\b$fetch\b` couldn't match `$fetch` at all.
122
+ * `includes()` is deliberately COARSER than a word-boundary scan (it
123
+ * matches `helper` inside `helperFoo` too) — that's fine here: the
124
+ * fallback's only job is "never under-report", and over-keeping an
125
+ * import whose binding already exists is harmless, while dropping one is
126
+ * fatal.
127
+ */
128
+ export function makeValueUsageTest(generatedCode: string): (localName: string) => boolean {
129
+ let referenced: Set<string> | null | undefined
130
+ return (localName: string) => {
131
+ if (referenced === undefined) {
132
+ referenced = collectValueReferencedNames(generatedCode)
133
+ }
134
+ if (referenced !== null) {
135
+ return referenced.has(localName)
136
+ }
137
+ return generatedCode.includes(localName)
138
+ }
139
+ }
140
+
96
141
  /**
97
142
  * Collect external (non-DOM, non-component) imports that are used in generated code.
98
143
  * These are third-party libraries like @barefootjs/form, zod, etc. that need to be
@@ -101,6 +146,7 @@ export function collectUserDomImports(ir: ComponentIR): string[] {
101
146
  export function collectExternalImports(ir: ComponentIR, generatedCode: string, localImportPrefixes?: string[]): string[] {
102
147
  const componentNames = collectComponentNames(ir.root)
103
148
  const importLines: string[] = []
149
+ const isUsedAsValue = makeValueUsageTest(generatedCode)
104
150
  for (const imp of ir.metadata.imports) {
105
151
  if (imp.isTypeOnly) continue
106
152
  if (imp.source === '@barefootjs/client' || imp.source === RUNTIME_MODULE) continue
@@ -117,9 +163,11 @@ export function collectExternalImports(ir: ComponentIR, generatedCode: string, l
117
163
  // Skip component names — they are rendered via initChild(), not imported directly.
118
164
  const usedSpecs: string[] = []
119
165
  for (const spec of imp.specifiers) {
166
+ // Per-specifier `import { type Foo }` has no value binding — #2432.
167
+ if (spec.isTypeOnly) continue
120
168
  const localName = spec.alias || spec.name
121
169
  if (componentNames.has(localName)) continue
122
- if (new RegExp(`\\b${localName}\\b`).test(generatedCode)) {
170
+ if (isUsedAsValue(localName)) {
123
171
  usedSpecs.push(spec.alias ? `${spec.name} as ${spec.alias}` : spec.name)
124
172
  }
125
173
  }
@@ -0,0 +1,130 @@
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
+ * CONTRACT: this classifies identifier positions in **JavaScript** source.
30
+ * Both current callers parse with `ts.ScriptKind.JS` —
31
+ * `collectValueReferencedNames` below, and `detectStrippedReferences` in
32
+ * `packages/cli/src/lib/resolve-imports.ts`, which parses the assembled
33
+ * bundle. TypeScript-only positions are deliberately NOT handled: an
34
+ * identifier in a type position (`const x: Foo = …`, a
35
+ * `TypeReferenceNode`) is still reported as a value reference, and so are
36
+ * `interface` / `type` / `enum` declaration names. Do not point this at
37
+ * TypeScript source — it would over-report there.
38
+ *
39
+ * Caveat: this is a syntactic test, not a scope analysis. If a local
40
+ * function parameter happens to share a name with an imported binding,
41
+ * references inside that function's body will count as references to
42
+ * the import (false positive). For the import-emission caller
43
+ * (`makeValueUsageTest` in `ir-to-client-js/imports.ts`), over-counting is
44
+ * the safe direction — an extra import whose binding exists is harmless.
45
+ * It is NOT safe for the BF053 caller (`detectStrippedReferences`): there,
46
+ * an over-reported reference to a stripped binding is a false build
47
+ * error on legal code. That asymmetry is why every exclusion branch below
48
+ * matters — each one is a case that would otherwise misfire BF053.
49
+ */
50
+ export function isValueReferenceIdentifier(id: ts.Identifier): boolean {
51
+ const parent = id.parent
52
+ if (!parent) return false
53
+ if (ts.isPropertyAccessExpression(parent) && parent.name === id) return false
54
+ if (ts.isPropertyAssignment(parent) && parent.name === id) return false
55
+ if (
56
+ (ts.isMethodDeclaration(parent) ||
57
+ ts.isGetAccessorDeclaration(parent) ||
58
+ ts.isSetAccessorDeclaration(parent)) &&
59
+ parent.name === id
60
+ ) {
61
+ return false
62
+ }
63
+ // Class field name (`class C { helper = 1 }`, including `static helper
64
+ // = 1`): the name is a member key, not a read. The `parent.name === id`
65
+ // guard is what preserves the computed case — in `class C { [helper] =
66
+ // 1 }` the name is a ComputedPropertyName, not `id` itself, so `helper`
67
+ // (inside the brackets) still falls through and counts as a reference.
68
+ if (ts.isPropertyDeclaration(parent) && parent.name === id) return false
69
+ // `new.target` / `import.meta`: `target`/`meta` sits in keyword
70
+ // position, not a binding.
71
+ if (ts.isMetaProperty(parent) && parent.name === id) return false
72
+ if (ts.isVariableDeclaration(parent) && parent.name === id) return false
73
+ if (ts.isFunctionDeclaration(parent) && parent.name === id) return false
74
+ if (ts.isFunctionExpression(parent) && parent.name === id) return false
75
+ if (ts.isClassDeclaration(parent) && parent.name === id) return false
76
+ if (ts.isClassExpression(parent) && parent.name === id) return false
77
+ if (ts.isParameter(parent) && parent.name === id) return false
78
+ if (ts.isBindingElement(parent) && (parent.name === id || parent.propertyName === id)) return false
79
+ if (ts.isLabeledStatement(parent) && parent.label === id) return false
80
+ if (ts.isBreakOrContinueStatement(parent) && parent.label === id) return false
81
+ // ImportSpecifier (`{ X }` or `{ X as Y }`) and ExportSpecifier have
82
+ // only `name`/`propertyName` as Identifier children — written as an
83
+ // explicit slot check for stylistic consistency with the other
84
+ // branches above.
85
+ if (ts.isImportSpecifier(parent) && (parent.name === id || parent.propertyName === id)) return false
86
+ if (ts.isExportSpecifier(parent) && (parent.name === id || parent.propertyName === id)) return false
87
+ if (ts.isImportClause(parent) && parent.name === id) return false
88
+ if (ts.isNamespaceImport(parent) && parent.name === id) return false
89
+ if (ts.isQualifiedName(parent) && parent.right === id) return false
90
+ return true
91
+ }
92
+
93
+ /**
94
+ * Parse `code` and collect the text of every identifier that is a VALUE
95
+ * reference per `isValueReferenceIdentifier`.
96
+ *
97
+ * Returns `null` when the text did not parse cleanly. `null` means
98
+ * "cannot answer" — callers MUST fall back to their previous (regex-scan)
99
+ * behaviour rather than treating it as an empty set. Narrowing on a
100
+ * partial parse would DROP a needed import, which is the failure
101
+ * direction we must never take (a phantom missing-import build failure
102
+ * is recoverable; a silently dead client bundle is not).
103
+ */
104
+ export function collectValueReferencedNames(code: string): Set<string> | null {
105
+ let sourceFile: ts.SourceFile
106
+ try {
107
+ sourceFile = ts.createSourceFile(
108
+ 'generated.js',
109
+ code,
110
+ ts.ScriptTarget.Latest,
111
+ /*setParentNodes*/ true,
112
+ ts.ScriptKind.JS,
113
+ )
114
+ } catch {
115
+ return null
116
+ }
117
+
118
+ const diagnostics = (sourceFile as unknown as { parseDiagnostics?: readonly unknown[] }).parseDiagnostics
119
+ if (diagnostics && diagnostics.length > 0) return null
120
+
121
+ const names = new Set<string>()
122
+ function visit(node: ts.Node): void {
123
+ if (ts.isIdentifier(node) && isValueReferenceIdentifier(node)) {
124
+ names.add(node.text)
125
+ }
126
+ ts.forEachChild(node, visit)
127
+ }
128
+ visit(sourceFile)
129
+ return names
130
+ }