@barefootjs/jsx 0.10.1 → 0.11.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/compiler.d.ts.map +1 -1
- package/dist/debug-profile.d.ts +115 -0
- package/dist/debug-profile.d.ts.map +1 -0
- package/dist/debug.d.ts +4 -3
- package/dist/debug.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +31 -0
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1872 -207
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts +6 -0
- package/dist/ir-to-client-js/control-flow/plan/branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts +2 -2
- package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts +3 -3
- package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts +2 -0
- package/dist/ir-to-client-js/control-flow/plan/build-insert.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +2 -0
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts +4 -2
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +3 -1
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +7 -0
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +7 -0
- package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/insert.d.ts +8 -0
- package/dist/ir-to-client-js/control-flow/plan/insert.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +8 -0
- package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +28 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
- package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/component-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-listener.d.ts +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-listener.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/insert.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +2 -2
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/index.d.ts +2 -2
- package/dist/ir-to-client-js/index.d.ts.map +1 -1
- package/dist/ir-to-client-js/phases/effects-and-on-mounts.d.ts.map +1 -1
- package/dist/ir-to-client-js/phases/event-handlers.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts +6 -0
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +5 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +29 -0
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/loop-destructure.d.ts +26 -0
- package/dist/loop-destructure.d.ts.map +1 -0
- package/dist/profiler.d.ts +492 -0
- package/dist/profiler.d.ts.map +1 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/debug-profile.test.ts +405 -0
- package/src/__tests__/expression-parser.test.ts +44 -1
- package/src/__tests__/profile-bfid-emission.test.ts +63 -0
- package/src/__tests__/profile-binding-ids.test.ts +123 -0
- package/src/__tests__/profile-cond-binding-ids.test.ts +80 -0
- package/src/__tests__/profile-loop-binding-ids.test.ts +106 -0
- package/src/__tests__/profile-nested-binding-ids.test.ts +153 -0
- package/src/__tests__/profile-turn-markers-branch.test.ts +83 -0
- package/src/__tests__/profile-turn-markers-delegation.test.ts +63 -0
- package/src/__tests__/profile-turn-markers.test.ts +54 -0
- package/src/__tests__/profiler-batch-advisor.test.ts +198 -0
- package/src/__tests__/profiler-coverage-conformance.test.ts +360 -0
- package/src/__tests__/profiler-e2e.test.ts +104 -0
- package/src/__tests__/profiler-hot-subscribers.test.ts +263 -0
- package/src/__tests__/profiler-wasted-re-runs.test.ts +147 -0
- package/src/__tests__/profiler.test.ts +408 -0
- package/src/compiler.ts +3 -0
- package/src/debug-profile.ts +543 -0
- package/src/debug.ts +192 -28
- package/src/expression-parser.ts +53 -0
- package/src/index.ts +72 -1
- package/src/ir-to-client-js/control-flow/plan/branch-loop.ts +6 -0
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +5 -3
- package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +3 -1
- package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +8 -2
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +19 -3
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +2 -0
- package/src/ir-to-client-js/control-flow/plan/build-insert.ts +9 -2
- package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +9 -1
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +12 -8
- package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +10 -4
- package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +7 -0
- package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +7 -0
- package/src/ir-to-client-js/control-flow/plan/insert.ts +8 -0
- package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +8 -0
- package/src/ir-to-client-js/control-flow/plan/loop.ts +28 -0
- package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
- package/src/ir-to-client-js/control-flow/stringify/branch-loop.ts +5 -3
- package/src/ir-to-client-js/control-flow/stringify/component-loop.ts +4 -2
- package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +6 -3
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +14 -2
- package/src/ir-to-client-js/control-flow/stringify/event-listener.ts +5 -2
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +13 -11
- package/src/ir-to-client-js/control-flow/stringify/insert.ts +19 -7
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +18 -13
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +9 -7
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +18 -14
- package/src/ir-to-client-js/control-flow.ts +12 -6
- package/src/ir-to-client-js/emit-reactive.ts +18 -5
- package/src/ir-to-client-js/imports.ts +2 -0
- package/src/ir-to-client-js/index.ts +6 -1
- package/src/ir-to-client-js/phases/effects-and-on-mounts.ts +10 -4
- package/src/ir-to-client-js/phases/event-handlers.ts +6 -2
- package/src/ir-to-client-js/plan/build-declaration-emit.ts +7 -1
- package/src/ir-to-client-js/plan/declaration-emit.ts +6 -0
- package/src/ir-to-client-js/stringify/declaration-emit.ts +12 -6
- package/src/ir-to-client-js/types.ts +5 -0
- package/src/ir-to-client-js/utils.ts +37 -0
- package/src/jsx-to-ir.ts +2 -2
- package/src/loop-destructure.ts +170 -0
- package/src/profiler.ts +1488 -0
- package/src/types.ts +8 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static reactive profile analysis for `bf debug profile` (#1690, v1).
|
|
3
|
+
*
|
|
4
|
+
* Implements SR5 (static reactivity budget): per-component profile computed
|
|
5
|
+
* entirely from the IR — signal/memo/binding counts, fan-out, memo chain depth,
|
|
6
|
+
* and batch candidates — without running any code.
|
|
7
|
+
*
|
|
8
|
+
* v1 analyses implemented:
|
|
9
|
+
* - High fan-out signals (hot-subscriber precursor)
|
|
10
|
+
* - Deep memo chains
|
|
11
|
+
* - Batch candidates (event sets ≥2 distinct signals)
|
|
12
|
+
* - Fallback-heavy components (compiler couldn't prove reactivity)
|
|
13
|
+
*
|
|
14
|
+
* SR6 (compile-diff regression) is also included: `diffProfiles` compares two
|
|
15
|
+
* `ComponentProfileMetrics` instances and surfaces structural regressions.
|
|
16
|
+
*
|
|
17
|
+
* Runtime analyses (hot subscribers, wasted re-runs) require SR1-SR4
|
|
18
|
+
* (instrumentation hooks + compiler-emitted turn markers) and are out of
|
|
19
|
+
* scope for v1.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ComponentGraph, EventSummary } from './debug.ts'
|
|
23
|
+
import { buildComponentGraph, buildEventSummary } from './debug.ts'
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Types
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
/** Per-component reactive budget metrics derived from the static IR (SR5). */
|
|
30
|
+
export interface ComponentProfileMetrics {
|
|
31
|
+
componentName: string
|
|
32
|
+
sourceFile: string
|
|
33
|
+
/** Component uses `"use client"` / has reactive state. */
|
|
34
|
+
hydrated: boolean
|
|
35
|
+
// Raw counts
|
|
36
|
+
signals: number
|
|
37
|
+
memos: number
|
|
38
|
+
effects: number
|
|
39
|
+
loops: number
|
|
40
|
+
eventHandlers: number
|
|
41
|
+
/** Total reactive DOM bindings (reactive + fallback). */
|
|
42
|
+
dynamicBindings: number
|
|
43
|
+
/** Bindings the compiler could not statically prove reactive (fallback-wrapped). */
|
|
44
|
+
fallbacks: number
|
|
45
|
+
conditionals: number
|
|
46
|
+
// Derived budget metrics
|
|
47
|
+
/** Consumers of the most-subscribed signal (fan-out). */
|
|
48
|
+
maxSignalFanOut: number
|
|
49
|
+
/** Name of the signal with maximum fan-out; null when no signals. */
|
|
50
|
+
hotSignal: string | null
|
|
51
|
+
/** Longest memo→memo chain depth (1 = standalone memo, N = N-level chain). */
|
|
52
|
+
maxMemoChainDepth: number
|
|
53
|
+
/** Sum of dependency counts across all memos, effects, and DOM bindings. */
|
|
54
|
+
totalSubscriptions: number
|
|
55
|
+
/** Event handlers that set ≥2 distinct signals — batch() candidates. */
|
|
56
|
+
batchCandidateCount: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A single actionable insight produced by static analysis. */
|
|
60
|
+
export interface ProfileFinding {
|
|
61
|
+
kind: 'high-fan-out' | 'deep-memo-chain' | 'batch-candidate' | 'fallback-heavy'
|
|
62
|
+
severity: 'info' | 'warning'
|
|
63
|
+
message: string
|
|
64
|
+
suggestion: string
|
|
65
|
+
/** Affected signal name (high-fan-out). */
|
|
66
|
+
signal?: string
|
|
67
|
+
/** Chain depth (deep-memo-chain). */
|
|
68
|
+
depth?: number
|
|
69
|
+
/** Signals involved (batch-candidate). */
|
|
70
|
+
signals?: string[]
|
|
71
|
+
loc?: { file: string; line: number }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Full profile for one component: metrics + findings. */
|
|
75
|
+
export interface ComponentProfile {
|
|
76
|
+
metrics: ComponentProfileMetrics
|
|
77
|
+
findings: ProfileFinding[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// SR6: structural diff between two compile snapshots (no run required).
|
|
81
|
+
export interface ProfileDiff {
|
|
82
|
+
componentName: string
|
|
83
|
+
before: ComponentProfileMetrics
|
|
84
|
+
after: ComponentProfileMetrics
|
|
85
|
+
regressions: ProfileDiffEntry[]
|
|
86
|
+
improvements: ProfileDiffEntry[]
|
|
87
|
+
neutral: ProfileDiffEntry[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ProfileDiffEntry {
|
|
91
|
+
metric: string
|
|
92
|
+
before: number | string | null
|
|
93
|
+
after: number | string | null
|
|
94
|
+
delta: number | null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// =============================================================================
|
|
98
|
+
// Thresholds (tunable, kept conservative for v1)
|
|
99
|
+
// =============================================================================
|
|
100
|
+
|
|
101
|
+
const THRESHOLDS = {
|
|
102
|
+
/** Warn when a signal has more than this many consumers. */
|
|
103
|
+
highFanOut: 3,
|
|
104
|
+
/** Warn when any memo chain is deeper than this. */
|
|
105
|
+
deepMemoChain: 3,
|
|
106
|
+
/** Minimum fallback count before triggering fallback-heavy. */
|
|
107
|
+
fallbackHeavyMin: 3,
|
|
108
|
+
/** Fallback ratio (fallbacks/total bindings) above which to warn. */
|
|
109
|
+
fallbackHeavyRatio: 0.5,
|
|
110
|
+
/** Suggest batch() when a handler sets this many or more distinct signals. */
|
|
111
|
+
batchMinSignals: 2,
|
|
112
|
+
} as const
|
|
113
|
+
|
|
114
|
+
// =============================================================================
|
|
115
|
+
// Public API
|
|
116
|
+
// =============================================================================
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build a reactive profile from raw source text.
|
|
120
|
+
* Runs the full analyzer → IR → graph + event pipeline.
|
|
121
|
+
*/
|
|
122
|
+
export function buildReactiveProfile(
|
|
123
|
+
source: string,
|
|
124
|
+
filePath: string,
|
|
125
|
+
componentName?: string,
|
|
126
|
+
): ComponentProfile {
|
|
127
|
+
const graph = buildComponentGraph(source, filePath, componentName)
|
|
128
|
+
const eventSummary = buildEventSummary(source, filePath, componentName)
|
|
129
|
+
const hydrated = graph.signals.length > 0 || graph.memos.length > 0 || graph.effects.length > 0
|
|
130
|
+
return buildProfileFromGraph(graph, eventSummary, hydrated)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build a reactive profile from a pre-built graph + event summary.
|
|
135
|
+
* Useful when the caller already has the graph (avoids double analysis).
|
|
136
|
+
*/
|
|
137
|
+
export function buildProfileFromGraph(
|
|
138
|
+
graph: ComponentGraph,
|
|
139
|
+
eventSummary: EventSummary,
|
|
140
|
+
hydrated: boolean,
|
|
141
|
+
): ComponentProfile {
|
|
142
|
+
const metrics = computeMetrics(graph, eventSummary, hydrated)
|
|
143
|
+
const findings = computeFindings(metrics, graph, eventSummary)
|
|
144
|
+
return { metrics, findings }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// =============================================================================
|
|
148
|
+
// Metrics computation
|
|
149
|
+
// =============================================================================
|
|
150
|
+
|
|
151
|
+
function computeMetrics(
|
|
152
|
+
graph: ComponentGraph,
|
|
153
|
+
eventSummary: EventSummary,
|
|
154
|
+
hydrated: boolean,
|
|
155
|
+
): ComponentProfileMetrics {
|
|
156
|
+
// Fan-out: which signal has the most consumers?
|
|
157
|
+
let maxFanOut = 0
|
|
158
|
+
let hotSignal: string | null = null
|
|
159
|
+
for (const signal of graph.signals) {
|
|
160
|
+
if (signal.consumers.length > maxFanOut) {
|
|
161
|
+
maxFanOut = signal.consumers.length
|
|
162
|
+
hotSignal = signal.name
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const maxMemoChainDepth = computeMaxMemoChainDepth(graph)
|
|
167
|
+
|
|
168
|
+
const dynamicBindings = graph.domBindings.length
|
|
169
|
+
const fallbacks = graph.domBindings.filter(d => d.classification === 'fallback').length
|
|
170
|
+
const eventHandlers = graph.domBindings.filter(d => d.type === 'event').length
|
|
171
|
+
const conditionals = graph.domBindings.filter(d => d.type === 'conditional').length
|
|
172
|
+
const loops = graph.domBindings.filter(d => d.type === 'loop').length
|
|
173
|
+
|
|
174
|
+
// Total subscriptions = sum of all dep-counts across memos, effects, and DOM bindings.
|
|
175
|
+
const totalSubscriptions =
|
|
176
|
+
graph.memos.reduce((s, m) => s + m.deps.length, 0) +
|
|
177
|
+
graph.effects.reduce((s, e) => s + e.deps.length, 0) +
|
|
178
|
+
graph.domBindings.reduce((s, b) => s + b.deps.length, 0)
|
|
179
|
+
|
|
180
|
+
// Batch candidates: deduplicate by (eventName, loc, signals) to match the
|
|
181
|
+
// findings list — the same handler wired at N JSX sites (e.g. Calendar's
|
|
182
|
+
// dual-month layout) counts once, not N times. (Bug D)
|
|
183
|
+
const batchDedupeKeys = new Set<string>()
|
|
184
|
+
for (const event of eventSummary.events) {
|
|
185
|
+
const distinct = new Set<string>()
|
|
186
|
+
for (const sc of event.setterCalls) {
|
|
187
|
+
if (sc.signal) distinct.add(sc.signal)
|
|
188
|
+
}
|
|
189
|
+
if (distinct.size >= THRESHOLDS.batchMinSignals) {
|
|
190
|
+
const key = `${event.eventName}|${event.loc.file ?? ''}|${event.loc.start.line}|${[...distinct].sort().join(',')}`
|
|
191
|
+
batchDedupeKeys.add(key)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const batchCandidateCount = batchDedupeKeys.size
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
componentName: graph.componentName,
|
|
198
|
+
sourceFile: graph.sourceFile,
|
|
199
|
+
hydrated,
|
|
200
|
+
signals: graph.signals.length,
|
|
201
|
+
memos: graph.memos.length,
|
|
202
|
+
effects: graph.effects.length,
|
|
203
|
+
loops,
|
|
204
|
+
eventHandlers,
|
|
205
|
+
dynamicBindings,
|
|
206
|
+
fallbacks,
|
|
207
|
+
conditionals,
|
|
208
|
+
maxSignalFanOut: maxFanOut,
|
|
209
|
+
hotSignal,
|
|
210
|
+
maxMemoChainDepth,
|
|
211
|
+
totalSubscriptions,
|
|
212
|
+
batchCandidateCount,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Compute the longest memo→memo chain depth in the graph.
|
|
218
|
+
* Depth 1 = a memo with no memo dependants (leaf); depth N = N-level chain.
|
|
219
|
+
* Cycles are guarded via a visited set per traversal.
|
|
220
|
+
*/
|
|
221
|
+
function computeMaxMemoChainDepth(graph: ComponentGraph): number {
|
|
222
|
+
if (graph.memos.length === 0) return 0
|
|
223
|
+
|
|
224
|
+
const memoSet = new Set(graph.memos.map(m => m.name))
|
|
225
|
+
// For each memo, which other memos does it depend on?
|
|
226
|
+
const memoDeps = new Map<string, string[]>()
|
|
227
|
+
for (const memo of graph.memos) {
|
|
228
|
+
memoDeps.set(memo.name, memo.deps.filter(d => memoSet.has(d)))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const cache = new Map<string, number>()
|
|
232
|
+
|
|
233
|
+
function depth(name: string, visited: Set<string>): number {
|
|
234
|
+
if (cache.has(name)) return cache.get(name)!
|
|
235
|
+
if (visited.has(name)) return 0 // cycle guard
|
|
236
|
+
const children = memoDeps.get(name) ?? []
|
|
237
|
+
if (children.length === 0) {
|
|
238
|
+
cache.set(name, 1)
|
|
239
|
+
return 1
|
|
240
|
+
}
|
|
241
|
+
visited.add(name)
|
|
242
|
+
const d = 1 + Math.max(...children.map(c => depth(c, new Set(visited))))
|
|
243
|
+
cache.set(name, d)
|
|
244
|
+
return d
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let max = 0
|
|
248
|
+
for (const memo of graph.memos) {
|
|
249
|
+
const d = depth(memo.name, new Set())
|
|
250
|
+
if (d > max) max = d
|
|
251
|
+
}
|
|
252
|
+
return max
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// =============================================================================
|
|
256
|
+
// Findings (static analyses)
|
|
257
|
+
// =============================================================================
|
|
258
|
+
|
|
259
|
+
function computeFindings(
|
|
260
|
+
metrics: ComponentProfileMetrics,
|
|
261
|
+
graph: ComponentGraph,
|
|
262
|
+
eventSummary: EventSummary,
|
|
263
|
+
): ProfileFinding[] {
|
|
264
|
+
const findings: ProfileFinding[] = []
|
|
265
|
+
|
|
266
|
+
// 1. High fan-out signals
|
|
267
|
+
for (const signal of graph.signals) {
|
|
268
|
+
if (signal.consumers.length > THRESHOLDS.highFanOut) {
|
|
269
|
+
findings.push({
|
|
270
|
+
kind: 'high-fan-out',
|
|
271
|
+
severity: 'warning',
|
|
272
|
+
signal: signal.name,
|
|
273
|
+
message: `${signal.name} has ${signal.consumers.length} consumers (fan-out > ${THRESHOLDS.highFanOut})`,
|
|
274
|
+
suggestion: `Split ${signal.name} into finer-grained signals, or add a createMemo to shield downstream consumers from unrelated updates`,
|
|
275
|
+
loc: { file: signal.loc.file, line: signal.loc.line },
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 2. Deep memo chains
|
|
281
|
+
if (metrics.maxMemoChainDepth > THRESHOLDS.deepMemoChain) {
|
|
282
|
+
findings.push({
|
|
283
|
+
kind: 'deep-memo-chain',
|
|
284
|
+
severity: 'warning',
|
|
285
|
+
depth: metrics.maxMemoChainDepth,
|
|
286
|
+
message: `Memo chain depth ${metrics.maxMemoChainDepth} (threshold: ${THRESHOLDS.deepMemoChain}) — a single signal update cascades through ${metrics.maxMemoChainDepth} memo levels`,
|
|
287
|
+
suggestion: 'Flatten intermediate memos that do not cache expensive computations; deep chains increase propagation latency',
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 3. Batch candidates — deduplicate by (eventName, loc, signals) so that
|
|
292
|
+
// the same handler wired to multiple JSX elements (e.g. Calendar dual-month
|
|
293
|
+
// nav buttons) does not produce duplicate findings. (Bug C)
|
|
294
|
+
const batchSeen = new Set<string>()
|
|
295
|
+
for (const event of eventSummary.events) {
|
|
296
|
+
const distinct = new Set<string>()
|
|
297
|
+
const setterNames: string[] = []
|
|
298
|
+
for (const sc of event.setterCalls) {
|
|
299
|
+
if (sc.signal) {
|
|
300
|
+
distinct.add(sc.signal)
|
|
301
|
+
setterNames.push(sc.setter)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (distinct.size >= THRESHOLDS.batchMinSignals) {
|
|
305
|
+
const dedupeKey = `${event.eventName}|${event.loc.file ?? ''}|${event.loc.start.line}|${[...distinct].sort().join(',')}`
|
|
306
|
+
if (batchSeen.has(dedupeKey)) continue
|
|
307
|
+
batchSeen.add(dedupeKey)
|
|
308
|
+
findings.push({
|
|
309
|
+
kind: 'batch-candidate',
|
|
310
|
+
severity: 'info',
|
|
311
|
+
signals: [...distinct],
|
|
312
|
+
message: `${event.eventName} on <${event.elementContext}> sets ${distinct.size} signals (${[...distinct].join(', ')}) — triggers ${distinct.size} separate update cycles (static; verify setters are not in separate if/else branches)`,
|
|
313
|
+
suggestion: `If all listed setters fire unconditionally in the same handler path, wrap in batch(() => { ${setterNames.join('; ')}; }) to collapse ${distinct.size} cycles into 1`,
|
|
314
|
+
loc: event.loc.file ? { file: event.loc.file, line: event.loc.start.line } : undefined,
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 4. Fallback-heavy
|
|
320
|
+
if (
|
|
321
|
+
metrics.dynamicBindings > 0 &&
|
|
322
|
+
metrics.fallbacks >= THRESHOLDS.fallbackHeavyMin &&
|
|
323
|
+
metrics.fallbacks / metrics.dynamicBindings > THRESHOLDS.fallbackHeavyRatio
|
|
324
|
+
) {
|
|
325
|
+
findings.push({
|
|
326
|
+
kind: 'fallback-heavy',
|
|
327
|
+
severity: 'info',
|
|
328
|
+
message: `${metrics.fallbacks}/${metrics.dynamicBindings} bindings (${Math.round(metrics.fallbacks / metrics.dynamicBindings * 100)}%) are fallback-wrapped — reactivity not statically provable`,
|
|
329
|
+
suggestion: 'Run `bf debug fallbacks` to see each expression and fix them so the compiler can prove reactivity without the fallback wrapper',
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return findings
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// =============================================================================
|
|
337
|
+
// SR6: Compile-diff regression detection
|
|
338
|
+
// =============================================================================
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Compare two profile metric snapshots.
|
|
342
|
+
* Returns regressions (metrics that got worse), improvements, and neutral changes.
|
|
343
|
+
*
|
|
344
|
+
* "Worse" metrics when they increase: fallbacks, maxSignalFanOut,
|
|
345
|
+
* maxMemoChainDepth, totalSubscriptions, batchCandidateCount.
|
|
346
|
+
* All other metric changes (signal/memo/binding counts) are neutral (structural).
|
|
347
|
+
*/
|
|
348
|
+
export function diffProfiles(
|
|
349
|
+
before: ComponentProfileMetrics,
|
|
350
|
+
after: ComponentProfileMetrics,
|
|
351
|
+
): ProfileDiff {
|
|
352
|
+
const worseWhenHigher = new Set<keyof ComponentProfileMetrics>([
|
|
353
|
+
'fallbacks',
|
|
354
|
+
'maxSignalFanOut',
|
|
355
|
+
'maxMemoChainDepth',
|
|
356
|
+
'totalSubscriptions',
|
|
357
|
+
'batchCandidateCount',
|
|
358
|
+
])
|
|
359
|
+
|
|
360
|
+
const numericKeys: Array<keyof ComponentProfileMetrics> = [
|
|
361
|
+
'signals', 'memos', 'effects', 'loops', 'eventHandlers',
|
|
362
|
+
'dynamicBindings', 'fallbacks', 'conditionals',
|
|
363
|
+
'maxSignalFanOut', 'maxMemoChainDepth', 'totalSubscriptions', 'batchCandidateCount',
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
const regressions: ProfileDiffEntry[] = []
|
|
367
|
+
const improvements: ProfileDiffEntry[] = []
|
|
368
|
+
const neutral: ProfileDiffEntry[] = []
|
|
369
|
+
|
|
370
|
+
for (const key of numericKeys) {
|
|
371
|
+
const b = before[key] as number
|
|
372
|
+
const a = after[key] as number
|
|
373
|
+
if (a === b) continue
|
|
374
|
+
const entry: ProfileDiffEntry = { metric: key, before: b, after: a, delta: a - b }
|
|
375
|
+
if (worseWhenHigher.has(key)) {
|
|
376
|
+
if (a > b) regressions.push(entry)
|
|
377
|
+
else improvements.push(entry)
|
|
378
|
+
} else {
|
|
379
|
+
neutral.push(entry)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return { componentName: after.componentName, before, after, regressions, improvements, neutral }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// =============================================================================
|
|
387
|
+
// Formatting: human-readable output
|
|
388
|
+
// =============================================================================
|
|
389
|
+
|
|
390
|
+
/** Format a single component profile for `bf debug profile <component>`. */
|
|
391
|
+
export function formatSingleProfile(profile: ComponentProfile): string {
|
|
392
|
+
const m = profile.metrics
|
|
393
|
+
const lines: string[] = []
|
|
394
|
+
|
|
395
|
+
lines.push(`${m.componentName} — reactive profile (static)`)
|
|
396
|
+
if (m.sourceFile) lines.push(` source: ${m.sourceFile}`)
|
|
397
|
+
lines.push(` hydrated: ${m.hydrated ? 'yes' : 'no'}`)
|
|
398
|
+
|
|
399
|
+
lines.push('')
|
|
400
|
+
lines.push(' Counts:')
|
|
401
|
+
lines.push(` signals: ${m.signals}`)
|
|
402
|
+
lines.push(` memos: ${m.memos}`)
|
|
403
|
+
if (m.effects > 0) lines.push(` effects: ${m.effects}`)
|
|
404
|
+
lines.push(` dynamic bindings: ${m.dynamicBindings}`)
|
|
405
|
+
if (m.fallbacks > 0) lines.push(` fallbacks: ${m.fallbacks}`)
|
|
406
|
+
if (m.loops > 0) lines.push(` loops: ${m.loops}`)
|
|
407
|
+
if (m.conditionals > 0) lines.push(` conditionals: ${m.conditionals}`)
|
|
408
|
+
if (m.eventHandlers > 0) lines.push(` event handlers: ${m.eventHandlers}`)
|
|
409
|
+
|
|
410
|
+
lines.push('')
|
|
411
|
+
lines.push(' Reactive budget (SR5):')
|
|
412
|
+
const fanOutSuffix = m.hotSignal ? ` (${m.hotSignal})` : ''
|
|
413
|
+
lines.push(` max signal fan-out: ${m.maxSignalFanOut}${fanOutSuffix}`)
|
|
414
|
+
lines.push(` max memo chain depth: ${m.maxMemoChainDepth}`)
|
|
415
|
+
lines.push(` total subscriptions: ${m.totalSubscriptions}`)
|
|
416
|
+
if (m.batchCandidateCount > 0) {
|
|
417
|
+
lines.push(` batch candidates: ${m.batchCandidateCount} handler(s) set ≥2 signals`)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (profile.findings.length > 0) {
|
|
421
|
+
lines.push('')
|
|
422
|
+
lines.push(' Findings:')
|
|
423
|
+
for (const f of profile.findings) {
|
|
424
|
+
const icon = f.severity === 'warning' ? '⚠' : '→'
|
|
425
|
+
lines.push(` ${icon} [${f.kind}] ${f.message}`)
|
|
426
|
+
lines.push(` fix: ${f.suggestion}`)
|
|
427
|
+
if (f.loc) {
|
|
428
|
+
const file = f.loc.file.split('/').pop() ?? f.loc.file
|
|
429
|
+
lines.push(` at ${file}:${f.loc.line}`)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
} else {
|
|
433
|
+
lines.push('')
|
|
434
|
+
lines.push(' No findings — component is within all thresholds.')
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return lines.join('\n')
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Format a multi-component profile table for `--scenario auto`.
|
|
442
|
+
* Sorted by totalSubscriptions descending (highest reactive cost first).
|
|
443
|
+
*/
|
|
444
|
+
export function formatProfileTable(profiles: ComponentProfile[]): string {
|
|
445
|
+
if (profiles.length === 0) return 'No components found.'
|
|
446
|
+
|
|
447
|
+
const sorted = [...profiles].sort(
|
|
448
|
+
(a, b) => b.metrics.totalSubscriptions - a.metrics.totalSubscriptions,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
const lines: string[] = []
|
|
452
|
+
lines.push('Component sig memo bind fall fanOut chain subs batch findings')
|
|
453
|
+
lines.push('─'.repeat(90))
|
|
454
|
+
|
|
455
|
+
for (const p of sorted) {
|
|
456
|
+
const m = p.metrics
|
|
457
|
+
const name = m.componentName.padEnd(23).slice(0, 23)
|
|
458
|
+
const findingStr = p.findings.length > 0
|
|
459
|
+
? p.findings.map(f => f.kind.replace(/-/g, '_')).join(',')
|
|
460
|
+
: '—'
|
|
461
|
+
const row = [
|
|
462
|
+
name,
|
|
463
|
+
String(m.signals).padStart(3),
|
|
464
|
+
String(m.memos).padStart(5),
|
|
465
|
+
String(m.dynamicBindings).padStart(5),
|
|
466
|
+
String(m.fallbacks).padStart(5),
|
|
467
|
+
String(m.maxSignalFanOut).padStart(7),
|
|
468
|
+
String(m.maxMemoChainDepth).padStart(6),
|
|
469
|
+
String(m.totalSubscriptions).padStart(5),
|
|
470
|
+
String(m.batchCandidateCount).padStart(6),
|
|
471
|
+
` ${findingStr}`,
|
|
472
|
+
].join(' ')
|
|
473
|
+
lines.push(row)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Findings detail section
|
|
477
|
+
const allFindings = sorted.flatMap(p =>
|
|
478
|
+
p.findings.map(f => ({ component: p.metrics.componentName, finding: f })),
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
if (allFindings.length > 0) {
|
|
482
|
+
lines.push('')
|
|
483
|
+
lines.push('Findings:')
|
|
484
|
+
for (const { component, finding } of allFindings) {
|
|
485
|
+
const icon = finding.severity === 'warning' ? '⚠' : '→'
|
|
486
|
+
lines.push(` ${icon} ${component}: ${finding.message}`)
|
|
487
|
+
lines.push(` fix: ${finding.suggestion}`)
|
|
488
|
+
if (finding.loc) {
|
|
489
|
+
const file = finding.loc.file.split('/').pop() ?? finding.loc.file
|
|
490
|
+
lines.push(` at ${file}:${finding.loc.line}`)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
} else {
|
|
494
|
+
lines.push('')
|
|
495
|
+
lines.push('No findings across all components.')
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return lines.join('\n')
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Format a profile diff for `--diff`. */
|
|
502
|
+
export function formatProfileDiff(diff: ProfileDiff): string {
|
|
503
|
+
const lines: string[] = []
|
|
504
|
+
lines.push(`${diff.componentName} — reactive profile diff (before → after)`)
|
|
505
|
+
lines.push('')
|
|
506
|
+
|
|
507
|
+
if (diff.regressions.length === 0 && diff.improvements.length === 0 && diff.neutral.length === 0) {
|
|
508
|
+
lines.push(' No changes in reactive metrics.')
|
|
509
|
+
return lines.join('\n')
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (diff.regressions.length > 0) {
|
|
513
|
+
lines.push(' Regressions (reactive cost increased):')
|
|
514
|
+
for (const e of diff.regressions) {
|
|
515
|
+
lines.push(` ${e.metric}: ${e.before} → ${e.after} (+${e.delta})`)
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (diff.improvements.length > 0) {
|
|
520
|
+
lines.push(' Improvements (reactive cost decreased):')
|
|
521
|
+
for (const e of diff.improvements) {
|
|
522
|
+
lines.push(` ${e.metric}: ${e.before} → ${e.after} (${e.delta})`)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (diff.neutral.length > 0) {
|
|
527
|
+
lines.push(' Structural changes (count changes, no clear direction):')
|
|
528
|
+
for (const e of diff.neutral) {
|
|
529
|
+
const sign = (e.delta ?? 0) > 0 ? '+' : ''
|
|
530
|
+
lines.push(` ${e.metric}: ${e.before} → ${e.after} (${sign}${e.delta})`)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return lines.join('\n')
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Serialize a single profile to a JSON-safe object. */
|
|
538
|
+
export function profileToJSON(profile: ComponentProfile): object {
|
|
539
|
+
return {
|
|
540
|
+
metrics: profile.metrics,
|
|
541
|
+
findings: profile.findings,
|
|
542
|
+
}
|
|
543
|
+
}
|