@barefootjs/jsx 0.13.0 → 0.15.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.
Files changed (46) 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 +5 -4
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +411 -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/profiler.d.ts +37 -0
  21. package/dist/profiler.d.ts.map +1 -1
  22. package/dist/ssr-defaults.d.ts.map +1 -1
  23. package/dist/types.d.ts +16 -0
  24. package/dist/types.d.ts.map +1 -1
  25. package/package.json +2 -2
  26. package/src/__tests__/compiler-stress-1244.test.ts +4 -2
  27. package/src/__tests__/expression-parser.test.ts +92 -1
  28. package/src/__tests__/ir-async.test.ts +8 -0
  29. package/src/__tests__/ir-builtin-import-scope.test.ts +188 -0
  30. package/src/__tests__/ir-region.test.ts +86 -0
  31. package/src/__tests__/profiler.test.ts +69 -0
  32. package/src/__tests__/ssr-defaults.test.ts +25 -0
  33. package/src/adapters/env-signal.ts +75 -0
  34. package/src/adapters/parsed-expr-emitter.ts +11 -0
  35. package/src/analyzer.ts +9 -0
  36. package/src/augment-inherited-props.ts +170 -2
  37. package/src/builtins.ts +63 -0
  38. package/src/compiler.ts +6 -2
  39. package/src/errors.ts +10 -0
  40. package/src/expression-parser.ts +156 -2
  41. package/src/index.ts +5 -2
  42. package/src/ir-to-client-js/imports.ts +5 -0
  43. package/src/jsx-to-ir.ts +189 -8
  44. package/src/profiler.ts +63 -0
  45. package/src/ssr-defaults.ts +55 -17
  46. package/src/types.ts +16 -0
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
  // =============================================================================
package/src/profiler.ts CHANGED
@@ -32,8 +32,30 @@ import {
32
32
  import { listComponentFunctions, createProgramForFile } from './analyzer.ts'
33
33
  import type { ProfilerEvent } from '@barefootjs/shared'
34
34
 
35
+ /**
36
+ * Schema version for the machine-readable `bf debug profile --json` output
37
+ * (#1841). Bumped when the shape of `StaticBudget` / `ProfileReport` /
38
+ * `BudgetDiff` changes in a way a consumer must notice. An agent can branch on
39
+ * this before trusting any other field — the contract is "same major version =
40
+ * additive only".
41
+ */
42
+ export const PROFILE_SCHEMA_VERSION = 1
43
+
35
44
  // -- Static budget (SR5) ------------------------------------------------------
36
45
 
46
+ /** An event handler the IR knows about — the unit `--scenario auto` fires. */
47
+ export interface BudgetHandler {
48
+ /**
49
+ * `<eventName>@<slotId>` (e.g. `click@s1`). The slotId is the stable join
50
+ * key: it is the element marker `--scenario auto` dispatches against and the
51
+ * id the coverage report counts, so an agent can map a handler listed here to
52
+ * its dynamic coverage without running first.
53
+ */
54
+ name: string
55
+ /** Source location of the handler binding (1-indexed line). */
56
+ loc: { file: string; line: number }
57
+ }
58
+
37
59
  export interface FanOutEntry {
38
60
  /** Signal name (the reactive source whose change fans out). */
39
61
  signal: string
@@ -55,6 +77,8 @@ export interface FanOutEntry {
55
77
  }
56
78
 
57
79
  export interface StaticBudget {
80
+ /** Machine-readable output contract version (#1841). See `PROFILE_SCHEMA_VERSION`. */
81
+ schemaVersion: number
58
82
  componentName: string
59
83
  sourceFile: string
60
84
  kind: 'static-budget'
@@ -70,6 +94,14 @@ export interface StaticBudget {
70
94
  memoChainLongest: string[]
71
95
  /** Per-signal fan-out, descending, hottest first. */
72
96
  fanOut: FanOutEntry[]
97
+ /**
98
+ * Event handlers the IR knows about — the exact set `--scenario auto` would
99
+ * fire — as name + source location (#1841). Exposed statically so an agent
100
+ * can predict the coverage gap and reference handler names *before* any run.
101
+ * Empty when this component binds no handlers (they may live in composed
102
+ * children — see `crossComponentOnly`).
103
+ */
104
+ handlers: BudgetHandler[]
73
105
  /**
74
106
  * True when the component declares reactive state (signals/memos) but nothing
75
107
  * in *this* component subscribes to it — every consumer is in a composed child
@@ -124,6 +156,22 @@ export function buildStaticBudget(
124
156
  })
125
157
  .sort((a, b) => b.subscribers - a.subscribers)
126
158
 
159
+ // Event handlers, in source order — the same `graph.domBindings` event slots
160
+ // the auto scenario fires (scenario-driver discovers handlers from here), so
161
+ // the static list and the dynamic coverage share one identity (slotId).
162
+ const handlers: BudgetHandler[] = graph.domBindings
163
+ .filter(b => b.type === 'event')
164
+ // Event bindings always carry `loc` (IREvent.loc is required), and coverage's
165
+ // turn→binding join keys on it too — so skip any loc-less binding rather than
166
+ // emit an invalid (line 0) location that a consumer would have to special-case.
167
+ .flatMap(b => {
168
+ if (!b.loc) return []
169
+ // Label is `<event> handler "<slot>"` — the same parse the scenario driver
170
+ // and coverage join use. Fall back to a neutral `event` if it ever drifts.
171
+ const eventName = b.label.match(/^(\w+)\s+handler/)?.[1] ?? 'event'
172
+ return [{ name: `${eventName}@${b.slotId}`, loc: { file: b.loc.file, line: b.loc.start.line } }]
173
+ })
174
+
127
175
  const { depth, chain } = longestMemoChain(graph)
128
176
 
129
177
  // Reactive state exists, yet nothing in *this* component observes it. The
@@ -141,6 +189,7 @@ export function buildStaticBudget(
141
189
  const crossComponentOnly = hasReactiveState && !observedInComponent
142
190
 
143
191
  return {
192
+ schemaVersion: PROFILE_SCHEMA_VERSION,
144
193
  componentName: summary.componentName,
145
194
  sourceFile: summary.sourceFile,
146
195
  kind: 'static-budget',
@@ -152,6 +201,7 @@ export function buildStaticBudget(
152
201
  memoChainDepth: depth,
153
202
  memoChainLongest: chain,
154
203
  fanOut,
204
+ handlers,
155
205
  crossComponentOnly,
156
206
  }
157
207
  }
@@ -254,6 +304,13 @@ export function formatStaticBudget(b: StaticBudget): string {
254
304
  lines.push(` ${f.signal.padEnd(12)} → ${f.subscribers} subscribers${detail}${f.hot ? ' ⚠ high' : ''}`)
255
305
  }
256
306
  }
307
+ if (b.handlers.length > 0) {
308
+ lines.push(` handlers (${b.handlers.length}):`)
309
+ for (const h of b.handlers) {
310
+ const file = h.loc.file.split('/').pop() ?? h.loc.file
311
+ lines.push(` ${h.name.padEnd(16)} ${file}:${h.loc.line}`)
312
+ }
313
+ }
257
314
  if (b.crossComponentOnly) {
258
315
  lines.push(
259
316
  ` ⓘ compound: ${b.signals} signal(s) / ${b.memos} memo(s) but 0 in-component subscriptions —`,
@@ -281,6 +338,8 @@ export interface BudgetDiff {
281
338
  * component with no reactive state (#1849 B2).
282
339
  */
283
340
  kind: 'diff'
341
+ /** Machine-readable output contract version (#1841). See `PROFILE_SCHEMA_VERSION`. */
342
+ schemaVersion: number
284
343
  componentName: string
285
344
  signals: number
286
345
  memos: number
@@ -318,6 +377,7 @@ export function diffStaticBudget(base: StaticBudget, head: StaticBudget): Budget
318
377
 
319
378
  const d: Omit<BudgetDiff, 'regressed'> = {
320
379
  kind: 'diff',
380
+ schemaVersion: PROFILE_SCHEMA_VERSION,
321
381
  componentName: head.componentName,
322
382
  signals: head.signals - base.signals,
323
383
  memos: head.memos - base.memos,
@@ -1312,6 +1372,8 @@ export interface ProfileCoverage {
1312
1372
 
1313
1373
  export interface ProfileReport {
1314
1374
  kind: 'profile'
1375
+ /** Machine-readable output contract version (#1841). See `PROFILE_SCHEMA_VERSION`. */
1376
+ schemaVersion: number
1315
1377
  componentName: string
1316
1378
  sourceFile: string
1317
1379
  /** Scenario label (e.g. `'auto'` or a scenario file name). */
@@ -1464,6 +1526,7 @@ export function buildProfileReport(input: ProfileReportInput): ProfileReport {
1464
1526
 
1465
1527
  return {
1466
1528
  kind: 'profile',
1529
+ schemaVersion: PROFILE_SCHEMA_VERSION,
1467
1530
  componentName: primary.componentName,
1468
1531
  sourceFile: primary.sourceFile,
1469
1532
  scenario,
@@ -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)
package/src/types.ts CHANGED
@@ -302,6 +302,13 @@ export interface IRElement {
302
302
  children: IRNode[]
303
303
  slotId: string | null
304
304
  needsScope: boolean
305
+ /**
306
+ * Page-lifecycle boundary id for an element lowered from `<Region>`
307
+ * (spec/router.md). Set only on region host elements; adapters emit it as
308
+ * `bf-region="<id>"`. Deterministic (`<file scope>:<index>`) so a layout's
309
+ * shared partial carries the same id across every page that composes it.
310
+ */
311
+ regionId?: string
305
312
  loc: SourceLocation
306
313
  }
307
314
 
@@ -1206,6 +1213,15 @@ export interface ImportSpecifier {
1206
1213
  alias: string | null
1207
1214
  isDefault: boolean
1208
1215
  isNamespace: boolean
1216
+ /**
1217
+ * Per-specifier type-only import (`import { type Foo } from '...'`). Distinct
1218
+ * from `ImportInfo.isTypeOnly` (the whole `import type { ... }` statement).
1219
+ * A type-only specifier introduces no runtime/value binding, so consumers
1220
+ * enforcing a value import (e.g. the `<Async>`/`<Region>` built-ins, #1915)
1221
+ * must ignore it. Absent/false for value specifiers and for default /
1222
+ * namespace imports (which cannot be per-specifier type-only).
1223
+ */
1224
+ isTypeOnly?: boolean
1209
1225
  }
1210
1226
 
1211
1227
  export interface FunctionInfo {