@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.
- package/dist/adapters/env-signal.d.ts +40 -0
- package/dist/adapters/env-signal.d.ts.map +1 -0
- package/dist/adapters/parsed-expr-emitter.d.ts +2 -1
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/augment-inherited-props.d.ts +42 -1
- package/dist/augment-inherited-props.d.ts.map +1 -1
- package/dist/builtins.d.ts +33 -0
- package/dist/builtins.d.ts.map +1 -0
- package/dist/compiler.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +48 -1
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +411 -26
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/profiler.d.ts +37 -0
- package/dist/profiler.d.ts.map +1 -1
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/compiler-stress-1244.test.ts +4 -2
- package/src/__tests__/expression-parser.test.ts +92 -1
- package/src/__tests__/ir-async.test.ts +8 -0
- package/src/__tests__/ir-builtin-import-scope.test.ts +188 -0
- package/src/__tests__/ir-region.test.ts +86 -0
- package/src/__tests__/profiler.test.ts +69 -0
- package/src/__tests__/ssr-defaults.test.ts +25 -0
- package/src/adapters/env-signal.ts +75 -0
- package/src/adapters/parsed-expr-emitter.ts +11 -0
- package/src/analyzer.ts +9 -0
- package/src/augment-inherited-props.ts +170 -2
- package/src/builtins.ts +63 -0
- package/src/compiler.ts +6 -2
- package/src/errors.ts +10 -0
- package/src/expression-parser.ts +156 -2
- package/src/index.ts +5 -2
- package/src/ir-to-client-js/imports.ts +5 -0
- package/src/jsx-to-ir.ts +189 -8
- package/src/profiler.ts +63 -0
- package/src/ssr-defaults.ts +55 -17
- 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
|
|
720
|
-
|
|
721
|
-
|
|
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
|
|
786
|
-
|
|
787
|
-
|
|
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,
|
package/src/ssr-defaults.ts
CHANGED
|
@@ -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
|
-
|
|
273
|
-
|
|
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 {
|