@barefootjs/jsx 0.14.0 → 0.15.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.
Files changed (42) hide show
  1. package/dist/adapters/env-signal.d.ts +40 -0
  2. package/dist/adapters/env-signal.d.ts.map +1 -0
  3. package/dist/adapters/parsed-expr-emitter.d.ts +2 -1
  4. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/augment-inherited-props.d.ts +42 -1
  7. package/dist/augment-inherited-props.d.ts.map +1 -1
  8. package/dist/builtins.d.ts +33 -0
  9. package/dist/builtins.d.ts.map +1 -0
  10. package/dist/compiler.d.ts.map +1 -1
  11. package/dist/errors.d.ts +1 -0
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/expression-parser.d.ts +48 -1
  14. package/dist/expression-parser.d.ts.map +1 -1
  15. package/dist/index.d.ts +8 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +392 -26
  18. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  19. package/dist/jsx-to-ir.d.ts.map +1 -1
  20. package/dist/ssr-defaults.d.ts.map +1 -1
  21. package/dist/types.d.ts +16 -0
  22. package/dist/types.d.ts.map +1 -1
  23. package/package.json +2 -2
  24. package/src/__tests__/compiler-stress-1244.test.ts +4 -2
  25. package/src/__tests__/expression-parser.test.ts +92 -1
  26. package/src/__tests__/ir-async.test.ts +8 -0
  27. package/src/__tests__/ir-builtin-import-scope.test.ts +188 -0
  28. package/src/__tests__/ir-region.test.ts +86 -0
  29. package/src/__tests__/ssr-defaults.test.ts +25 -0
  30. package/src/adapters/env-signal.ts +75 -0
  31. package/src/adapters/parsed-expr-emitter.ts +11 -0
  32. package/src/analyzer.ts +9 -0
  33. package/src/augment-inherited-props.ts +170 -2
  34. package/src/builtins.ts +63 -0
  35. package/src/compiler.ts +6 -2
  36. package/src/errors.ts +10 -0
  37. package/src/expression-parser.ts +156 -2
  38. package/src/index.ts +8 -2
  39. package/src/ir-to-client-js/imports.ts +5 -0
  40. package/src/jsx-to-ir.ts +189 -8
  41. package/src/ssr-defaults.ts +55 -17
  42. package/src/types.ts +16 -0
@@ -0,0 +1,75 @@
1
+ // Shared recognition of the router v0.5 `searchParams()` environment signal
2
+ // for the template-string adapters (Mojo / Xslate today; Go has its own
3
+ // struct-field path). A request-scoped reactive env signal reads like an
4
+ // ordinary signal getter, but its value is a URLSearchParams-like reader with
5
+ // real methods (`.get(key)`) — not a hashref. The generic `member` lowering
6
+ // would deref `.get` as a property access and drop the call + argument, so the
7
+ // adapters special-case it to a real per-request reader method call. See #1922.
8
+
9
+ import type { ParsedExpr } from '../expression-parser.ts'
10
+ import type { IRMetadata } from '../types.ts'
11
+
12
+ /**
13
+ * The local binding name(s) that `searchParams` from `@barefootjs/client` is
14
+ * imported under in this component — the same import the analyzer allow-lists
15
+ * (`CLIENT_EXPORTS`). Usually the single name `searchParams`, but an aliased
16
+ * import (`import { searchParams as sp }`) binds it to `sp`, and the template
17
+ * expression then reads `sp()` — so adapters must gate + match against the
18
+ * LOCAL name(s), not the literal `searchParams`. Empty when the env signal is
19
+ * not imported (the component keeps the generic signal lowering).
20
+ *
21
+ * `ImportSpecifier.name` is the exported name and `alias` the local rebinding
22
+ * (see `collectImport` in analyzer.ts), so the import is detected by `name ===
23
+ * 'searchParams'` and the local binding is `alias ?? name`. Namespace / default
24
+ * specifiers bind a different identifier and are excluded.
25
+ */
26
+ export function searchParamsLocalNames(metadata: IRMetadata): Set<string> {
27
+ const names = new Set<string>()
28
+ for (const imp of metadata.imports) {
29
+ if (imp.source !== '@barefootjs/client' || imp.isTypeOnly) continue
30
+ for (const s of imp.specifiers) {
31
+ if (s.isTypeOnly || s.isNamespace || s.isDefault) continue
32
+ if (s.name === 'searchParams') names.add(s.alias ?? s.name)
33
+ }
34
+ }
35
+ return names
36
+ }
37
+
38
+ /**
39
+ * True when the component imports the `searchParams` env signal under any local
40
+ * name. Convenience for adapters/harnesses that only need to gate on presence
41
+ * (the lowering itself needs the {@link searchParamsLocalNames} set to match the
42
+ * actual binding in the expression).
43
+ */
44
+ export function importsSearchParams(metadata: IRMetadata): boolean {
45
+ return searchParamsLocalNames(metadata).size > 0
46
+ }
47
+
48
+ /**
49
+ * Recognise a `<binding>().<method>(<args>)` env-signal method call from a
50
+ * `call` node's callee + args, where `<binding>` is one of the local names
51
+ * `searchParams` was imported under (`localNames`, from
52
+ * {@link searchParamsLocalNames}). Returns the method name and argument nodes
53
+ * when the receiver is the zero-arg env-signal getter, else null. The caller
54
+ * lowers the match to a real method call on its per-request reader object
55
+ * (`$searchParams->get('sort')` in Mojo, `$searchParams.get('sort')` in
56
+ * Xslate — the canonical `$searchParams` var regardless of the JS alias);
57
+ * without it the generic `member` lowering drops the call + arg.
58
+ */
59
+ export function matchSearchParamsMethodCall(
60
+ callee: ParsedExpr,
61
+ args: ParsedExpr[],
62
+ localNames: ReadonlySet<string>,
63
+ ): { method: string; args: ParsedExpr[] } | null {
64
+ if (callee.kind !== 'member' || callee.computed) return null
65
+ const recv = callee.object
66
+ if (
67
+ recv.kind !== 'call' ||
68
+ recv.args.length !== 0 ||
69
+ recv.callee.kind !== 'identifier' ||
70
+ !localNames.has(recv.callee.name)
71
+ ) {
72
+ return null
73
+ }
74
+ return { method: callee.property, args }
75
+ }
@@ -66,6 +66,7 @@ export type ArrayMethod =
66
66
  | 'toLowerCase'
67
67
  | 'toUpperCase'
68
68
  | 'trim'
69
+ | 'toFixed'
69
70
  | 'split'
70
71
  | 'startsWith'
71
72
  | 'endsWith'
@@ -115,6 +116,14 @@ export interface ParsedExprEmitter {
115
116
  computed: boolean,
116
117
  emit: (e: ParsedExpr) => string,
117
118
  ): string
119
+ // Element access with a non-literal index (`arr[index]`). The index
120
+ // is a full `ParsedExpr` (loop variable, arithmetic, etc.); the
121
+ // adapter picks array vs hash deref per target language. #1897.
122
+ indexAccess(
123
+ object: ParsedExpr,
124
+ index: ParsedExpr,
125
+ emit: (e: ParsedExpr) => string,
126
+ ): string
118
127
  binary(op: string, left: ParsedExpr, right: ParsedExpr, emit: (e: ParsedExpr) => string): string
119
128
  unary(op: string, argument: ParsedExpr, emit: (e: ParsedExpr) => string): string
120
129
  logical(
@@ -196,6 +205,8 @@ export function emitParsedExpr(expr: ParsedExpr, emitter: ParsedExprEmitter): st
196
205
  return emitter.call(expr.callee, expr.args, emit)
197
206
  case 'member':
198
207
  return emitter.member(expr.object, expr.property, expr.computed, emit)
208
+ case 'index-access':
209
+ return emitter.indexAccess(expr.object, expr.index, emit)
199
210
  case 'binary':
200
211
  return emitter.binary(expr.op, expr.left, expr.right, emit)
201
212
  case 'unary':
package/src/analyzer.ts CHANGED
@@ -1648,6 +1648,13 @@ const CLIENT_EXPORTS = new Set([
1648
1648
  'forwardProps', 'unwrap', '__slot',
1649
1649
  'createContext', 'useContext', 'provideContext',
1650
1650
  'createPortal', 'isSSRPortal', 'findSiblingSlot', 'cleanupPortalPlaceholder',
1651
+ // Request-scoped environment signal (router v0.5) — a real user-facing
1652
+ // reactive export the compiler lowers like any other `@barefootjs/client`
1653
+ // signal read (SSR: a template binding; client: a `createEffect`).
1654
+ 'searchParams',
1655
+ // Compile-away JSX built-ins (#1915) — importing them is what scopes the
1656
+ // compiler's `<Async>` / `<Region>` recognition; the import is elided on emit.
1657
+ 'Async', 'Region',
1651
1658
  ])
1652
1659
 
1653
1660
  /**
@@ -1748,6 +1755,8 @@ function collectImport(node: ts.ImportDeclaration, ctx: AnalyzerContext): void {
1748
1755
  alias: element.propertyName ? element.name.text : null,
1749
1756
  isDefault: false,
1750
1757
  isNamespace: false,
1758
+ // Per-specifier `import { type Foo }` — no value binding (#1915).
1759
+ isTypeOnly: element.isTypeOnly,
1751
1760
  })
1752
1761
  }
1753
1762
  }
@@ -164,7 +164,31 @@ export function augmentInheritedPropAccesses(ir: ComponentIR): void {
164
164
  if (!node) return
165
165
  const el = node as unknown as IRElement
166
166
  for (const attr of el.attrs ?? []) {
167
- const v = attr.value as { kind?: string; expr?: string; presenceOrUndefined?: boolean }
167
+ const v = attr.value as {
168
+ kind?: string
169
+ expr?: string
170
+ presenceOrUndefined?: boolean
171
+ parts?: Array<
172
+ | { type: 'string'; value: string }
173
+ | { type: 'ternary'; condition: string; whenTrue: string; whenFalse: string }
174
+ | { type: 'lookup'; key: string }
175
+ >
176
+ }
177
+ // Template-literal attr values (`className={\`… \${props.className ??
178
+ // ''}\`}`) carry their `props.X` reads inside the parts structure,
179
+ // not a flat expr string (#1896 — TabsContent's template referenced
180
+ // `.ClassName` while the Props struct never declared it). Scan every
181
+ // textual field of every part shape.
182
+ if (v?.parts) {
183
+ for (const part of v.parts) {
184
+ if (part.type === 'string') scan(part.value)
185
+ else if (part.type === 'ternary') {
186
+ scan(part.condition)
187
+ scan(part.whenTrue)
188
+ scan(part.whenFalse)
189
+ } else if (part.type === 'lookup') scan(part.key)
190
+ }
191
+ }
168
192
  if (v?.kind === 'expression' && typeof v.expr === 'string') {
169
193
  scan(v.expr)
170
194
  const expr = v.expr.trim()
@@ -186,6 +210,20 @@ export function augmentInheritedPropAccesses(ir: ComponentIR): void {
186
210
  const c = child as { element?: IRNode }
187
211
  walk((c.element ?? child) as IRNode)
188
212
  }
213
+ // Conditional / if-statement nodes keep their subtrees in branch
214
+ // fields, not `children` (#1896 — DialogTrigger's asChild
215
+ // if-statement hid the button branch's `id={props.id}` from this
216
+ // scan, so the template referenced `.ID` without a Props field).
217
+ const branchy = node as unknown as {
218
+ whenTrue?: IRNode
219
+ whenFalse?: IRNode
220
+ consequent?: IRNode
221
+ alternate?: IRNode
222
+ }
223
+ walk(branchy.whenTrue)
224
+ walk(branchy.whenFalse)
225
+ walk(branchy.consequent)
226
+ walk(branchy.alternate)
189
227
  }
190
228
  walk(ir.root)
191
229
 
@@ -206,6 +244,134 @@ export function augmentInheritedPropAccesses(ir: ComponentIR): void {
206
244
  }
207
245
  }
208
246
 
247
+ /**
248
+ * Parse a const initializer to its static string value: a string literal,
249
+ * a no-substitution template literal, or a `[<string literals>].join(sep)`
250
+ * call. Returns `null` for anything else. The TS parser resolves escapes
251
+ * and quoting exactly as JS would, matching the value the Hono reference
252
+ * inlines at runtime. Shared by the Go, Mojo, and Xslate adapters.
253
+ */
254
+ export function parseStaticStringConst(source: string): string | null {
255
+ const sf = ts.createSourceFile(
256
+ '__const.ts', `const __x = (${source});`, ts.ScriptTarget.Latest, /*setParentNodes*/ false,
257
+ )
258
+ const stmt = sf.statements[0]
259
+ if (!stmt || !ts.isVariableStatement(stmt)) return null
260
+ let init = stmt.declarationList.declarations[0]?.initializer
261
+ while (init && ts.isParenthesizedExpression(init)) init = init.expression
262
+ if (!init) return null
263
+ if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
264
+ return init.text
265
+ }
266
+ return evalStringArrayJoin(source)
267
+ }
268
+
269
+ /**
270
+ * Statically evaluate a template literal whose every interpolation is a
271
+ * bare identifier present in `resolved` to its flat string, else `null`.
272
+ * Companion to `parseStaticStringConst` for COMPOSED module consts
273
+ * (#1896 / #1897 — radio-group's
274
+ * `itemClasses = \`\${itemBaseClasses} \${itemFocusClasses} …\``).
275
+ */
276
+ export function evalTemplateOfStringConsts(
277
+ source: string,
278
+ resolved: ReadonlyMap<string, string>,
279
+ ): string | null {
280
+ const sf = ts.createSourceFile(
281
+ '__const.ts', `const __x = (${source});`, ts.ScriptTarget.Latest, /*setParentNodes*/ false,
282
+ )
283
+ const stmt = sf.statements[0]
284
+ if (!stmt || !ts.isVariableStatement(stmt)) return null
285
+ let init = stmt.declarationList.declarations[0]?.initializer
286
+ while (init && ts.isParenthesizedExpression(init)) init = init.expression
287
+ if (!init || !ts.isTemplateExpression(init)) return null
288
+ let out = init.head.text
289
+ for (const span of init.templateSpans) {
290
+ if (!ts.isIdentifier(span.expression)) return null
291
+ const value = resolved.get(span.expression.text)
292
+ if (value === undefined) return null
293
+ out += value + span.literal.text
294
+ }
295
+ return out
296
+ }
297
+
298
+ /**
299
+ * Build the module string-const map from the IR's localConstants —
300
+ * the SINGLE SOURCE OF TRUTH for all three SSR template adapters.
301
+ * A const qualifies when module-scope and statically resolvable as:
302
+ * - a pure string / no-substitution-template literal,
303
+ * - `[<string literals>].join(sep)`,
304
+ * - a template literal COMPOSED of other qualifying module consts
305
+ * (resolved to a fixed point, so composition order doesn't matter).
306
+ */
307
+ export function collectModuleStringConsts(
308
+ constants: IRMetadata['localConstants'] | undefined,
309
+ ): Map<string, string> {
310
+ const map = new Map<string, string>()
311
+ const candidates = (constants ?? []).filter(
312
+ c => c.isModule && c.value !== undefined,
313
+ )
314
+ let progressed = true
315
+ while (progressed) {
316
+ progressed = false
317
+ for (const c of candidates) {
318
+ if (map.has(c.name)) continue
319
+ const literal =
320
+ parseStaticStringConst(c.value!) ??
321
+ evalTemplateOfStringConsts(c.value!, map)
322
+ if (literal !== null) {
323
+ map.set(c.name, literal)
324
+ progressed = true
325
+ }
326
+ }
327
+ }
328
+ return map
329
+ }
330
+
331
+ /**
332
+ * Resolve `IDENT.key` / `IDENT['key']` where `IDENT` is a module-scope
333
+ * object-literal const and the key/value are static literals — a fully
334
+ * compile-time lookup (the icon registry's `strokePaths['chevron-down']`,
335
+ * pagination's `variantClasses.ghost`; #1896 / #1897). Returns the
336
+ * looked-up scalar, or `null` for any other shape so callers fall back
337
+ * to their generic lowering. Shared by all three SSR template adapters;
338
+ * the prop-KEYED variant of the pattern lives in `parseRecordIndexAccess`.
339
+ */
340
+ export function lookupStaticRecordLiteral(
341
+ objectName: string,
342
+ key: string,
343
+ constants: IRMetadata['localConstants'] | undefined,
344
+ ): { kind: 'string' | 'number'; text: string } | null {
345
+ const constInfo = (constants ?? []).find(c => c.name === objectName && c.isModule)
346
+ if (constInfo?.value === undefined) return null
347
+ const sf = ts.createSourceFile(
348
+ '__rec.ts', `(${constInfo.value})`, ts.ScriptTarget.Latest, /*setParentNodes*/ true,
349
+ )
350
+ if (sf.statements.length !== 1) return null
351
+ const stmt = sf.statements[0]
352
+ if (!ts.isExpressionStatement(stmt)) return null
353
+ let parsed: ts.Expression = stmt.expression
354
+ while (ts.isParenthesizedExpression(parsed)) parsed = parsed.expression
355
+ if (!ts.isObjectLiteralExpression(parsed)) return null
356
+ for (const prop of parsed.properties) {
357
+ if (!ts.isPropertyAssignment(prop)) continue
358
+ const name = prop.name
359
+ const propKey =
360
+ ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)
361
+ ? name.text
362
+ : null
363
+ if (propKey !== key) continue
364
+ let v: ts.Expression = prop.initializer
365
+ while (ts.isParenthesizedExpression(v)) v = v.expression
366
+ if (ts.isNumericLiteral(v)) return { kind: 'number', text: v.text }
367
+ if (ts.isStringLiteral(v) || ts.isNoSubstitutionTemplateLiteral(v)) {
368
+ return { kind: 'string', text: v.text }
369
+ }
370
+ return null
371
+ }
372
+ return null
373
+ }
374
+
209
375
  /**
210
376
  * Statically evaluate `[<string literals>].join(<sep?>)` (e.g. a module-scope
211
377
  * `const stateClasses = ['…', …].join(' ')`) to its joined string, so SSR
@@ -213,7 +379,9 @@ export function augmentInheritedPropAccesses(ir: ComponentIR): void {
213
379
  * instead of referencing a binding that doesn't exist server-side. Default
214
380
  * separator `,` matches JS `Array.prototype.join`. Returns `null` for any
215
381
  * other shape (non-`.join` call, non-array receiver, non-string-literal element
216
- * or separator). Shared by the Mojo + Xslate adapters; Go keeps a private copy.
382
+ * or separator). Shared by the Go, Mojo, and Xslate adapters (all three
383
+ * resolve module consts through `collectModuleStringConsts` above, which
384
+ * folds these joins during its fixed-point pass).
217
385
  */
218
386
  export function evalStringArrayJoin(source: string): string | null {
219
387
  const sf = ts.createSourceFile(
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Compiler built-in JSX tags that are import-scoped to `@barefootjs/client`
3
+ * and compiled away (no runtime value survives in emitted output).
4
+ *
5
+ * `<Async>` and `<Region>` are recognised **structurally** — by their
6
+ * `@barefootjs/client` import in `ir.metadata.imports`, never by a bare
7
+ * capitalized tag-name match — so a user's own `<Async>` / `<Region>`
8
+ * component (imported from elsewhere or declared locally) does not collide
9
+ * with the built-in. The import is elided on emit (both `templateImports`
10
+ * and the client-JS DOM imports) so it never lingers as a phantom runtime
11
+ * import.
12
+ *
13
+ * Runtime stubs + types ship from `@barefootjs/client` (see
14
+ * `packages/client/src/builtins.ts`). See piconic-ai/barefootjs#1915.
15
+ */
16
+
17
+ import type { ImportInfo } from './types.ts'
18
+
19
+ /** Package that the built-in tags must be imported from to be recognised. */
20
+ export const CLIENT_BUILTIN_SOURCE = '@barefootjs/client'
21
+
22
+ export type ClientBuiltinTag = 'Async' | 'Region'
23
+
24
+ /** The recognised built-in tag (export) names. */
25
+ export const CLIENT_BUILTIN_TAGS: readonly ClientBuiltinTag[] = ['Async', 'Region']
26
+
27
+ /** True when `name` is one of the compile-away built-in export names. */
28
+ export function isClientBuiltinName(name: string): name is ClientBuiltinTag {
29
+ return name === 'Async' || name === 'Region'
30
+ }
31
+
32
+ /**
33
+ * Elide the compile-away built-ins from an import list for emission (#1915).
34
+ * `<Async>` / `<Region>` are lowered into the template, so their
35
+ * `@barefootjs/client` import must not survive as a phantom runtime import in
36
+ * either the SSR template or the client JS bundle. Drops the `Async` / `Region`
37
+ * specifiers from `@barefootjs/client` imports, and drops the whole import
38
+ * statement when it has no remaining specifiers.
39
+ */
40
+ export function stripClientBuiltinImports(imports: ImportInfo[]): ImportInfo[] {
41
+ const result: ImportInfo[] = []
42
+ for (const imp of imports) {
43
+ // Only a *value* named import of the built-ins can become a phantom runtime
44
+ // import. Leave everything else untouched: imports from other sources;
45
+ // `import type { Async }` (erased by TS — never a runtime import, and may be
46
+ // needed to type-check emitted templates); and side-effect imports (no
47
+ // specifiers, deliberate). See #1915 review.
48
+ if (imp.source !== CLIENT_BUILTIN_SOURCE || imp.isTypeOnly || imp.specifiers.length === 0) {
49
+ result.push(imp)
50
+ continue
51
+ }
52
+ const kept = imp.specifiers.filter(
53
+ // Keep per-specifier type-only built-ins (`import { type Async }`) — they
54
+ // are erased by TS and never a runtime phantom.
55
+ spec => spec.isDefault || spec.isNamespace || spec.isTypeOnly || !isClientBuiltinName(spec.name),
56
+ )
57
+ // Drop the import entirely when every specifier was a built-in; otherwise
58
+ // re-emit without them.
59
+ if (kept.length === 0) continue
60
+ result.push(kept.length === imp.specifiers.length ? imp : { ...imp, specifiers: kept })
61
+ }
62
+ return result
63
+ }
package/src/compiler.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  import type { TemplateAdapter } from './adapters/interface.ts'
15
15
  import { analyzeComponent, listComponentFunctions, createProgramForFile, needsTypeBasedDetection } from './analyzer.ts'
16
16
  import { jsxToIR } from './jsx-to-ir.ts'
17
+ import { stripClientBuiltinImports } from './builtins.ts'
17
18
  import { generateClientJs, generateClientJsWithSourceMap, analyzeClientNeeds } from './ir-to-client-js/index.ts'
18
19
  import { emitModuleLevelDeclarations } from './ir-to-client-js/emit-module-level.ts'
19
20
  import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode } from './ir-to-client-js/imports.ts'
@@ -502,8 +503,11 @@ export function buildMetadata(
502
503
  // re-emission. Adapters that re-emit imports (Hono, test) call
503
504
  // `rewriteImportsForTemplate` themselves to apply client-shim rewrite or
504
505
  // strip behaviour; adapters whose templates never carry imports (Go,
505
- // Mojo) only consult this list for diagnostics like BF103.
506
- templateImports: ctx.imports,
506
+ // Mojo) only consult this list for diagnostics like BF103. The
507
+ // compile-away built-ins (`<Async>` / `<Region>`) are stripped here so
508
+ // their `@barefootjs/client` import never reaches any adapter's template
509
+ // as a phantom (#1915).
510
+ templateImports: stripClientBuiltinImports(ctx.imports),
507
511
  namedExports: ctx.namedExports,
508
512
  localFunctions: ctx.localFunctions,
509
513
  localConstants: ctx.localConstants,
package/src/errors.ts CHANGED
@@ -47,6 +47,11 @@ export const ErrorCodes = {
47
47
  // Import errors (BF050-BF059)
48
48
  SHARED_PROGRAM_REQUIRED: 'BF050',
49
49
  WRONG_PACKAGE_IMPORT: 'BF051',
50
+ // A bare `<Async>` / `<Region>` tag was used without importing it from
51
+ // `@barefootjs/client`. The built-ins are recognised import-scoped (#1915),
52
+ // so an unimported tag with the built-in name is either a forgotten import
53
+ // or an undeclared component — fail loud with the import to add.
54
+ BUILTIN_REQUIRES_IMPORT: 'BF054',
50
55
 
51
56
  // Init statement errors (BF052)
52
57
  UNDECLARED_INIT_STATEMENT_REFERENCE: 'BF052',
@@ -135,6 +140,11 @@ const errorMessages: Record<ErrorCode, string> = {
135
140
  [ErrorCodes.WRONG_PACKAGE_IMPORT]:
136
141
  'Import from wrong package.',
137
142
 
143
+ [ErrorCodes.BUILTIN_REQUIRES_IMPORT]:
144
+ "Built-in <Async> / <Region> must be imported from '@barefootjs/client'. " +
145
+ 'The compiler recognises these tags by their import (not by tag name), ' +
146
+ 'so an unimported tag with this name is treated as an undeclared component.',
147
+
138
148
  [ErrorCodes.UNDECLARED_INIT_STATEMENT_REFERENCE]:
139
149
  'Init statement references an undeclared identifier. Declare it at module scope, inside the component, or import it — otherwise ESM strict mode throws ReferenceError at runtime.',
140
150