@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
@@ -17,6 +17,14 @@ export type ParsedExpr =
17
17
  | { kind: 'literal'; value: string | number | boolean | null; literalType: 'string' | 'number' | 'boolean' | 'null' }
18
18
  | { kind: 'call'; callee: ParsedExpr; args: ParsedExpr[] }
19
19
  | { kind: 'member'; object: ParsedExpr; property: string; computed: boolean }
20
+ // Element access with a NON-literal index (`selected()[index]`,
21
+ // `rows[i + 1]`). A literal-index access (`arr[0]`, `obj['key']`)
22
+ // stays a `member` (computed) since the key is statically known and
23
+ // folds into the same property path. The variable case can't, so the
24
+ // index travels as its own `ParsedExpr` for the adapter to lower
25
+ // (array `->[$i]` vs hash `->{$k}` in Perl, `[index]` in JS). #1897
26
+ // (data-table's per-row `selected()[index]`).
27
+ | { kind: 'index-access'; object: ParsedExpr; index: ParsedExpr }
20
28
  | { kind: 'binary'; op: string; left: ParsedExpr; right: ParsedExpr }
21
29
  | { kind: 'unary'; op: string; argument: ParsedExpr }
22
30
  | { kind: 'conditional'; test: ParsedExpr; consequent: ParsedExpr; alternate: ParsedExpr }
@@ -46,6 +54,7 @@ export type ParsedExpr =
46
54
  | 'toLowerCase'
47
55
  | 'toUpperCase'
48
56
  | 'trim'
57
+ | 'toFixed'
49
58
  | 'split'
50
59
  | 'startsWith'
51
60
  | 'endsWith'
@@ -476,6 +485,106 @@ export function extractArrowBodyExpression(source: string): string | null {
476
485
  return expr.body.getText(sf).trim()
477
486
  }
478
487
 
488
+ /**
489
+ * One member of a context-provider object-literal value
490
+ * (`<Ctx.Provider value={{ open: () => …, onOpenChange: … }}>`), classified
491
+ * for SSR lowering:
492
+ *
493
+ * - `getter` — a ZERO-parameter arrow with an expression body. At SSR time
494
+ * the provider value is fixed for the render, so the getter is equivalent
495
+ * to its body's value snapshot (`open: () => props.open ?? false` reads as
496
+ * `props.open ?? false`). Arrows with parameters are NOT getters — their
497
+ * body references the parameter, which has no SSR value.
498
+ * - `function` — any other function shape (parameterised / block-bodied
499
+ * arrow, function expression, or a `??` / `||` chain with a function
500
+ * operand). These are behavior, not data: SSR never invokes them, so
501
+ * adapters lower them to their nil value (`undef` / `nil`).
502
+ * - `expression` — everything else; lowers through the adapter's normal
503
+ * expression pipeline (so signal getters, props, memo calls keep their
504
+ * existing SSR seeding semantics).
505
+ */
506
+ export type ProviderObjectMember =
507
+ | { name: string; kind: 'getter'; body: string }
508
+ | { name: string; kind: 'function' }
509
+ | { name: string; kind: 'expression'; expr: string }
510
+
511
+ /**
512
+ * Structurally parse a `<Ctx.Provider value={{ … }}>` object literal into
513
+ * per-member SSR lowering classifications (see `ProviderObjectMember`).
514
+ *
515
+ * Returns `null` when the source is not a plain object literal, or when it
516
+ * contains a shape with no per-member story (spread entry, computed key,
517
+ * get/set accessor) — callers fall back to their existing whole-expression
518
+ * path (typically a BF101 refusal). Shorthand members (`{ search }`) yield
519
+ * an `expression` member with the identifier as the expression; method
520
+ * members (`{ open() {…} }`) classify as `function` like block-bodied
521
+ * arrows.
522
+ */
523
+ export function parseProviderObjectLiteral(source: string): ProviderObjectMember[] | null {
524
+ const sf = ts.createSourceFile(
525
+ '__provider__.ts',
526
+ `const __x = (${source});`,
527
+ ts.ScriptTarget.Latest,
528
+ /* setParentNodes */ true,
529
+ )
530
+ const stmt = sf.statements[0]
531
+ if (!stmt || !ts.isVariableStatement(stmt)) return null
532
+ let init = stmt.declarationList.declarations[0]?.initializer
533
+ while (init && ts.isParenthesizedExpression(init)) init = init.expression
534
+ if (!init || !ts.isObjectLiteralExpression(init)) return null
535
+
536
+ const isFunctionShaped = (e: ts.Expression): boolean => {
537
+ let v: ts.Expression = e
538
+ while (ts.isParenthesizedExpression(v)) v = v.expression
539
+ if (ts.isArrowFunction(v) || ts.isFunctionExpression(v)) return true
540
+ // `props.onX ?? (() => {})` — a fallback chain with a function operand
541
+ // is function-typed regardless of which side wins at runtime.
542
+ if (
543
+ ts.isBinaryExpression(v) &&
544
+ (v.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken ||
545
+ v.operatorToken.kind === ts.SyntaxKind.BarBarToken)
546
+ ) {
547
+ return isFunctionShaped(v.left) || isFunctionShaped(v.right)
548
+ }
549
+ return false
550
+ }
551
+
552
+ const members: ProviderObjectMember[] = []
553
+ for (const prop of init.properties) {
554
+ if (ts.isShorthandPropertyAssignment(prop)) {
555
+ members.push({ name: prop.name.text, kind: 'expression', expr: prop.name.text })
556
+ continue
557
+ }
558
+ if (ts.isMethodDeclaration(prop)) {
559
+ // `{ open() {…} }` — function-shaped behavior, same as a
560
+ // block-bodied arrow member.
561
+ const name = ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)
562
+ ? prop.name.text
563
+ : null
564
+ if (name === null) return null // computed key
565
+ members.push({ name, kind: 'function' })
566
+ continue
567
+ }
568
+ if (!ts.isPropertyAssignment(prop)) return null // spread / accessor
569
+ const name = ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)
570
+ ? prop.name.text
571
+ : null
572
+ if (name === null) return null // computed key
573
+ let v: ts.Expression = prop.initializer
574
+ while (ts.isParenthesizedExpression(v)) v = v.expression
575
+ if (ts.isArrowFunction(v) && v.parameters.length === 0 && !ts.isBlock(v.body)) {
576
+ members.push({ name, kind: 'getter', body: v.body.getText(sf).trim() })
577
+ continue
578
+ }
579
+ if (isFunctionShaped(v)) {
580
+ members.push({ name, kind: 'function' })
581
+ continue
582
+ }
583
+ members.push({ name, kind: 'expression', expr: v.getText(sf).trim() })
584
+ }
585
+ return members
586
+ }
587
+
479
588
  /**
480
589
  * A single entry of a JSX `style={{ … }}` object, lowered for SSR. The key is
481
590
  * already CSS-cased (`backgroundColor` → `background-color`); the value is
@@ -757,6 +866,14 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
757
866
  if (callee.property === 'trim') {
758
867
  return { kind: 'array-method', method: 'trim', object: callee.object, args }
759
868
  }
869
+ // `.toFixed(digits?)` — Number → fixed-decimal string. The digit
870
+ // count (default 0) travels as the single arg; all adapters route
871
+ // through a `to_fixed` runtime helper (Perl) / `fmt.Sprintf` (Go)
872
+ // so JS's rounding + zero-padding semantics match. #1897
873
+ // (data-table's `payment.amount.toFixed(2)`).
874
+ if (callee.property === 'toFixed') {
875
+ return { kind: 'array-method', method: 'toFixed', object: callee.object, args }
876
+ }
760
877
  // `.split()` / `.split(sep)` / `.split(sep, limit)` — string →
761
878
  // array, full JS arity. `.split()` (no separator) returns the
762
879
  // whole string as a single element; `.split(sep)` splits on the
@@ -1028,6 +1145,13 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1028
1145
  if (ts.isElementAccessExpression(node)) {
1029
1146
  const object = convertNode(node.expression, raw)
1030
1147
  const argNode = node.argumentExpression
1148
+ // `argumentExpression` is non-optional in the TS types but CAN be
1149
+ // undefined on an AST recovered from incomplete source (`arr[`). Guard
1150
+ // so a half-typed expression surfaces a recoverable BF101 instead of
1151
+ // throwing inside `ts.isNumericLiteral(undefined)`.
1152
+ if (!argNode) {
1153
+ return { kind: 'unsupported', raw, reason: 'Element access with no index expression' }
1154
+ }
1031
1155
  // For simple number/string access, store as property
1032
1156
  if (ts.isNumericLiteral(argNode)) {
1033
1157
  return { kind: 'member', object, property: argNode.text, computed: true }
@@ -1035,8 +1159,13 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1035
1159
  if (ts.isStringLiteral(argNode)) {
1036
1160
  return { kind: 'member', object, property: argNode.text, computed: true }
1037
1161
  }
1038
- // Complex computed access
1039
- return { kind: 'unsupported', raw, reason: 'Complex computed property access' }
1162
+ // Variable / expression index (`selected()[index]`, `rows[i + 1]`):
1163
+ // carry the index as its own ParsedExpr so the adapter can lower it
1164
+ // (the literal forms above fold into a static property path; this
1165
+ // one can't). #1897 (data-table).
1166
+ const index = convertNode(argNode, raw)
1167
+ if (index.kind === 'unsupported') return index
1168
+ return { kind: 'index-access', object, index }
1040
1169
  }
1041
1170
 
1042
1171
  // Binary expression: a === b, count > 0, a + b
@@ -1988,6 +2117,8 @@ function findImpureDefaultNode(expr: ParsedExpr): string | null {
1988
2117
  return null
1989
2118
  case 'member':
1990
2119
  return findImpureDefaultNode(expr.object)
2120
+ case 'index-access':
2121
+ return findImpureDefaultNode(expr.object) ?? findImpureDefaultNode(expr.index)
1991
2122
  case 'unary':
1992
2123
  return findImpureDefaultNode(expr.argument)
1993
2124
  case 'binary':
@@ -2217,6 +2348,10 @@ function collectIdentifiers(expr: ParsedExpr, out: Set<string>): void {
2217
2348
  case 'member':
2218
2349
  collectIdentifiers(expr.object, out)
2219
2350
  return
2351
+ case 'index-access':
2352
+ collectIdentifiers(expr.object, out)
2353
+ collectIdentifiers(expr.index, out)
2354
+ return
2220
2355
  case 'binary':
2221
2356
  case 'logical':
2222
2357
  collectIdentifiers(expr.left, out)
@@ -2311,6 +2446,8 @@ function substituteDestructuredFields(
2311
2446
  }
2312
2447
  }
2313
2448
  return { kind: 'member', object: walk(e.object), property: e.property, computed: e.computed }
2449
+ case 'index-access':
2450
+ return { kind: 'index-access', object: walk(e.object), index: walk(e.index) }
2314
2451
  case 'binary':
2315
2452
  return { kind: 'binary', op: e.op, left: walk(e.left), right: walk(e.right) }
2316
2453
  case 'logical':
@@ -2559,6 +2696,17 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2559
2696
  return { supported: true, level: 'L2' }
2560
2697
  }
2561
2698
 
2699
+ case 'index-access': {
2700
+ // `arr[index]` — supported when both the receiver and the index
2701
+ // expression are themselves supported (the index is typically a
2702
+ // loop variable or arithmetic over one). #1897 (data-table).
2703
+ const objSupport = checkSupport(expr.object)
2704
+ if (!objSupport.supported) return objSupport
2705
+ const indexSupport = checkSupport(expr.index)
2706
+ if (!indexSupport.supported) return indexSupport
2707
+ return { supported: true, level: 'L2' }
2708
+ }
2709
+
2562
2710
  case 'binary': {
2563
2711
  const leftSupport = checkSupport(expr.left)
2564
2712
  if (!leftSupport.supported) return leftSupport
@@ -2640,6 +2788,8 @@ export function containsHigherOrder(expr: ParsedExpr): boolean {
2640
2788
  return expr.args.some(containsHigherOrder) || containsHigherOrder(expr.callee)
2641
2789
  case 'member':
2642
2790
  return containsHigherOrder(expr.object)
2791
+ case 'index-access':
2792
+ return containsHigherOrder(expr.object) || containsHigherOrder(expr.index)
2643
2793
  case 'binary':
2644
2794
  return containsHigherOrder(expr.left) || containsHigherOrder(expr.right)
2645
2795
  case 'unary':
@@ -2811,6 +2961,8 @@ export function exprToString(expr: ParsedExpr): string {
2811
2961
  return `${exprToString(expr.callee)}(${expr.args.map(exprToString).join(', ')})`
2812
2962
  case 'member':
2813
2963
  return `${exprToString(expr.object)}.${expr.property}`
2964
+ case 'index-access':
2965
+ return `${exprToString(expr.object)}[${exprToString(expr.index)}]`
2814
2966
  case 'binary':
2815
2967
  return `${exprToString(expr.left)} ${expr.op} ${exprToString(expr.right)}`
2816
2968
  case 'unary':
@@ -2892,6 +3044,8 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
2892
3044
  : JSON.stringify(expr.property)
2893
3045
  return `${obj}[${key}]`
2894
3046
  }
3047
+ case 'index-access':
3048
+ return `${stringifyParsedExpr(expr.object)}[${stringifyParsedExpr(expr.index)}]`
2895
3049
  case 'binary':
2896
3050
  return `${stringifyParsedExpr(expr.left)} ${expr.op} ${stringifyParsedExpr(expr.right)}`
2897
3051
  case 'unary':
package/src/index.ts CHANGED
@@ -76,6 +76,7 @@ export type { JsxAdapterConfig } from './adapters/jsx-adapter.ts'
76
76
  export { rewriteImportsForTemplate } from './adapters/template-imports.ts'
77
77
  export { emitParsedExpr } from './adapters/parsed-expr-emitter.ts'
78
78
  export type { ParsedExprEmitter, HigherOrderMethod, ArrayMethod, SortMethod, LiteralType } from './adapters/parsed-expr-emitter.ts'
79
+ export { importsSearchParams, searchParamsLocalNames, matchSearchParamsMethodCall } from './adapters/env-signal.ts'
79
80
  export { emitIRNode } from './adapters/ir-node-emitter.ts'
80
81
  export type { IRNodeEmitter, EmitIRNode } from './adapters/ir-node-emitter.ts'
81
82
  export { emitAttrValue } from './adapters/attr-value-emitter.ts'
@@ -149,6 +150,11 @@ export type ExternalSpec =
149
150
  /**
150
151
  * An entry point to bundle directly with esbuild.
151
152
  * Externals declared in `BuildOptions.externals` are applied automatically.
153
+ * `@barefootjs/client`, `@barefootjs/client/runtime`, and
154
+ * `@barefootjs/client/reactive` are always kept external, so you never need
155
+ * to list them here. They resolve through the page's import map to the shared
156
+ * `barefoot.js` runtime; inlining them would fork the reactive runtime and
157
+ * duplicate signals (#927).
152
158
  * Use this for modules that are not barefoot components (e.g. plain TS entry
153
159
  * points that import external vendor packages).
154
160
  */
@@ -247,7 +253,7 @@ export {
247
253
  export { ErrorCodes, createError, formatError, generateCodeFrame } from './errors.ts'
248
254
 
249
255
  // Expression Parser
250
- export { parseExpression, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries } from './expression-parser.ts'
256
+ export { parseExpression, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, parseProviderObjectLiteral, type ProviderObjectMember } from './expression-parser.ts'
251
257
  export type { StyleObjectEntry } from './expression-parser.ts'
252
258
  export type { ParsedExpr, ParsedStatement, SortComparator, SortKey, ReduceOp, FlatDepth, FlatMapOp, FlatMapLeaf, SupportLevel, SupportResult, TemplatePart } from './expression-parser.ts'
253
259
  export { buildLoopChainExpr } from './loop-chain.ts'
@@ -354,7 +360,7 @@ export type {
354
360
  export { BOOLEAN_ATTRS, isBooleanAttr } from './html-constants.ts'
355
361
 
356
362
  // Shared props-object-pattern helpers for the Go / Mojo template adapters
357
- export { augmentInheritedPropAccesses, parseRecordIndexAccess, evalStringArrayJoin, collectContextConsumers } from './augment-inherited-props.ts'
363
+ export { augmentInheritedPropAccesses, parseRecordIndexAccess, evalStringArrayJoin, collectModuleStringConsts, lookupStaticRecordLiteral, collectContextConsumers } from './augment-inherited-props.ts'
358
364
  export type { RecordIndexAccess, RecordIndexEntry, ContextConsumer } from './augment-inherited-props.ts'
359
365
 
360
366
  // HTML element attribute types
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ComponentIR, IRNode } from '../types.ts'
6
+ import { isClientBuiltinName } from '../builtins.ts'
6
7
 
7
8
  // All exports from @barefootjs/client/runtime that may be used in generated code
8
9
  export const RUNTIME_IMPORT_CANDIDATES = [
@@ -62,6 +63,10 @@ export function collectUserDomImports(ir: ComponentIR): string[] {
62
63
  if (runtimeSources.has(imp.source) && !imp.isTypeOnly) {
63
64
  for (const spec of imp.specifiers) {
64
65
  if (!spec.isDefault && !spec.isNamespace) {
66
+ // Compile-away built-ins (`<Async>` / `<Region>`) are lowered into
67
+ // the template — never emit their import into the client bundle,
68
+ // where it would be a phantom runtime import (#1915).
69
+ if (isClientBuiltinName(spec.name)) continue
65
70
  userImports.push(spec.alias ? `${spec.name} as ${spec.alias}` : spec.name)
66
71
  }
67
72
  }
package/src/jsx-to-ir.ts CHANGED
@@ -35,12 +35,14 @@ import {
35
35
  import { type AnalyzerContext, type MultiReturnJsxInfo, getSourceLocation } from './analyzer-context.ts'
36
36
  import { parseExpression, isSupported, parseBlockBody, extractSortComparatorFromTS, cssKebabCase, type ParsedExpr, type ParsedStatement, type SortComparator } from './expression-parser.ts'
37
37
  import { createError, ErrorCodes, internalInvariant } from './errors.ts'
38
+ import { CLIENT_BUILTIN_SOURCE, isClientBuiltinName, type ClientBuiltinTag } from './builtins.ts'
38
39
  import { containsReactiveExpression } from './reactivity-checker.ts'
39
40
  import {
40
41
  rewriteBarePropRefs as rewriteBarePropRefsCore,
41
42
  collectAstPropRefs,
42
43
  } from './prop-rewrite.ts'
43
44
  import { resolveFreeRefs, type BindingEnvironment } from './free-refs.ts'
45
+ import { computeFileScope } from './ir-to-client-js/component-scope.ts'
44
46
  import { extractFreeIdentifiersFromNode, initializerShapeContainsJsx } from './analyzer.ts'
45
47
  import { iterateJsTokens, replaceInExprContexts } from './scanner/js-scanner.ts'
46
48
 
@@ -78,6 +80,8 @@ interface TransformContext {
78
80
  loopParams: Set<string>
79
81
  /** Counter for async boundary IDs (a0, a1, ...) */
80
82
  asyncIdCounter: number
83
+ /** Counter for <Region> structural index (0, 1, ...) within a file. */
84
+ regionIdCounter: number
81
85
  /** Counter for loop marker IDs (l0, l1, ...) — separate from slot IDs so element bf="sN" numbering stays stable across versions (#1087). */
82
86
  loopMarkerCounter: number
83
87
  /**
@@ -137,6 +141,14 @@ interface TransformContext {
137
141
  * See #1425.
138
142
  */
139
143
  _branchScopePropDeps?: Map<string, Set<string>>
144
+ /**
145
+ * Lazily computed map of local JSX tag name → compile-away built-in
146
+ * (`Async` / `Region`), derived from `@barefootjs/client` imports (#1915).
147
+ * Recognition is import-scoped (not a bare tag-name match) so a user's own
148
+ * `<Async>` / `<Region>` component doesn't collide with the built-in, and an
149
+ * aliased `import { Async as Boundary }` maps `<Boundary>` to the built-in.
150
+ */
151
+ _clientBuiltinTags?: Map<string, ClientBuiltinTag>
140
152
  }
141
153
 
142
154
  /**
@@ -366,6 +378,7 @@ function createTransformContext(analyzer: AnalyzerContext): TransformContext {
366
378
  filePath: analyzer.filePath,
367
379
  slotIdCounter: 0,
368
380
  asyncIdCounter: 0,
381
+ regionIdCounter: 0,
369
382
  loopMarkerCounter: 0,
370
383
  spreadIdCounter: 0,
371
384
  isRoot: true,
@@ -705,6 +718,101 @@ function transformNode(node: ts.Node, ctx: TransformContext): IRNode | null {
705
718
  // JSX Element Transformation
706
719
  // =============================================================================
707
720
 
721
+ /**
722
+ * Map a local JSX tag name to its compile-away built-in (`Async` / `Region`)
723
+ * if it was imported from `@barefootjs/client` (#1915). Recognition is
724
+ * import-scoped — keyed off `imports` metadata, never a bare tag-name match —
725
+ * so a user's own `<Async>` / `<Region>` component does not collide with the
726
+ * built-in, and `import { Async as Boundary }` maps `<Boundary>` to it.
727
+ * Memoized on `ctx`; the import list is fixed for the compile.
728
+ */
729
+ function clientBuiltinTags(ctx: TransformContext): Map<string, ClientBuiltinTag> {
730
+ if (ctx._clientBuiltinTags) return ctx._clientBuiltinTags
731
+ const map = new Map<string, ClientBuiltinTag>()
732
+ for (const imp of ctx.analyzer.imports) {
733
+ // Require a *value* import: the tag is used as a JSX value, and the design
734
+ // is import-value-required. `import type { Async }` brings no value binding
735
+ // into scope (and is never a runtime import), so it does not scope the
736
+ // built-in — `<Async>` then falls through to BF054 (#1915 review).
737
+ if (imp.source !== CLIENT_BUILTIN_SOURCE || imp.isTypeOnly) continue
738
+ for (const spec of imp.specifiers) {
739
+ // Skip per-specifier `import { type Async }` — no value binding.
740
+ if (spec.isDefault || spec.isNamespace || spec.isTypeOnly) continue
741
+ if (isClientBuiltinName(spec.name)) {
742
+ map.set(spec.alias ?? spec.name, spec.name)
743
+ }
744
+ }
745
+ }
746
+ ctx._clientBuiltinTags = map
747
+ return map
748
+ }
749
+
750
+ /**
751
+ * Whether `name` resolves to any in-scope value binding — an import (by its
752
+ * local name), a local function / constant, or an ambient `declare`. Used to
753
+ * keep the BF054 "import the built-in" diagnostic from firing when the author
754
+ * legitimately has their own `<Async>` / `<Region>` binding.
755
+ */
756
+ function isNameBound(ctx: TransformContext, name: string): boolean {
757
+ const a = ctx.analyzer
758
+ if (a.ambientGlobals.has(name)) return true
759
+ if (a.localFunctions.some(f => f.name === name)) return true
760
+ if (a.localConstants.some(c => c.name === name)) return true
761
+ for (const imp of a.imports) {
762
+ // Type-only imports create a type binding, not a value one — they can't
763
+ // back a JSX value tag, so they must not suppress BF054. Applies to both
764
+ // `import type { ... }` and per-specifier `import { type X }` (#1915 review).
765
+ if (imp.isTypeOnly) continue
766
+ for (const spec of imp.specifiers) {
767
+ if (spec.isTypeOnly) continue
768
+ if ((spec.alias ?? spec.name) === name) return true
769
+ }
770
+ }
771
+ return false
772
+ }
773
+
774
+ function reportBuiltinNotImported(
775
+ ctx: TransformContext,
776
+ node: ts.Node,
777
+ tagName: ClientBuiltinTag,
778
+ ): void {
779
+ ctx.analyzer.errors.push(
780
+ createError(
781
+ ErrorCodes.BUILTIN_REQUIRES_IMPORT,
782
+ getSourceLocation(node, ctx.sourceFile, ctx.filePath),
783
+ {
784
+ severity: 'error',
785
+ message: `<${tagName}> must be imported from '${CLIENT_BUILTIN_SOURCE}' to be recognised as a compiler built-in.`,
786
+ suggestion: {
787
+ message: `Add: import { ${tagName} } from '${CLIENT_BUILTIN_SOURCE}'`,
788
+ },
789
+ },
790
+ ),
791
+ )
792
+ }
793
+
794
+ /**
795
+ * Dispatch a built-in JSX tag (`Async` / `Region`) when import-scoped
796
+ * recognition matches, or emit BF054 when the bare built-in name is used
797
+ * without the import and without any other in-scope binding. Returns the
798
+ * lowered IR node, or `null` to fall through to normal component handling.
799
+ */
800
+ function dispatchClientBuiltin(
801
+ tagName: string,
802
+ ctx: TransformContext,
803
+ diagNode: ts.Node,
804
+ transformAsync: () => IRNode,
805
+ transformRegion: () => IRNode,
806
+ ): IRNode | null {
807
+ const builtin = clientBuiltinTags(ctx).get(tagName)
808
+ if (builtin === 'Async') return transformAsync()
809
+ if (builtin === 'Region') return transformRegion()
810
+ if (isClientBuiltinName(tagName) && !isNameBound(ctx, tagName)) {
811
+ reportBuiltinNotImported(ctx, diagNode, tagName)
812
+ }
813
+ return null
814
+ }
815
+
708
816
  function transformJsxElement(
709
817
  node: ts.JsxElement,
710
818
  ctx: TransformContext
@@ -716,10 +824,16 @@ function transformJsxElement(
716
824
  return transformProviderElement(node, ctx, tagName)
717
825
  }
718
826
 
719
- // Detect Async streaming boundary: <Async fallback={...}>
720
- if (tagName === 'Async') {
721
- return transformAsyncElement(node, ctx)
722
- }
827
+ // Detect compile-away built-ins (`<Async>` / `<Region>`), recognised by
828
+ // their `@barefootjs/client` import rather than by tag name (#1915).
829
+ const builtin = dispatchClientBuiltin(
830
+ tagName,
831
+ ctx,
832
+ node.openingElement,
833
+ () => transformAsyncElement(node, ctx),
834
+ () => transformRegionElement(node, ctx),
835
+ )
836
+ if (builtin) return builtin
723
837
 
724
838
  const isComponent = /^[A-Z]/.test(tagName)
725
839
 
@@ -782,10 +896,16 @@ function transformSelfClosingElement(
782
896
  return transformSelfClosingProviderElement(node, ctx, tagName)
783
897
  }
784
898
 
785
- // Detect Async streaming boundary: <Async ... />
786
- if (tagName === 'Async') {
787
- return transformSelfClosingAsyncElement(node, ctx)
788
- }
899
+ // Detect compile-away built-ins (`<Async />` / `<Region />`), recognised by
900
+ // their `@barefootjs/client` import rather than by tag name (#1915).
901
+ const builtin = dispatchClientBuiltin(
902
+ tagName,
903
+ ctx,
904
+ node,
905
+ () => transformSelfClosingAsyncElement(node, ctx),
906
+ () => transformSelfClosingRegionElement(node, ctx),
907
+ )
908
+ if (builtin) return builtin
789
909
 
790
910
  const isComponent = /^[A-Z]/.test(tagName)
791
911
 
@@ -1007,6 +1127,67 @@ function transformSelfClosingAsyncElement(
1007
1127
  }
1008
1128
  }
1009
1129
 
1130
+ /**
1131
+ * Lower `<Region>{children}</Region>` to a plain wrapper element carrying the
1132
+ * `bf-region` marker (spec/router.md "Regions"). The id is deterministic —
1133
+ * `<file scope>:<index>` — so a layout that compiles to one shared partial
1134
+ * emits the *same* id across every page that composes it, which is what the
1135
+ * client router matches on. `<Region>` is recognised by its `@barefootjs/client`
1136
+ * import (import-scoped, not a bare tag-name match — #1915).
1137
+ */
1138
+ function regionId(ctx: TransformContext): string {
1139
+ return `${computeFileScope(ctx.filePath)}:${ctx.regionIdCounter++}`
1140
+ }
1141
+
1142
+ function transformRegionElement(
1143
+ node: ts.JsxElement,
1144
+ ctx: TransformContext
1145
+ ): IRElement {
1146
+ const id = regionId(ctx)
1147
+
1148
+ // Mirror transformHtmlElement's isRoot bookkeeping so the region's children
1149
+ // are not mistaken for component roots.
1150
+ const needsScope = ctx.isRoot
1151
+ ctx.isRoot = false
1152
+
1153
+ const children = transformChildren(node.children, ctx)
1154
+
1155
+ return {
1156
+ type: 'element',
1157
+ tag: 'div',
1158
+ attrs: [],
1159
+ events: [],
1160
+ ref: null,
1161
+ children,
1162
+ slotId: null,
1163
+ needsScope,
1164
+ regionId: id,
1165
+ loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
1166
+ }
1167
+ }
1168
+
1169
+ function transformSelfClosingRegionElement(
1170
+ node: ts.JsxSelfClosingElement,
1171
+ ctx: TransformContext
1172
+ ): IRElement {
1173
+ const id = regionId(ctx)
1174
+ const needsScope = ctx.isRoot
1175
+ ctx.isRoot = false
1176
+
1177
+ return {
1178
+ type: 'element',
1179
+ tag: 'div',
1180
+ attrs: [],
1181
+ events: [],
1182
+ ref: null,
1183
+ children: [],
1184
+ slotId: null,
1185
+ needsScope,
1186
+ regionId: id,
1187
+ loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
1188
+ }
1189
+ }
1190
+
1010
1191
  // =============================================================================
1011
1192
  // Component Transformation
1012
1193
  // =============================================================================
@@ -65,6 +65,12 @@ export interface SsrDefault {
65
65
  const UNRESOLVED = Symbol('unresolved')
66
66
  type EvalResult = unknown | typeof UNRESOLVED
67
67
 
68
+ // Sentinel: a statement list ran to its end without hitting a `return`
69
+ // (so the enclosing branch falls through to the next statement). Kept
70
+ // distinct from `UNRESOLVED` (couldn't evaluate) so a falsy guard
71
+ // (`if (!key) return X`) continues evaluation instead of bailing.
72
+ const NO_RETURN = Symbol('no-return')
73
+
68
74
  interface EvalContext {
69
75
  /** Identifier name → previously-resolved value (signal getters, memos). */
70
76
  bindings: Record<string, EvalResult>
@@ -230,6 +236,53 @@ function tryStaticEval(expr: string, ctx: EvalContext): EvalResult {
230
236
  return evalNode(node, ctx)
231
237
  }
232
238
 
239
+ /**
240
+ * Evaluate a block-body's statements for its returned value. Handles
241
+ * `const` declarations (bound into `ctx.bindings`, which mutate in
242
+ * place so later statements and nested branches see them), `return`
243
+ * statements, and `if (cond) …` guards whose condition is statically
244
+ * resolvable — the early-return-on-default-state shape of
245
+ * an `@client`-annotated memo (`const key = sortKey(); if (!key)
246
+ * return payments; … sort …`). A resolvable-but-falsy guard continues
247
+ * to the next statement (`NO_RETURN` from the skipped branch); an
248
+ * unresolvable condition or any other statement kind bails to
249
+ * `UNRESOLVED`. #1897 (data-table's `sortedData`).
250
+ */
251
+ function evalStatementsForReturn(
252
+ statements: readonly ts.Statement[],
253
+ ctx: EvalContext,
254
+ ): EvalResult | typeof NO_RETURN {
255
+ for (const stmt of statements) {
256
+ if (ts.isVariableStatement(stmt)) {
257
+ for (const d of stmt.declarationList.declarations) {
258
+ if (!ts.isIdentifier(d.name) || !d.initializer) continue
259
+ const v = evalNode(d.initializer, ctx)
260
+ // Leave unresolved locals unbound; only a `return` / guard
261
+ // referencing one would then surface UNRESOLVED.
262
+ if (v !== UNRESOLVED) ctx.bindings[d.name.text] = v
263
+ }
264
+ } else if (ts.isReturnStatement(stmt)) {
265
+ return stmt.expression ? evalNode(stmt.expression, ctx) : UNRESOLVED
266
+ } else if (ts.isIfStatement(stmt)) {
267
+ const cond = evalNode(stmt.expression, ctx)
268
+ if (cond === UNRESOLVED) return UNRESOLVED
269
+ const branch = cond ? stmt.thenStatement : stmt.elseStatement
270
+ if (branch) {
271
+ const taken = evalStatementsForReturn(
272
+ ts.isBlock(branch) ? branch.statements : [branch],
273
+ ctx,
274
+ )
275
+ if (taken !== NO_RETURN) return taken
276
+ }
277
+ // Guard not taken (or its branch fell through) — continue.
278
+ } else {
279
+ // Any other statement (loop, side-effecting call) — bail.
280
+ return UNRESOLVED
281
+ }
282
+ }
283
+ return NO_RETURN
284
+ }
285
+
233
286
  function parseExpression(expr: string): ts.Expression | null {
234
287
  // Wrap in parens so a leading `{}` parses as an object literal rather
235
288
  // than an empty block statement.
@@ -269,23 +322,8 @@ function evalNode(node: ts.Expression, ctx: EvalContext): EvalResult {
269
322
  if (!ts.isBlock(node.body)) return evalNode(node.body as ts.Expression, ctx)
270
323
  const localBindings: Record<string, EvalResult> = { ...ctx.bindings }
271
324
  const localCtx: EvalContext = { ...ctx, bindings: localBindings }
272
- for (const stmt of node.body.statements) {
273
- if (ts.isVariableStatement(stmt)) {
274
- for (const d of stmt.declarationList.declarations) {
275
- if (!ts.isIdentifier(d.name) || !d.initializer) continue
276
- const v = evalNode(d.initializer, localCtx)
277
- // Leave unresolved locals unbound; only the `return` referencing
278
- // one would then surface UNRESOLVED.
279
- if (v !== UNRESOLVED) localBindings[d.name.text] = v
280
- }
281
- } else if (ts.isReturnStatement(stmt)) {
282
- return stmt.expression ? evalNode(stmt.expression, localCtx) : UNRESOLVED
283
- } else {
284
- // Any other statement (a branch, a side-effecting call) — bail.
285
- return UNRESOLVED
286
- }
287
- }
288
- return UNRESOLVED
325
+ const result = evalStatementsForReturn(node.body.statements, localCtx)
326
+ return result === NO_RETURN ? UNRESOLVED : result
289
327
  }
290
328
 
291
329
  if (ts.isNumericLiteral(node)) return Number(node.text)