@barefootjs/jsx 0.10.1 → 0.12.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 +1881 -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 +502 -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 +466 -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 +1520 -0
- package/src/types.ts +8 -0
package/src/profiler.ts
ADDED
|
@@ -0,0 +1,1520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive performance profiler — static half (SR5 / SR6).
|
|
3
|
+
*
|
|
4
|
+
* Background: issue #1690. User-facing docs live in `bf debug profile --help`
|
|
5
|
+
* (the design doc that used to live in `spec/profiler.md` was removed to avoid
|
|
6
|
+
* drift — the CLI help is the single source of truth). This module implements
|
|
7
|
+
* the **run-free** parts of `bf debug profile`: the static reactivity budget
|
|
8
|
+
* (SR5) and the compile-diff regression (SR6). Both are pure functions of
|
|
9
|
+
* the IR — no instrumented run is required — so they reuse the static
|
|
10
|
+
* analysis already shipped in `debug.ts`:
|
|
11
|
+
*
|
|
12
|
+
* - `buildComponentAnalysis` → `{ graph, ir }`
|
|
13
|
+
* - `buildComponentSummary` → node counts
|
|
14
|
+
* - `traceUpdatePath` → transitive dependents (fan-out + chain depth)
|
|
15
|
+
*
|
|
16
|
+
* The dynamic half (SR1–SR4: instrumented runtime, turn markers, IR join,
|
|
17
|
+
* Hot-subscribers / Wasted-re-runs / Batch-advisor analyses) is assembled by
|
|
18
|
+
* `buildProfileReport` from a recorded SR2 event stream — see those analyses
|
|
19
|
+
* below.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import ts from 'typescript'
|
|
23
|
+
import {
|
|
24
|
+
buildComponentAnalysis,
|
|
25
|
+
buildComponentSummary,
|
|
26
|
+
buildEventSummary,
|
|
27
|
+
traceUpdatePath,
|
|
28
|
+
type ComponentGraph,
|
|
29
|
+
type EventBinding,
|
|
30
|
+
type UpdatePathEntry,
|
|
31
|
+
} from './debug.ts'
|
|
32
|
+
import { listComponentFunctions, createProgramForFile } from './analyzer.ts'
|
|
33
|
+
import type { ProfilerEvent } from '@barefootjs/shared'
|
|
34
|
+
|
|
35
|
+
// -- Static budget (SR5) ------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export interface FanOutEntry {
|
|
38
|
+
/** Signal name (the reactive source whose change fans out). */
|
|
39
|
+
signal: string
|
|
40
|
+
/** Distinct transitive subscribers (memos + effects + DOM bindings). */
|
|
41
|
+
subscribers: number
|
|
42
|
+
/**
|
|
43
|
+
* Distinct subscribers that read the signal *directly* (not through a memo).
|
|
44
|
+
* This is the real per-write re-run pressure: a direct write re-runs exactly
|
|
45
|
+
* these. The remaining `subscribers − direct` sit behind a memo barrier and
|
|
46
|
+
* only re-run when that memo's value actually changes — so adding a memo
|
|
47
|
+
* barrier *lowers* `direct` while it can *raise* `subscribers` (more nodes
|
|
48
|
+
* become statically attributable). Read `direct`, not `subscribers`, to judge
|
|
49
|
+
* cost.
|
|
50
|
+
*/
|
|
51
|
+
direct: number
|
|
52
|
+
/** True when *direct* fan-out (not the transitive total) meets the threshold. */
|
|
53
|
+
hot: boolean
|
|
54
|
+
loc: { file: string; line: number }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface StaticBudget {
|
|
58
|
+
componentName: string
|
|
59
|
+
sourceFile: string
|
|
60
|
+
kind: 'static-budget'
|
|
61
|
+
signals: number
|
|
62
|
+
memos: number
|
|
63
|
+
effects: number
|
|
64
|
+
loops: number
|
|
65
|
+
/** Σ over signals of `consumers.length` — total reactive subscriptions. */
|
|
66
|
+
subscriptions: number
|
|
67
|
+
/** Longest memo→memo dependency path length (0 = no memos). */
|
|
68
|
+
memoChainDepth: number
|
|
69
|
+
/** The memo names forming `memoChainDepth`, head-first. */
|
|
70
|
+
memoChainLongest: string[]
|
|
71
|
+
/** Per-signal fan-out, descending, hottest first. */
|
|
72
|
+
fanOut: FanOutEntry[]
|
|
73
|
+
/**
|
|
74
|
+
* True when the component declares reactive state (signals/memos) but nothing
|
|
75
|
+
* in *this* component subscribes to it — every consumer is in a composed child
|
|
76
|
+
* (a compound component like `Select`/`Combobox`), or the state is only read
|
|
77
|
+
* from event handlers. The single-component static budget can't see across the
|
|
78
|
+
* composition boundary, so its `subscriptions`/fan-out read 0 and would
|
|
79
|
+
* otherwise look misleadingly "free". `--scenario` measures across components.
|
|
80
|
+
*/
|
|
81
|
+
crossComponentOnly: boolean
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface StaticBudgetOptions {
|
|
85
|
+
/** Fan-out threshold above which a signal is flagged `hot`. Default 8. */
|
|
86
|
+
fanOutThreshold?: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const DEFAULT_FANOUT_THRESHOLD = 8
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the static reactivity budget for one component (SR5).
|
|
93
|
+
*
|
|
94
|
+
* Predictive only — it names likely hot spots before any run. Pair with
|
|
95
|
+
* `--scenario` (the dynamic half) to confirm actual cost.
|
|
96
|
+
*/
|
|
97
|
+
export function buildStaticBudget(
|
|
98
|
+
source: string,
|
|
99
|
+
filePath: string,
|
|
100
|
+
componentName?: string,
|
|
101
|
+
options: StaticBudgetOptions = {},
|
|
102
|
+
): StaticBudget {
|
|
103
|
+
const threshold = options.fanOutThreshold ?? DEFAULT_FANOUT_THRESHOLD
|
|
104
|
+
// Build the TS program once and reuse it for both analyses (re-parsing the
|
|
105
|
+
// file twice is the bulk of the cost on a large source).
|
|
106
|
+
const program = createProgramForFile(source, filePath)?.program
|
|
107
|
+
const { graph } = buildComponentAnalysis(source, filePath, componentName, program)
|
|
108
|
+
// `graph` is the authority for reactive-node counts; the summary is consulted
|
|
109
|
+
// only for `loops`, which needs the IR tree walk (`countNodeType`) that the
|
|
110
|
+
// graph doesn't expose.
|
|
111
|
+
const summary = buildComponentSummary(source, filePath, componentName, program)
|
|
112
|
+
|
|
113
|
+
const subscriptions = graph.signals.reduce(
|
|
114
|
+
(n, s) => n + s.consumers.filter(c => !isEventHandlerConsumer(c)).length,
|
|
115
|
+
0,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const fanOut: FanOutEntry[] = graph.signals
|
|
119
|
+
.map(s => {
|
|
120
|
+
const { direct, total } = subscriberCounts(graph, s.name)
|
|
121
|
+
// `hot` keys off *direct* fan-out — a signal driving 12 nodes through one
|
|
122
|
+
// memo (direct 1) isn't hot; one driving 12 nodes directly is.
|
|
123
|
+
return { signal: s.name, subscribers: total, direct, hot: direct >= threshold, loc: s.loc }
|
|
124
|
+
})
|
|
125
|
+
.sort((a, b) => b.subscribers - a.subscribers)
|
|
126
|
+
|
|
127
|
+
const { depth, chain } = longestMemoChain(graph)
|
|
128
|
+
|
|
129
|
+
// Reactive state exists, yet nothing in *this* component observes it. The
|
|
130
|
+
// check spans signals AND memos: a memo with an in-component consumer (e.g.
|
|
131
|
+
// memo → DOM binding, with the memo reading a prop rather than a signal) is a
|
|
132
|
+
// real subscriber even though `subscriptions`/signal-`fanOut` — both
|
|
133
|
+
// signal-derived — read 0. So a node is "observed in-component" iff it has a
|
|
134
|
+
// non-empty transitive dependent tree; if none of the signals or memos do, the
|
|
135
|
+
// consumers live across a composition boundary (compound component) — flag it
|
|
136
|
+
// so the 0s don't read as "free".
|
|
137
|
+
const hasReactiveState = graph.signals.length > 0 || graph.memos.length > 0
|
|
138
|
+
const observedInComponent =
|
|
139
|
+
graph.signals.some(s => transitiveSubscriberCount(graph, s.name) > 0) ||
|
|
140
|
+
graph.memos.some(m => transitiveSubscriberCount(graph, m.name) > 0)
|
|
141
|
+
const crossComponentOnly = hasReactiveState && !observedInComponent
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
componentName: summary.componentName,
|
|
145
|
+
sourceFile: summary.sourceFile,
|
|
146
|
+
kind: 'static-budget',
|
|
147
|
+
signals: graph.signals.length,
|
|
148
|
+
memos: graph.memos.length,
|
|
149
|
+
effects: graph.effects.length,
|
|
150
|
+
loops: summary.loops,
|
|
151
|
+
subscriptions,
|
|
152
|
+
memoChainDepth: depth,
|
|
153
|
+
memoChainLongest: chain,
|
|
154
|
+
fanOut,
|
|
155
|
+
crossComponentOnly,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* An event handler *reads* a signal (e.g. `setCount(count() + 1)`) but runs
|
|
161
|
+
* outside any reactive scope, so it does not re-run when the signal changes —
|
|
162
|
+
* it is not a reactive subscriber and must be excluded from fan-out /
|
|
163
|
+
* subscription counts (cross-checked against the dynamic run, #1690 §4.2.4).
|
|
164
|
+
*/
|
|
165
|
+
function isEventHandlerEntry(kind: string, name: string): boolean {
|
|
166
|
+
return kind === 'dom' && /^\w+ handler "/.test(name)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** As above, for the `kind:name` consumer strings on `SignalNode.consumers`. */
|
|
170
|
+
function isEventHandlerConsumer(consumer: string): boolean {
|
|
171
|
+
const i = consumer.indexOf(':')
|
|
172
|
+
return i > 0 && isEventHandlerEntry(consumer.slice(0, i), consumer.slice(i + 1))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Subscriber counts for a signal/memo: `direct` (read it without a memo in
|
|
177
|
+
* between — the top level of the dependent tree) and `total` (every distinct
|
|
178
|
+
* transitive subscriber). Walks the same tagged `consumers` tree
|
|
179
|
+
* `traceUpdatePath` builds (`debug.ts`), deduplicating across branches so a
|
|
180
|
+
* diamond dependency counts each subscriber once. Event handlers are excluded —
|
|
181
|
+
* they read but don't react. `total − direct` are the nodes reached only
|
|
182
|
+
* through a memo barrier.
|
|
183
|
+
*/
|
|
184
|
+
function subscriberCounts(graph: ComponentGraph, name: string): { direct: number; total: number } {
|
|
185
|
+
const path = traceUpdatePath(graph, name)
|
|
186
|
+
if (!path) return { direct: 0, total: 0 }
|
|
187
|
+
const seen = new Set<string>()
|
|
188
|
+
const directSeen = new Set<string>()
|
|
189
|
+
const walk = (entries: UpdatePathEntry[], depth: number): void => {
|
|
190
|
+
for (const e of entries) {
|
|
191
|
+
if (!isEventHandlerEntry(e.kind, e.name)) {
|
|
192
|
+
seen.add(`${e.kind}:${e.name}`)
|
|
193
|
+
if (depth === 0) directSeen.add(`${e.kind}:${e.name}`)
|
|
194
|
+
}
|
|
195
|
+
walk(e.children, depth + 1)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
walk(path.dependents, 0)
|
|
199
|
+
return { direct: directSeen.size, total: seen.size }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Distinct transitive subscribers — the `total` of {@link subscriberCounts}. */
|
|
203
|
+
function transitiveSubscriberCount(graph: ComponentGraph, name: string): number {
|
|
204
|
+
return subscriberCounts(graph, name).total
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Longest chain of memo→memo dependencies across the whole component. Starting
|
|
209
|
+
* from every memo (so a head memo whose deps are signals seeds the full chain)
|
|
210
|
+
* and taking the max captures the true longest path.
|
|
211
|
+
*/
|
|
212
|
+
function longestMemoChain(graph: ComponentGraph): { depth: number; chain: string[] } {
|
|
213
|
+
const memoChainFrom = (entry: UpdatePathEntry): string[] => {
|
|
214
|
+
if (entry.kind !== 'memo') return []
|
|
215
|
+
let best: string[] = []
|
|
216
|
+
for (const child of entry.children) {
|
|
217
|
+
const c = memoChainFrom(child)
|
|
218
|
+
if (c.length > best.length) best = c
|
|
219
|
+
}
|
|
220
|
+
return [entry.name, ...best]
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let best: string[] = []
|
|
224
|
+
for (const memo of graph.memos) {
|
|
225
|
+
const path = traceUpdatePath(graph, memo.name)
|
|
226
|
+
let downstream: string[] = []
|
|
227
|
+
for (const dep of path?.dependents ?? []) {
|
|
228
|
+
const c = memoChainFrom(dep)
|
|
229
|
+
if (c.length > downstream.length) downstream = c
|
|
230
|
+
}
|
|
231
|
+
const chain = [memo.name, ...downstream]
|
|
232
|
+
if (chain.length > best.length) best = chain
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { depth: best.length, chain: best }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function formatStaticBudget(b: StaticBudget): string {
|
|
239
|
+
const lines: string[] = []
|
|
240
|
+
lines.push(`${b.componentName} — static reactivity budget`)
|
|
241
|
+
lines.push(` signals: ${b.signals} memos: ${b.memos} effects: ${b.effects} loops: ${b.loops}`)
|
|
242
|
+
lines.push(` subscriptions: ${b.subscriptions}`)
|
|
243
|
+
if (b.memoChainDepth > 0) {
|
|
244
|
+
lines.push(` memo-chain depth: ${b.memoChainDepth} (${b.memoChainLongest.join(' → ')})`)
|
|
245
|
+
}
|
|
246
|
+
const shown = b.fanOut.filter(f => f.subscribers > 0).slice(0, 5)
|
|
247
|
+
if (shown.length > 0) {
|
|
248
|
+
lines.push(' fan-out (top):')
|
|
249
|
+
for (const f of shown) {
|
|
250
|
+
// Show the direct/indirect split only when a memo barrier routes some of
|
|
251
|
+
// the subscribers — otherwise the bare count is already the direct count.
|
|
252
|
+
const indirect = f.subscribers - f.direct
|
|
253
|
+
const detail = indirect > 0 ? ` (${f.direct} direct · ${indirect} via memo)` : ''
|
|
254
|
+
lines.push(` ${f.signal.padEnd(12)} → ${f.subscribers} subscribers${detail}${f.hot ? ' ⚠ high' : ''}`)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (b.crossComponentOnly) {
|
|
258
|
+
lines.push(
|
|
259
|
+
` ⓘ compound: ${b.signals} signal(s) / ${b.memos} memo(s) but 0 in-component subscriptions —`,
|
|
260
|
+
)
|
|
261
|
+
lines.push(' consumers are likely in composed child components (or it is read only from handlers);')
|
|
262
|
+
lines.push(' run with --scenario to measure across the composition boundary.')
|
|
263
|
+
}
|
|
264
|
+
lines.push(' note: run with --scenario to measure actual cost; static budget is predictive only.')
|
|
265
|
+
return lines.join('\n')
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// -- Compile-diff regression (SR6) --------------------------------------------
|
|
269
|
+
|
|
270
|
+
export interface FanOutChange {
|
|
271
|
+
signal: string
|
|
272
|
+
before: number
|
|
273
|
+
after: number
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface BudgetDiff {
|
|
277
|
+
/**
|
|
278
|
+
* Discriminator so a JSON consumer can tell the three `bf debug profile`
|
|
279
|
+
* modes apart (`static-budget` / `profile` / `diff`). Without it an all-zero
|
|
280
|
+
* diff (no structural change) was indistinguishable from a pure-static
|
|
281
|
+
* component with no reactive state (#1849 B2).
|
|
282
|
+
*/
|
|
283
|
+
kind: 'diff'
|
|
284
|
+
componentName: string
|
|
285
|
+
signals: number
|
|
286
|
+
memos: number
|
|
287
|
+
effects: number
|
|
288
|
+
loops: number
|
|
289
|
+
subscriptions: number
|
|
290
|
+
memoChainDepth: number
|
|
291
|
+
/** Signals whose *direct* fan-out changed (added/removed signals as 0↔n). */
|
|
292
|
+
fanOut: FanOutChange[]
|
|
293
|
+
/** True when any tracked metric regressed (grew) past zero. */
|
|
294
|
+
regressed: boolean
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Structural reactivity delta between two compiles of the same component (SR6).
|
|
299
|
+
* Each numeric field is `after − before`; positive = grew. CI can fail when
|
|
300
|
+
* `regressed` is true (or gate on a specific metric threshold).
|
|
301
|
+
*/
|
|
302
|
+
export function diffStaticBudget(base: StaticBudget, head: StaticBudget): BudgetDiff {
|
|
303
|
+
// Track *direct* fan-out: a refactor that routes subscribers through a new
|
|
304
|
+
// memo lowers direct fan-out (less re-run pressure) even as it can raise the
|
|
305
|
+
// transitive total, so a direct-fan-out delta reads such a change as the
|
|
306
|
+
// improvement it is instead of a false regression.
|
|
307
|
+
const baseFan = new Map(base.fanOut.map(f => [f.signal, f.direct]))
|
|
308
|
+
const headFan = new Map(head.fanOut.map(f => [f.signal, f.direct]))
|
|
309
|
+
const signals = new Set([...baseFan.keys(), ...headFan.keys()])
|
|
310
|
+
|
|
311
|
+
const fanOut: FanOutChange[] = []
|
|
312
|
+
for (const sig of signals) {
|
|
313
|
+
const before = baseFan.get(sig) ?? 0
|
|
314
|
+
const after = headFan.get(sig) ?? 0
|
|
315
|
+
if (before !== after) fanOut.push({ signal: sig, before, after })
|
|
316
|
+
}
|
|
317
|
+
fanOut.sort((a, b) => (b.after - b.before) - (a.after - a.before))
|
|
318
|
+
|
|
319
|
+
const d: Omit<BudgetDiff, 'regressed'> = {
|
|
320
|
+
kind: 'diff',
|
|
321
|
+
componentName: head.componentName,
|
|
322
|
+
signals: head.signals - base.signals,
|
|
323
|
+
memos: head.memos - base.memos,
|
|
324
|
+
effects: head.effects - base.effects,
|
|
325
|
+
loops: head.loops - base.loops,
|
|
326
|
+
subscriptions: head.subscriptions - base.subscriptions,
|
|
327
|
+
memoChainDepth: head.memoChainDepth - base.memoChainDepth,
|
|
328
|
+
fanOut,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const regressed =
|
|
332
|
+
d.signals > 0 || d.memos > 0 || d.effects > 0 || d.subscriptions > 0 ||
|
|
333
|
+
d.memoChainDepth > 0 || fanOut.some(f => f.after > f.before)
|
|
334
|
+
|
|
335
|
+
return { ...d, regressed }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function formatBudgetDiff(d: BudgetDiff): string {
|
|
339
|
+
const lines: string[] = []
|
|
340
|
+
lines.push(`${d.componentName} — reactivity diff`)
|
|
341
|
+
const metric = (label: string, v: number) => {
|
|
342
|
+
if (v !== 0) lines.push(` ${v > 0 ? '+' : ''}${v} ${label}`)
|
|
343
|
+
}
|
|
344
|
+
metric('signals', d.signals)
|
|
345
|
+
metric('memos', d.memos)
|
|
346
|
+
metric('effects', d.effects)
|
|
347
|
+
metric('loops', d.loops)
|
|
348
|
+
metric('subscriptions', d.subscriptions)
|
|
349
|
+
if (d.memoChainDepth !== 0) {
|
|
350
|
+
lines.push(` memo chain ${d.memoChainDepth > 0 ? 'deepened' : 'shortened'} by ${Math.abs(d.memoChainDepth)}`)
|
|
351
|
+
}
|
|
352
|
+
for (const f of d.fanOut) {
|
|
353
|
+
lines.push(` signal \`${f.signal}\` direct fan-out ${f.before}→${f.after}`)
|
|
354
|
+
}
|
|
355
|
+
if (lines.length === 1) lines.push(' no structural reactivity change')
|
|
356
|
+
else lines.push(d.regressed ? ' ⚠ reactivity regressed' : ' ✓ no regression')
|
|
357
|
+
return lines.join('\n')
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// -- SR4 IR join --------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
/** An IR node a compiler-assigned profiler id resolves to. */
|
|
363
|
+
export interface ResolvedNode {
|
|
364
|
+
kind: 'signal' | 'memo' | 'effect' | 'handler'
|
|
365
|
+
/** Display name/label of the resolved IR node. */
|
|
366
|
+
name: string
|
|
367
|
+
loc: { file: string; line: number }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** id (`<Component>#<kind>:<rest>`) → resolved IR node. */
|
|
371
|
+
export type IdIndex = Map<string, ResolvedNode>
|
|
372
|
+
|
|
373
|
+
export interface ParsedProfilerId {
|
|
374
|
+
component: string
|
|
375
|
+
kind: string
|
|
376
|
+
/** Everything after `<kind>:` — a name, a line, or `controlled:<setter>`. */
|
|
377
|
+
rest: string
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Parse a compiler-assigned profiler id `<Component>#<kind>:<rest>` (SR1/SR3).
|
|
382
|
+
* Returns `null` for anything that isn't shaped like one, so a stray id in the
|
|
383
|
+
* event stream is surfaced as a coverage gap rather than mis-parsed.
|
|
384
|
+
*/
|
|
385
|
+
export function parseProfilerId(id: string): ParsedProfilerId | null {
|
|
386
|
+
const hash = id.indexOf('#')
|
|
387
|
+
if (hash < 0) return null
|
|
388
|
+
const colon = id.indexOf(':', hash)
|
|
389
|
+
if (colon < 0) return null
|
|
390
|
+
const component = id.slice(0, hash)
|
|
391
|
+
const kind = id.slice(hash + 1, colon)
|
|
392
|
+
const rest = id.slice(colon + 1)
|
|
393
|
+
if (!component || !kind || !rest) return null
|
|
394
|
+
return { component, kind, rest }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Build the id→IR-node index that the SR4 join keys on. Every graph node
|
|
399
|
+
* already carries `loc`, so this turns a raw runtime id into a source-mapped
|
|
400
|
+
* node. Effects are registered under both their source line and their label
|
|
401
|
+
* (the two shapes `effects-and-on-mounts.ts` emits), and controlled-signal
|
|
402
|
+
* sync effects under `effect:controlled:<setter>`, mirroring the compiler's
|
|
403
|
+
* id namespace (`build-declaration-emit.ts`).
|
|
404
|
+
*/
|
|
405
|
+
export function buildIdIndex(graph: ComponentGraph): IdIndex {
|
|
406
|
+
const comp = graph.componentName
|
|
407
|
+
const index: IdIndex = new Map()
|
|
408
|
+
|
|
409
|
+
for (const s of graph.signals) {
|
|
410
|
+
index.set(`${comp}#signal:${s.name}`, { kind: 'signal', name: s.name, loc: s.loc })
|
|
411
|
+
if (s.setter) {
|
|
412
|
+
index.set(`${comp}#effect:controlled:${s.setter}`, {
|
|
413
|
+
kind: 'effect',
|
|
414
|
+
name: `controlled:${s.setter}`,
|
|
415
|
+
loc: s.loc,
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
for (const m of graph.memos) {
|
|
420
|
+
index.set(`${comp}#memo:${m.name}`, { kind: 'memo', name: m.name, loc: m.loc })
|
|
421
|
+
}
|
|
422
|
+
for (const e of graph.effects) {
|
|
423
|
+
const node: ResolvedNode = { kind: 'effect', name: e.label, loc: e.loc }
|
|
424
|
+
index.set(`${comp}#effect:${e.loc.line}`, node)
|
|
425
|
+
index.set(`${comp}#effect:${e.label}`, node)
|
|
426
|
+
}
|
|
427
|
+
// DOM-binding effects (#1690, SR3/SR4): text/attribute/conditional/loop
|
|
428
|
+
// updates emit `createEffect(…, "<Component>#binding:<slotId>")`. Resolve
|
|
429
|
+
// each from its `domBinding` (slotId + loc). Event bindings are handlers, not
|
|
430
|
+
// re-running effects — skip them.
|
|
431
|
+
for (const b of graph.domBindings) {
|
|
432
|
+
if (!b.loc) continue
|
|
433
|
+
const loc = { file: b.loc.file, line: b.loc.start.line }
|
|
434
|
+
if (b.type === 'event') {
|
|
435
|
+
// Handler turn ids (`<Component>#handler:<slotId>:<eventName>`, SR3). The
|
|
436
|
+
// event name is embedded in the label (`click handler "s1"`).
|
|
437
|
+
const eventName = b.label.match(/^(\w+)\s+handler/)?.[1]
|
|
438
|
+
if (eventName) {
|
|
439
|
+
index.set(`${comp}#handler:${b.slotId}:${eventName}`, {
|
|
440
|
+
kind: 'handler',
|
|
441
|
+
name: `${eventName}@${b.slotId}`,
|
|
442
|
+
loc,
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
index.set(`${comp}#binding:${b.slotId}`, { kind: 'effect', name: `${b.slotId} (${b.type})`, loc })
|
|
448
|
+
}
|
|
449
|
+
return index
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** A profiler id that no IR node could be found for (SR4 coverage gap). */
|
|
453
|
+
export interface UnattributedId {
|
|
454
|
+
id: string
|
|
455
|
+
/** Distinct events that referenced this id. */
|
|
456
|
+
count: number
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export interface JoinedEvent {
|
|
460
|
+
event: ProfilerEvent
|
|
461
|
+
/** Resolved node for `event.subscriber` (effect/memo), if any. */
|
|
462
|
+
subscriber?: ResolvedNode
|
|
463
|
+
/** Resolved node for `event.signal`, if any. */
|
|
464
|
+
signal?: ResolvedNode
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export interface JoinResult {
|
|
468
|
+
joined: JoinedEvent[]
|
|
469
|
+
/**
|
|
470
|
+
* Actionable coverage gaps: ids shaped like a compiler profiler id
|
|
471
|
+
* (`<Component>#<kind>:<rest>`) that the IR did not resolve — surfaced, never
|
|
472
|
+
* dropped (SR4 invariant). A non-empty list is the honest coverage caveat the
|
|
473
|
+
* analyses print: something the IR *should* have explained but didn't.
|
|
474
|
+
*/
|
|
475
|
+
unattributed: UnattributedId[]
|
|
476
|
+
/**
|
|
477
|
+
* Non-actionable runtime bookkeeping ids (`s9`, `e3`, `m7`, `r10`) — anonymous
|
|
478
|
+
* reactive nodes the compiler never named (see `isRuntimeBookkeepingId`).
|
|
479
|
+
* Kept separate from `unattributed` so the coverage report focuses on real,
|
|
480
|
+
* fixable gaps instead of internal slot/ref noise, but still surfaced (never
|
|
481
|
+
* dropped) so the SR4 "account for every id" invariant holds.
|
|
482
|
+
*/
|
|
483
|
+
diagnostics: UnattributedId[]
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* True for the reactive runtime's *fallback* ids — the synthetic `s<n>`
|
|
488
|
+
* (signal), `e<n>` (effect), `m<n>` (memo), and `r<n>` (root) sequences
|
|
489
|
+
* `reactive.ts` assigns when a node carries no compiler `__bfId`. These name
|
|
490
|
+
* anonymous internal machinery, never a source location, so they can never be
|
|
491
|
+
* attributed to an IR node. Treating them as coverage gaps makes a healthy
|
|
492
|
+
* report look broken (#1840); they belong in the non-actionable diagnostics
|
|
493
|
+
* bucket instead. Compiler ids always carry a `#`, so they never match.
|
|
494
|
+
*/
|
|
495
|
+
function isRuntimeBookkeepingId(id: string): boolean {
|
|
496
|
+
return /^[serm]\d+$/.test(id)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* A runtime *fallback effect* id (`e1`, `e2`, …) — the subset of bookkeeping
|
|
501
|
+
* ids the runtime assigns to a `createEffect` the compiler never named (one in
|
|
502
|
+
* a ref callback / helper, not top-level reactive init). Narrower than
|
|
503
|
+
* {@link isRuntimeBookkeepingId} on purpose: only these surface as
|
|
504
|
+
* `uninstrumented` hot rows with candidate sites (#1849 B6), so the hot-table
|
|
505
|
+
* label and the candidate-scan gate share this one definition rather than each
|
|
506
|
+
* re-spelling `^e\d+$`.
|
|
507
|
+
*/
|
|
508
|
+
function isFallbackEffectId(id: string): boolean {
|
|
509
|
+
return /^e\d+$/.test(id)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Join a recorded event stream (SR2) to a component's IR (SR4). Each event's
|
|
514
|
+
* `subscriber` / `signal` id is resolved to its source-mapped node; ids with no
|
|
515
|
+
* match are collected as coverage gaps. This is the seam that turns a raw
|
|
516
|
+
* measurement into an explained, fixable finding.
|
|
517
|
+
*/
|
|
518
|
+
export function joinProfilerEvents(events: readonly ProfilerEvent[], index: IdIndex): JoinResult {
|
|
519
|
+
const joined: JoinedEvent[] = []
|
|
520
|
+
const gaps = new Map<string, number>()
|
|
521
|
+
|
|
522
|
+
const resolve = (id: string | undefined): ResolvedNode | undefined => {
|
|
523
|
+
if (id === undefined) return undefined
|
|
524
|
+
const node = index.get(id)
|
|
525
|
+
if (!node) gaps.set(id, (gaps.get(id) ?? 0) + 1)
|
|
526
|
+
return node
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
for (const event of events) {
|
|
530
|
+
joined.push({
|
|
531
|
+
event,
|
|
532
|
+
subscriber: resolve(event.subscriber),
|
|
533
|
+
signal: resolve(event.signal),
|
|
534
|
+
})
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const unattributed: UnattributedId[] = []
|
|
538
|
+
const diagnostics: UnattributedId[] = []
|
|
539
|
+
for (const [id, count] of gaps.entries()) {
|
|
540
|
+
;(isRuntimeBookkeepingId(id) ? diagnostics : unattributed).push({ id, count })
|
|
541
|
+
}
|
|
542
|
+
const byCount = (a: UnattributedId, b: UnattributedId): number => b.count - a.count
|
|
543
|
+
unattributed.sort(byCount)
|
|
544
|
+
diagnostics.sort(byCount)
|
|
545
|
+
|
|
546
|
+
return { joined, unattributed, diagnostics }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// -- Analysis: hot subscribers (v1, §4.2.1) -----------------------------------
|
|
550
|
+
|
|
551
|
+
/** A likely source `createEffect(...)` call site for an uninstrumented id. */
|
|
552
|
+
export interface EffectCandidate {
|
|
553
|
+
file: string
|
|
554
|
+
line: number
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Static scan for `createEffect(...)` call sites in `source` whose line is NOT
|
|
559
|
+
* in `instrumentedLines` — i.e. effects the compiler did not assign a `__bfId`
|
|
560
|
+
* (a `createEffect` nested in a ref callback / helper rather than at the
|
|
561
|
+
* component's top-level reactive init). These are the likely sources behind an
|
|
562
|
+
* anonymous runtime `e<n>` id the IR join can't resolve (#1849 B6). A specific
|
|
563
|
+
* `e<n>` can't be mapped to a specific call site, so these are surfaced as
|
|
564
|
+
* *candidates* ("possible sources"), not definitive attribution.
|
|
565
|
+
*/
|
|
566
|
+
export function findUninstrumentedEffects(
|
|
567
|
+
source: string,
|
|
568
|
+
filePath: string,
|
|
569
|
+
instrumentedLines: ReadonlySet<number>,
|
|
570
|
+
): EffectCandidate[] {
|
|
571
|
+
const sf = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
572
|
+
const out: EffectCandidate[] = []
|
|
573
|
+
const visit = (node: ts.Node): void => {
|
|
574
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'createEffect') {
|
|
575
|
+
// Use the call node's start (not the callee identifier's) to match the
|
|
576
|
+
// compiler's `EffectInfo.loc`, which is `getSourceLocation(node)` over the
|
|
577
|
+
// CallExpression — so an instrumented effect's line never mismatches here.
|
|
578
|
+
const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1
|
|
579
|
+
if (!instrumentedLines.has(line)) out.push({ file: filePath, line })
|
|
580
|
+
}
|
|
581
|
+
ts.forEachChild(node, visit)
|
|
582
|
+
}
|
|
583
|
+
visit(sf)
|
|
584
|
+
out.sort((a, b) => a.line - b.line)
|
|
585
|
+
return out
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export interface HotSubscriber {
|
|
589
|
+
/** The compiler-assigned subscriber id (effect/memo). */
|
|
590
|
+
subscriber: string
|
|
591
|
+
/** Source-mapped node from the SR4 join; absent ⇒ a coverage gap. */
|
|
592
|
+
loc?: { file: string; line: number }
|
|
593
|
+
name?: string
|
|
594
|
+
kind?: ResolvedNode['kind']
|
|
595
|
+
/** Total times this subscriber ran (`effectEnter` count), mount included. */
|
|
596
|
+
runs: number
|
|
597
|
+
/** Runs during initial mount (outside any turn) — the unavoidable baseline. */
|
|
598
|
+
mountRuns: number
|
|
599
|
+
/** Total run time in ms (Σ `effectExit.dur`). */
|
|
600
|
+
totalMs: number
|
|
601
|
+
/** Distinct *interaction* turns it ran in (mount excluded). */
|
|
602
|
+
turns: number
|
|
603
|
+
/**
|
|
604
|
+
* Interaction runs per active turn — `(runs − mountRuns) / turns`, the
|
|
605
|
+
* re-run-pressure signal. Mount is excluded so a click that re-runs an effect
|
|
606
|
+
* 5× reads as `5.0`, not diluted by the one-time mount run.
|
|
607
|
+
*/
|
|
608
|
+
runsPerTurn: number
|
|
609
|
+
/** True when `runsPerTurn` meets the configured threshold. */
|
|
610
|
+
hot: boolean
|
|
611
|
+
/**
|
|
612
|
+
* Why an `e<n>`-style runtime id has no `loc`: the compiler never assigned it
|
|
613
|
+
* a `__bfId` (a `createEffect` outside the top-level reactive init — e.g.
|
|
614
|
+
* inside a ref callback). Set only for those ids so the reader understands the
|
|
615
|
+
* missing location is expected, not a broken profiler (#1849 B6). The cost is
|
|
616
|
+
* still surfaced — the row is kept, not hidden.
|
|
617
|
+
*/
|
|
618
|
+
resolution?: 'uninstrumented'
|
|
619
|
+
/** Human note paired with `resolution`. */
|
|
620
|
+
resolutionNote?: string
|
|
621
|
+
/**
|
|
622
|
+
* Likely source `createEffect` call sites for an `uninstrumented` id, from a
|
|
623
|
+
* static scan — possible sources, not a definitive attribution.
|
|
624
|
+
*/
|
|
625
|
+
candidates?: EffectCandidate[]
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export interface HotSubscribersResult {
|
|
629
|
+
kind: 'hot-subscribers'
|
|
630
|
+
/** Ranked by `totalMs` descending, then `runs` descending. */
|
|
631
|
+
subscribers: HotSubscriber[]
|
|
632
|
+
/** SR4 coverage gaps — subscriber ids the IR could not resolve. */
|
|
633
|
+
unattributed: UnattributedId[]
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export interface HotSubscribersOptions {
|
|
637
|
+
/** `runsPerTurn` at/above which a subscriber is flagged `hot`. Default 2. */
|
|
638
|
+
hotRunsPerTurn?: number
|
|
639
|
+
/** Keep only the top-N by `totalMs` (after ranking). Default: all. */
|
|
640
|
+
topN?: number
|
|
641
|
+
/**
|
|
642
|
+
* Drop subscribers whose `totalMs` is below this threshold (after ranking,
|
|
643
|
+
* before `topN`). Noise filter for `--hot-ms`: a component with a long tail of
|
|
644
|
+
* sub-millisecond effects (e.g. a calendar grid) collapses to the few that
|
|
645
|
+
* actually cost. Default: keep all.
|
|
646
|
+
*/
|
|
647
|
+
minMs?: number
|
|
648
|
+
/**
|
|
649
|
+
* Candidate `createEffect` call sites for uninstrumented `e<n>` ids (#1849 B6).
|
|
650
|
+
* Attached to each such subscriber so the reader can jump to the likely source
|
|
651
|
+
* without opening the file. Computed by the caller (it needs the component
|
|
652
|
+
* source); the analysis is otherwise source-free.
|
|
653
|
+
*/
|
|
654
|
+
uninstrumentedCandidates?: readonly EffectCandidate[]
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const DEFAULT_HOT_RUNS_PER_TURN = 2
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Hot subscribers (§4.2.1): which effects/memos ran most and cost most, joined
|
|
661
|
+
* to IR source loc. Pure over the SR2 stream + SR4 index — same scenario ⇒ same
|
|
662
|
+
* ranking (timings vary, ranks/structure do not).
|
|
663
|
+
*
|
|
664
|
+
* `runsPerTurn` is the re-run-pressure signal: an effect that runs many times
|
|
665
|
+
* within a single turn is a batch / over-subscription candidate (links to the
|
|
666
|
+
* batch advisor and wasted-re-runs analyses).
|
|
667
|
+
*/
|
|
668
|
+
export function analyzeHotSubscribers(
|
|
669
|
+
events: readonly ProfilerEvent[],
|
|
670
|
+
index: IdIndex,
|
|
671
|
+
options: HotSubscribersOptions = {},
|
|
672
|
+
): HotSubscribersResult {
|
|
673
|
+
const threshold = options.hotRunsPerTurn ?? DEFAULT_HOT_RUNS_PER_TURN
|
|
674
|
+
|
|
675
|
+
interface Acc {
|
|
676
|
+
runs: number
|
|
677
|
+
mountRuns: number
|
|
678
|
+
totalMs: number
|
|
679
|
+
/** Distinct turn *invocations* (by `turnSeq`, falling back to id) it ran in. */
|
|
680
|
+
turns: Set<string>
|
|
681
|
+
}
|
|
682
|
+
const byId = new Map<string, Acc>()
|
|
683
|
+
const acc = (id: string): Acc => {
|
|
684
|
+
let a = byId.get(id)
|
|
685
|
+
if (!a) {
|
|
686
|
+
a = { runs: 0, mountRuns: 0, totalMs: 0, turns: new Set() }
|
|
687
|
+
byId.set(id, a)
|
|
688
|
+
}
|
|
689
|
+
return a
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
for (const e of events) {
|
|
693
|
+
if (e.subscriber === undefined) continue
|
|
694
|
+
if (e.type === 'effectEnter') {
|
|
695
|
+
const a = acc(e.subscriber)
|
|
696
|
+
a.runs++
|
|
697
|
+
// Runs outside any turn are the one-time mount/setup baseline — kept
|
|
698
|
+
// separate so they don't dilute the per-interaction `runsPerTurn`.
|
|
699
|
+
if (e.turn === null) a.mountRuns++
|
|
700
|
+
else a.turns.add(String(e.turnSeq ?? e.turn))
|
|
701
|
+
} else if (e.type === 'effectExit' && e.dur !== undefined) {
|
|
702
|
+
acc(e.subscriber).totalMs += e.dur
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const { joined, unattributed } = joinProfilerEvents(events, index)
|
|
707
|
+
const nodeFor = new Map<string, JoinedEvent['subscriber']>()
|
|
708
|
+
for (const j of joined) {
|
|
709
|
+
if (j.event.subscriber !== undefined && j.subscriber) nodeFor.set(j.event.subscriber, j.subscriber)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
let subscribers: HotSubscriber[] = [...byId.entries()].map(([subscriber, a]) => {
|
|
713
|
+
const node = nodeFor.get(subscriber)
|
|
714
|
+
const turns = a.turns.size
|
|
715
|
+
const interactionRuns = a.runs - a.mountRuns
|
|
716
|
+
const runsPerTurn = turns > 0 ? interactionRuns / turns : 0
|
|
717
|
+
// An anonymous runtime `e<n>` id with no resolved node is a `createEffect`
|
|
718
|
+
// the compiler never assigned a `__bfId` (one nested in a ref callback /
|
|
719
|
+
// helper). Keep it in the table — its cost is real — but label *why* it has
|
|
720
|
+
// no location and surface candidate source lines so the reader needn't hunt
|
|
721
|
+
// for it (#1849 B6). Other bookkeeping ids (`s*`/`m*`/`r*`) stay plain.
|
|
722
|
+
const uninstrumented = !node && isFallbackEffectId(subscriber)
|
|
723
|
+
return {
|
|
724
|
+
subscriber,
|
|
725
|
+
loc: node?.loc,
|
|
726
|
+
name: node?.name,
|
|
727
|
+
kind: node?.kind,
|
|
728
|
+
runs: a.runs,
|
|
729
|
+
mountRuns: a.mountRuns,
|
|
730
|
+
totalMs: a.totalMs,
|
|
731
|
+
turns,
|
|
732
|
+
runsPerTurn,
|
|
733
|
+
hot: runsPerTurn >= threshold,
|
|
734
|
+
...(uninstrumented
|
|
735
|
+
? {
|
|
736
|
+
resolution: 'uninstrumented' as const,
|
|
737
|
+
resolutionNote: 'createEffect in non-JSX function scope (not attributed by compiler)',
|
|
738
|
+
candidates: [...(options.uninstrumentedCandidates ?? [])],
|
|
739
|
+
}
|
|
740
|
+
: {}),
|
|
741
|
+
}
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
// Rank by cost — `totalMs` is what the user fixes ("where's the time?"). To
|
|
745
|
+
// stay reproducible (SR7) despite wall-clock noise, compare at the displayed
|
|
746
|
+
// 0.1ms precision so same-cost subscribers form a stable cohort, then break
|
|
747
|
+
// ties by `runs` (the structural leading indicator) and finally the id.
|
|
748
|
+
const roundMs = (m: number): number => Math.round(m * 10) / 10
|
|
749
|
+
subscribers.sort(
|
|
750
|
+
(x, y) =>
|
|
751
|
+
roundMs(y.totalMs) - roundMs(x.totalMs) ||
|
|
752
|
+
y.runs - x.runs ||
|
|
753
|
+
(x.subscriber < y.subscriber ? -1 : x.subscriber > y.subscriber ? 1 : 0),
|
|
754
|
+
)
|
|
755
|
+
// `minMs` is a noise floor applied at the displayed 0.1ms precision so a
|
|
756
|
+
// subscriber on the line is filtered consistently with how it would render.
|
|
757
|
+
if (options.minMs !== undefined) subscribers = subscribers.filter(s => roundMs(s.totalMs) >= options.minMs!)
|
|
758
|
+
if (options.topN !== undefined) subscribers = subscribers.slice(0, options.topN)
|
|
759
|
+
|
|
760
|
+
// Only subscriber ids matter for this analysis — filter the join's gaps to
|
|
761
|
+
// ids that actually appeared as a subscriber.
|
|
762
|
+
const subscriberIds = new Set(byId.keys())
|
|
763
|
+
const gaps = unattributed.filter(u => subscriberIds.has(u.id))
|
|
764
|
+
|
|
765
|
+
return { kind: 'hot-subscribers', subscribers, unattributed: gaps }
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const BAR_EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
|
|
769
|
+
|
|
770
|
+
/** Pad or ellipsize `s` to exactly `width` cells so columns stay aligned. */
|
|
771
|
+
function fitLabel(s: string, width: number): string {
|
|
772
|
+
return s.length > width ? `${s.slice(0, width - 1)}…` : s.padEnd(width)
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* A proportional horizontal bar for `value/max`, in eighth-block precision
|
|
777
|
+
* (mitata-style), left-padded to `width` cells. Empty when value/max ≤ 0.
|
|
778
|
+
*/
|
|
779
|
+
function bar(value: number, max: number, width: number): string {
|
|
780
|
+
if (max <= 0 || value <= 0) return ''.padEnd(width)
|
|
781
|
+
const units = Math.min(value / max, 1) * width
|
|
782
|
+
let full = Math.floor(units)
|
|
783
|
+
let rem = Math.round((units - full) * 8)
|
|
784
|
+
if (rem === 8) {
|
|
785
|
+
full++ // a fractional part that rounds to 8/8 is a whole extra cell
|
|
786
|
+
rem = 0
|
|
787
|
+
}
|
|
788
|
+
return ('█'.repeat(full) + (rem > 0 ? BAR_EIGHTHS[rem] : '')).padEnd(width)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Render candidate `createEffect` sites grouped per file, e.g.
|
|
793
|
+
* `collapsible/index.tsx:82, :126, :184` (multiple files joined by `; `).
|
|
794
|
+
*/
|
|
795
|
+
function formatEffectCandidates(candidates: readonly EffectCandidate[]): string {
|
|
796
|
+
const byFile = new Map<string, number[]>()
|
|
797
|
+
for (const c of candidates) {
|
|
798
|
+
const arr = byFile.get(c.file) ?? []
|
|
799
|
+
arr.push(c.line)
|
|
800
|
+
byFile.set(c.file, arr)
|
|
801
|
+
}
|
|
802
|
+
const groups: string[] = []
|
|
803
|
+
for (const [file, ls] of byFile) {
|
|
804
|
+
const sorted = [...new Set(ls)].sort((a, b) => a - b)
|
|
805
|
+
groups.push(`${file}:${sorted[0]}${sorted.slice(1).map(l => `, :${l}`).join('')}`)
|
|
806
|
+
}
|
|
807
|
+
return groups.join('; ')
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
export function formatHotSubscribers(r: HotSubscribersResult, limit = 12): string {
|
|
811
|
+
const lines: string[] = []
|
|
812
|
+
lines.push('hot subscribers — most run / most time')
|
|
813
|
+
if (r.subscribers.length === 0) {
|
|
814
|
+
lines.push(' (no effect/memo runs recorded)')
|
|
815
|
+
}
|
|
816
|
+
// Resolved subscribers carry a source line and are the actionable ones; show
|
|
817
|
+
// them first, then a capped tail of unresolved noise (loop/binding effects
|
|
818
|
+
// the analyzer can't yet place), so a big list (e.g. a calendar grid) stays
|
|
819
|
+
// readable instead of dumping a thousand rows.
|
|
820
|
+
const shown = r.subscribers.slice(0, limit)
|
|
821
|
+
// Bars are proportional to `totalMs` — the cost, i.e. where to spend a fix.
|
|
822
|
+
// `runs` (the leading indicator of that cost) rides alongside as a number.
|
|
823
|
+
const maxMs = shown.reduce((m, s) => Math.max(m, s.totalMs), 0)
|
|
824
|
+
for (const s of shown) {
|
|
825
|
+
// An uninstrumented `e<n>` id has no loc by design (the compiler never named
|
|
826
|
+
// it); say so explicitly instead of the bare `unresolved` that reads like
|
|
827
|
+
// a broken profiler (#1849 B6). These strings carry no parens of their own —
|
|
828
|
+
// the row formatter wraps `where` in a single `(…)` below.
|
|
829
|
+
const where = s.loc
|
|
830
|
+
? `${s.loc.file}:${s.loc.line}`
|
|
831
|
+
: s.resolution === 'uninstrumented'
|
|
832
|
+
? 'uninstrumented — createEffect in non-JSX scope'
|
|
833
|
+
: 'unresolved'
|
|
834
|
+
const base = s.name ?? s.subscriber
|
|
835
|
+
// Binding names already carry their type (`s1 (attribute)`); don't double up.
|
|
836
|
+
const label = s.kind && !base.endsWith(')') ? `${base} (${s.kind})` : base
|
|
837
|
+
const note = s.hot ? ` ⚠ ${s.runsPerTurn.toFixed(1)}/turn` : ''
|
|
838
|
+
lines.push(
|
|
839
|
+
` ${fitLabel(label, 24)} ${bar(s.totalMs, maxMs, 14)} ${s.totalMs.toFixed(1)}ms ${String(s.runs).padStart(3)}× (${where})${note}`,
|
|
840
|
+
)
|
|
841
|
+
// Candidate source lines for an uninstrumented id — the reader jumps there
|
|
842
|
+
// without opening the file. Possible sources, not a definitive mapping.
|
|
843
|
+
if (s.candidates && s.candidates.length > 0) {
|
|
844
|
+
lines.push(`${' '.repeat(26)}candidates: ${formatEffectCandidates(s.candidates)}`)
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (r.subscribers.length > shown.length) {
|
|
848
|
+
lines.push(` … and ${r.subscribers.length - shown.length} more`)
|
|
849
|
+
}
|
|
850
|
+
if (r.unattributed.length > 0) {
|
|
851
|
+
lines.push(` ⚠ coverage: ${r.unattributed.length} unresolved subscriber id(s)`)
|
|
852
|
+
}
|
|
853
|
+
return lines.join('\n')
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// -- Analysis: wasted re-runs (v1, §4.2.2) ------------------------------------
|
|
857
|
+
|
|
858
|
+
export interface WastedSubscriber {
|
|
859
|
+
/** The compiler-assigned subscriber id (effect/memo/binding). */
|
|
860
|
+
subscriber: string
|
|
861
|
+
/** Source-mapped node from the SR4 join; absent ⇒ a coverage gap. */
|
|
862
|
+
loc?: { file: string; line: number }
|
|
863
|
+
name?: string
|
|
864
|
+
kind?: ResolvedNode['kind']
|
|
865
|
+
/** Runs that emitted an output fingerprint (`effectOutput` count). */
|
|
866
|
+
totalRuns: number
|
|
867
|
+
/** Fingerprinted runs whose output was identical to the previous run. */
|
|
868
|
+
wastedRuns: number
|
|
869
|
+
/** `wastedRuns / totalRuns` — the share of recompute that produced no change. */
|
|
870
|
+
wastedRatio: number
|
|
871
|
+
/** True when `wastedRatio` meets the configured threshold. */
|
|
872
|
+
wasted: boolean
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
export interface WastedReRunsResult {
|
|
876
|
+
kind: 'wasted-re-runs'
|
|
877
|
+
/** Subscribers with ≥1 wasted run, ranked by `wastedRuns` then `wastedRatio`. */
|
|
878
|
+
subscribers: WastedSubscriber[]
|
|
879
|
+
/** SR4 coverage gaps — fingerprinted subscriber ids the IR could not resolve. */
|
|
880
|
+
unattributed: UnattributedId[]
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export interface WastedReRunsOptions {
|
|
884
|
+
/**
|
|
885
|
+
* `wastedRatio` at/above which a subscriber is flagged `wasted`. A fraction in
|
|
886
|
+
* `[0,1]` (e.g. `0.5` = half its runs produced identical output). Default 0.5.
|
|
887
|
+
*/
|
|
888
|
+
wastedRatio?: number
|
|
889
|
+
/** Keep only the top-N by `wastedRuns` (after ranking). Default: all. */
|
|
890
|
+
topN?: number
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const DEFAULT_WASTED_RATIO = 0.5
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Wasted re-runs (§4.2.2): effects/memos that re-ran but produced output
|
|
897
|
+
* identical to their previous run — the computation happened, the DOM/value did
|
|
898
|
+
* not change, so the re-run was removable. The complement to hot subscribers:
|
|
899
|
+
* hot says *where the cost is*, wasted says *how much of it is removable*.
|
|
900
|
+
*
|
|
901
|
+
* Pure over the SR2 stream's `effectOutput` fingerprints (memo-value `Object.is`
|
|
902
|
+
* + instrumented DOM writes) joined to IR source loc — same scenario ⇒ same
|
|
903
|
+
* ranking. A high ratio means the subscriber reads at a coarser grain than its
|
|
904
|
+
* output needs; the fix is a finer signal/memo split.
|
|
905
|
+
*/
|
|
906
|
+
export function analyzeWastedReReruns(
|
|
907
|
+
events: readonly ProfilerEvent[],
|
|
908
|
+
index: IdIndex,
|
|
909
|
+
options: WastedReRunsOptions = {},
|
|
910
|
+
): WastedReRunsResult {
|
|
911
|
+
const threshold = options.wastedRatio ?? DEFAULT_WASTED_RATIO
|
|
912
|
+
|
|
913
|
+
interface Acc {
|
|
914
|
+
total: number
|
|
915
|
+
wasted: number
|
|
916
|
+
}
|
|
917
|
+
const byId = new Map<string, Acc>()
|
|
918
|
+
|
|
919
|
+
for (const e of events) {
|
|
920
|
+
if (e.type !== 'effectOutput' || e.subscriber === undefined || e.changed === undefined) continue
|
|
921
|
+
let a = byId.get(e.subscriber)
|
|
922
|
+
if (!a) {
|
|
923
|
+
a = { total: 0, wasted: 0 }
|
|
924
|
+
byId.set(e.subscriber, a)
|
|
925
|
+
}
|
|
926
|
+
a.total++
|
|
927
|
+
if (!e.changed) a.wasted++
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const { unattributed } = joinProfilerEvents(events, index)
|
|
931
|
+
const nodeFor = new Map<string, ResolvedNode>()
|
|
932
|
+
for (const [id] of byId) {
|
|
933
|
+
const node = index.get(id)
|
|
934
|
+
if (node) nodeFor.set(id, node)
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
let subscribers: WastedSubscriber[] = [...byId.entries()]
|
|
938
|
+
.map(([subscriber, a]) => {
|
|
939
|
+
const node = nodeFor.get(subscriber)
|
|
940
|
+
const wastedRatio = a.total > 0 ? a.wasted / a.total : 0
|
|
941
|
+
return {
|
|
942
|
+
subscriber,
|
|
943
|
+
loc: node?.loc,
|
|
944
|
+
name: node?.name,
|
|
945
|
+
kind: node?.kind,
|
|
946
|
+
totalRuns: a.total,
|
|
947
|
+
wastedRuns: a.wasted,
|
|
948
|
+
wastedRatio,
|
|
949
|
+
wasted: wastedRatio >= threshold,
|
|
950
|
+
}
|
|
951
|
+
})
|
|
952
|
+
// Only subscribers that actually wasted work are findings.
|
|
953
|
+
.filter(s => s.wastedRuns > 0)
|
|
954
|
+
|
|
955
|
+
// Rank by *removable* cost first — absolute wasted runs is what a finer split
|
|
956
|
+
// would eliminate; ratio (the severity) and id break ties, keeping the order
|
|
957
|
+
// deterministic across runs (SR7).
|
|
958
|
+
subscribers.sort(
|
|
959
|
+
(x, y) =>
|
|
960
|
+
y.wastedRuns - x.wastedRuns ||
|
|
961
|
+
y.wastedRatio - x.wastedRatio ||
|
|
962
|
+
(x.subscriber < y.subscriber ? -1 : x.subscriber > y.subscriber ? 1 : 0),
|
|
963
|
+
)
|
|
964
|
+
if (options.topN !== undefined) subscribers = subscribers.slice(0, options.topN)
|
|
965
|
+
|
|
966
|
+
// Only fingerprinted subscriber ids matter — filter the join's gaps to ids
|
|
967
|
+
// that actually appeared as a fingerprinted subscriber.
|
|
968
|
+
const subscriberIds = new Set(byId.keys())
|
|
969
|
+
const gaps = unattributed.filter(u => subscriberIds.has(u.id))
|
|
970
|
+
|
|
971
|
+
return { kind: 'wasted-re-runs', subscribers, unattributed: gaps }
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** The noun for a subscriber's identical output, keyed by kind (memo vs DOM). */
|
|
975
|
+
function wastedOutputNoun(kind?: ResolvedNode['kind']): string {
|
|
976
|
+
return kind === 'memo' ? 'identical value' : 'identical DOM'
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
export function formatWastedReReruns(r: WastedReRunsResult, limit = 12): string {
|
|
980
|
+
const lines: string[] = []
|
|
981
|
+
lines.push('wasted re-runs — re-ran but produced identical output')
|
|
982
|
+
if (r.subscribers.length === 0) {
|
|
983
|
+
lines.push(' (no wasted re-runs recorded)')
|
|
984
|
+
}
|
|
985
|
+
const shown = r.subscribers.slice(0, limit)
|
|
986
|
+
const maxWasted = shown.reduce((m, s) => Math.max(m, s.wastedRuns), 0)
|
|
987
|
+
for (const s of shown) {
|
|
988
|
+
const where = s.loc ? `${s.loc.file}:${s.loc.line}` : '(unresolved)'
|
|
989
|
+
const base = s.name ?? s.subscriber
|
|
990
|
+
const label = s.kind && !base.endsWith(')') ? `${base} (${s.kind})` : base
|
|
991
|
+
const pct = Math.round(s.wastedRatio * 100)
|
|
992
|
+
const note = s.wasted ? ' ⚠ split so it doesn’t re-run on unrelated changes' : ''
|
|
993
|
+
lines.push(
|
|
994
|
+
` ${fitLabel(label, 24)} ${bar(s.wastedRuns, maxWasted, 14)} wasted: ${s.wastedRuns}/${s.totalRuns} produced ${wastedOutputNoun(s.kind)} (${pct}%) (${where})${note}`,
|
|
995
|
+
)
|
|
996
|
+
}
|
|
997
|
+
if (r.subscribers.length > shown.length) {
|
|
998
|
+
lines.push(` … and ${r.subscribers.length - shown.length} more`)
|
|
999
|
+
}
|
|
1000
|
+
if (r.unattributed.length > 0) {
|
|
1001
|
+
lines.push(` ⚠ coverage: ${r.unattributed.length} unresolved subscriber id(s)`)
|
|
1002
|
+
}
|
|
1003
|
+
return lines.join('\n')
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// -- Analysis: batch advisor (v1, §4.2.3) -------------------------------------
|
|
1007
|
+
|
|
1008
|
+
export type BatchSafety = 'safe' | 'unsafe' | 'unverified'
|
|
1009
|
+
|
|
1010
|
+
export interface BatchCandidate {
|
|
1011
|
+
/** The turn's handler id (`<Component>#handler:<slot>:<event>`). */
|
|
1012
|
+
turn: string
|
|
1013
|
+
/** Handler source location, when the id index resolves it. */
|
|
1014
|
+
loc?: { file: string; line: number }
|
|
1015
|
+
/** Friendly handler name (`click@s1`), when resolved. */
|
|
1016
|
+
handler?: string
|
|
1017
|
+
/** Total effect runs in the turn. */
|
|
1018
|
+
totalRuns: number
|
|
1019
|
+
/** Distinct effects that ran (the floor a batched turn would collapse to). */
|
|
1020
|
+
distinctSubscribers: number
|
|
1021
|
+
/**
|
|
1022
|
+
* Signal writes (`set()` calls) the turn performed. A `batch()` wrap can only
|
|
1023
|
+
* collapse re-runs when there are *several* writes notifying the same effect,
|
|
1024
|
+
* so this is always ≥ 2 for a candidate — a single-write turn benefits not at
|
|
1025
|
+
* all and is never surfaced (see {@link analyzeBatchAdvisor}).
|
|
1026
|
+
*/
|
|
1027
|
+
writes: number
|
|
1028
|
+
/**
|
|
1029
|
+
* Upper bound on the runs a `batch()` wrap would remove (`totalRuns −
|
|
1030
|
+
* distinctSubscribers`). It is an upper bound, not an exact count: distinct
|
|
1031
|
+
* effects are counted by *id*, so a loop binding whose single id maps to many
|
|
1032
|
+
* per-item DOM instances reads as one "distinct subscriber" even though each
|
|
1033
|
+
* instance must still run once under a batch. Treat it as "up to N".
|
|
1034
|
+
*/
|
|
1035
|
+
savings: number
|
|
1036
|
+
/**
|
|
1037
|
+
* Whether wrapping the handler in `batch()` is provably behavior-preserving.
|
|
1038
|
+
* `'unverified'` until the static post-write-derived-read oracle (SR4) runs —
|
|
1039
|
+
* a savings opportunity is surfaced, but never advised as `'safe'` without
|
|
1040
|
+
* proof (§4.2.3).
|
|
1041
|
+
*/
|
|
1042
|
+
safety: BatchSafety
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
export interface BatchAdvisorResult {
|
|
1046
|
+
kind: 'batch-advisor'
|
|
1047
|
+
/** Turns with `savings > 0`, ranked by `savings` descending. */
|
|
1048
|
+
candidates: BatchCandidate[]
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Batch advisor (§4.2.3). BarefootJS uses **explicit** `batch()` — `set()`
|
|
1053
|
+
* notifies synchronously — so a turn that writes several signals re-runs shared
|
|
1054
|
+
* effects once per write. Per turn this measures `totalRuns` (effect runs) vs
|
|
1055
|
+
* `distinctSubscribers` (unique effects); `savings = totalRuns −
|
|
1056
|
+
* distinctSubscribers` is an upper bound on what a `batch()` wrap would collapse.
|
|
1057
|
+
*
|
|
1058
|
+
* A candidate must be a genuine **multi-write** turn (`writes ≥ 2`). `batch()`
|
|
1059
|
+
* only fuses the notifications of *several* writes; it does nothing to a single
|
|
1060
|
+
* write, even one that fans out widely. A loop binding re-running once per item
|
|
1061
|
+
* from one `set()` (e.g. a 42-cell calendar grid: one id, 42 runs) is *not* a
|
|
1062
|
+
* batch opportunity — without the write gate its 42 runs collapse to "saves 41"
|
|
1063
|
+
* against a single distinct id, a confident false positive.
|
|
1064
|
+
*
|
|
1065
|
+
* `writes` counts only `signalSet`s made *directly in the handler body* —
|
|
1066
|
+
* `effectEnter`/`effectExit` track effect-nesting depth, and a write is a
|
|
1067
|
+
* genuine handler write only at depth 0. This excludes the writes a memo makes
|
|
1068
|
+
* to its own private signal when it recomputes (a memo is an effect that writes
|
|
1069
|
+
* a signal sharing its id), and any other downstream cascade write, since those
|
|
1070
|
+
* fire *inside* the effects the direct writes triggered and are not something a
|
|
1071
|
+
* `batch()` around the handler could fuse.
|
|
1072
|
+
*
|
|
1073
|
+
* Measured half only: every candidate is reported `safety: 'unverified'`. The
|
|
1074
|
+
* static safety oracle that upgrades a candidate to `'safe'`/`'unsafe'` lands
|
|
1075
|
+
* in a follow-up — an advisory that could change behavior must not be labeled
|
|
1076
|
+
* safe (§4.2.3).
|
|
1077
|
+
*/
|
|
1078
|
+
export function analyzeBatchAdvisor(
|
|
1079
|
+
events: readonly ProfilerEvent[],
|
|
1080
|
+
index?: IdIndex,
|
|
1081
|
+
): BatchAdvisorResult {
|
|
1082
|
+
// Group by turn *invocation* (`turnSeq`), so several clicks of the same
|
|
1083
|
+
// handler are evaluated separately rather than summed into one inflated turn.
|
|
1084
|
+
interface TurnAcc {
|
|
1085
|
+
handlerId: string
|
|
1086
|
+
totalRuns: number
|
|
1087
|
+
writes: number
|
|
1088
|
+
/** Effect-nesting depth in this turn: a `signalSet` is a direct handler
|
|
1089
|
+
* write only at depth 0; deeper sets are memo/cascade recomputes. */
|
|
1090
|
+
depth: number
|
|
1091
|
+
subscribers: Set<string>
|
|
1092
|
+
}
|
|
1093
|
+
const byInvocation = new Map<string, TurnAcc>()
|
|
1094
|
+
|
|
1095
|
+
const get = (e: ProfilerEvent): TurnAcc | undefined => {
|
|
1096
|
+
if (e.turn === null) return undefined
|
|
1097
|
+
const key = String(e.turnSeq ?? e.turn)
|
|
1098
|
+
let acc = byInvocation.get(key)
|
|
1099
|
+
if (!acc) {
|
|
1100
|
+
acc = { handlerId: e.turn, totalRuns: 0, writes: 0, depth: 0, subscribers: new Set() }
|
|
1101
|
+
byInvocation.set(key, acc)
|
|
1102
|
+
}
|
|
1103
|
+
return acc
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
for (const e of events) {
|
|
1107
|
+
// `signalSet` at depth 0 is a write the handler made itself — the ground
|
|
1108
|
+
// truth for whether batching can fuse anything. `effectEnter`/`effectExit`
|
|
1109
|
+
// bracket the runs those writes (plus loop fan-out and memo recomputes)
|
|
1110
|
+
// produced; a set fired while one of those runs is on the stack (depth > 0)
|
|
1111
|
+
// is a downstream cascade write, not a handler write.
|
|
1112
|
+
if (e.type === 'signalSet') {
|
|
1113
|
+
const acc = get(e)
|
|
1114
|
+
if (acc && acc.depth === 0) acc.writes++
|
|
1115
|
+
} else if (e.type === 'effectEnter') {
|
|
1116
|
+
const acc = get(e)
|
|
1117
|
+
if (!acc) continue
|
|
1118
|
+
acc.totalRuns++
|
|
1119
|
+
acc.depth++
|
|
1120
|
+
if (e.subscriber !== undefined) acc.subscribers.add(e.subscriber)
|
|
1121
|
+
} else if (e.type === 'effectExit') {
|
|
1122
|
+
const acc = get(e)
|
|
1123
|
+
if (acc && acc.depth > 0) acc.depth--
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Collapse invocations to one candidate per handler — the worst (max-savings)
|
|
1128
|
+
// invocation represents the handler's batch opportunity.
|
|
1129
|
+
const byHandler = new Map<string, BatchCandidate>()
|
|
1130
|
+
for (const acc of byInvocation.values()) {
|
|
1131
|
+
// A turn that writes at most one signal cannot benefit from `batch()`, no
|
|
1132
|
+
// matter how many effect runs that single write fanned out to (#1690).
|
|
1133
|
+
if (acc.writes < 2) continue
|
|
1134
|
+
const distinctSubscribers = acc.subscribers.size
|
|
1135
|
+
const savings = acc.totalRuns - distinctSubscribers
|
|
1136
|
+
if (savings <= 0) continue
|
|
1137
|
+
const prev = byHandler.get(acc.handlerId)
|
|
1138
|
+
if (prev && prev.savings >= savings) continue
|
|
1139
|
+
const node = index?.get(acc.handlerId)
|
|
1140
|
+
byHandler.set(acc.handlerId, {
|
|
1141
|
+
turn: acc.handlerId,
|
|
1142
|
+
loc: node?.loc,
|
|
1143
|
+
handler: node?.name,
|
|
1144
|
+
totalRuns: acc.totalRuns,
|
|
1145
|
+
distinctSubscribers,
|
|
1146
|
+
writes: acc.writes,
|
|
1147
|
+
savings,
|
|
1148
|
+
safety: 'unverified',
|
|
1149
|
+
})
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const candidates = [...byHandler.values()]
|
|
1153
|
+
candidates.sort((a, b) => b.savings - a.savings || b.totalRuns - a.totalRuns)
|
|
1154
|
+
|
|
1155
|
+
return { kind: 'batch-advisor', candidates }
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
export function formatBatchAdvisor(r: BatchAdvisorResult): string {
|
|
1159
|
+
const lines: string[] = []
|
|
1160
|
+
lines.push('batch advisor — unbatched multi-write turns')
|
|
1161
|
+
if (r.candidates.length === 0) {
|
|
1162
|
+
lines.push(' (no turn would benefit from batching)')
|
|
1163
|
+
}
|
|
1164
|
+
const maxSavings = r.candidates.reduce((m, c) => Math.max(m, c.savings), 0)
|
|
1165
|
+
for (const c of r.candidates) {
|
|
1166
|
+
const safe = c.safety === 'safe' ? ', safe' : c.safety === 'unsafe' ? ', UNSAFE' : ', safety unverified'
|
|
1167
|
+
const where = c.loc ? ` (${c.loc.file}:${c.loc.line})` : ''
|
|
1168
|
+
const label = fitLabel(c.handler ?? c.turn, 24)
|
|
1169
|
+
// Bar proportional to savings (deterministic) — the bigger the bar, the more
|
|
1170
|
+
// effect re-runs a `batch()` would collapse.
|
|
1171
|
+
lines.push(
|
|
1172
|
+
` ${label} ${bar(c.savings, maxSavings, 14)} batch candidate ${c.totalRuns}→${c.distinctSubscribers} (saves ${c.savings}${safe})${where}`,
|
|
1173
|
+
)
|
|
1174
|
+
}
|
|
1175
|
+
return lines.join('\n')
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// -- Batch safety oracle (§4.2.3 / SR4) ---------------------------------------
|
|
1179
|
+
|
|
1180
|
+
/** Memos transitively dependent on any of `written` (so stale under batch). */
|
|
1181
|
+
function downstreamMemos(graph: ComponentGraph, written: Set<string>): Set<string> {
|
|
1182
|
+
const byName = new Map(graph.memos.map(m => [m.name, m]))
|
|
1183
|
+
const result = new Set<string>()
|
|
1184
|
+
const dependsOnWritten = (memoName: string, seen: Set<string>): boolean => {
|
|
1185
|
+
if (seen.has(memoName)) return false
|
|
1186
|
+
seen.add(memoName)
|
|
1187
|
+
const m = byName.get(memoName)
|
|
1188
|
+
if (!m) return false
|
|
1189
|
+
for (const dep of m.deps) {
|
|
1190
|
+
if (written.has(dep)) return true
|
|
1191
|
+
if (byName.has(dep) && dependsOnWritten(dep, seen)) return true
|
|
1192
|
+
}
|
|
1193
|
+
return false
|
|
1194
|
+
}
|
|
1195
|
+
for (const m of graph.memos) if (dependsOnWritten(m.name, new Set())) result.add(m.name)
|
|
1196
|
+
return result
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* The **post-write-derived-read** oracle (§4.2.3). Wrapping a handler in
|
|
1201
|
+
* `batch()` defers effect flush; since a memo is a push-effect that writes a
|
|
1202
|
+
* private signal (`reactive.ts`), a memo read *after* a write to one of its
|
|
1203
|
+
* dependencies returns a **stale** value under batch. So a wrap is safe iff no
|
|
1204
|
+
* such read happens.
|
|
1205
|
+
*
|
|
1206
|
+
* Conservative by construction — only `'safe'` when provably so:
|
|
1207
|
+
* - indirect setters (`via` a helper) ⇒ `'unverified'` (helper body unseen);
|
|
1208
|
+
* - a downstream-memo getter called after the first write ⇒ `'unsafe'`;
|
|
1209
|
+
* - a bare unknown-function call after a write ⇒ `'unverified'` (it could read a
|
|
1210
|
+
* memo we can't see). Signal reads are fine — `set()` updates the value
|
|
1211
|
+
* synchronously; only memo recomputation is deferred.
|
|
1212
|
+
*
|
|
1213
|
+
* Known gap: a memo read reached through an object-method call's closure is not
|
|
1214
|
+
* traced (only lexically-visible `memo()` getters and bare helper calls are).
|
|
1215
|
+
*/
|
|
1216
|
+
export function assessBatchSafety(args: {
|
|
1217
|
+
handler: string
|
|
1218
|
+
setterNames: readonly string[]
|
|
1219
|
+
hasIndirectSetters: boolean
|
|
1220
|
+
writtenSignals: readonly string[]
|
|
1221
|
+
graph: ComponentGraph
|
|
1222
|
+
}): BatchSafety {
|
|
1223
|
+
if (args.hasIndirectSetters || args.setterNames.length === 0) return 'unverified'
|
|
1224
|
+
|
|
1225
|
+
const D = downstreamMemos(args.graph, new Set(args.writtenSignals))
|
|
1226
|
+
const setters = new Set(args.setterNames)
|
|
1227
|
+
const memoNames = new Set(args.graph.memos.map(m => m.name))
|
|
1228
|
+
const signalGetters = new Set(args.graph.signals.map(s => s.name))
|
|
1229
|
+
|
|
1230
|
+
let sf: ts.SourceFile
|
|
1231
|
+
try {
|
|
1232
|
+
sf = ts.createSourceFile('__h.ts', `const __h = ${args.handler}`, ts.ScriptTarget.Latest, true)
|
|
1233
|
+
} catch {
|
|
1234
|
+
return 'unverified'
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
type Call = { pos: number; kind: 'write' | 'memoRead' | 'risky' }
|
|
1238
|
+
const calls: Call[] = []
|
|
1239
|
+
const visit = (node: ts.Node): void => {
|
|
1240
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
1241
|
+
const name = node.expression.text
|
|
1242
|
+
if (setters.has(name)) calls.push({ pos: node.getStart(sf), kind: 'write' })
|
|
1243
|
+
else if (D.has(name) && node.arguments.length === 0) calls.push({ pos: node.getStart(sf), kind: 'memoRead' })
|
|
1244
|
+
else if (!signalGetters.has(name) && !memoNames.has(name)) calls.push({ pos: node.getStart(sf), kind: 'risky' })
|
|
1245
|
+
}
|
|
1246
|
+
ts.forEachChild(node, visit)
|
|
1247
|
+
}
|
|
1248
|
+
visit(sf)
|
|
1249
|
+
calls.sort((a, b) => a.pos - b.pos)
|
|
1250
|
+
|
|
1251
|
+
let seenWrite = false
|
|
1252
|
+
let risky = false
|
|
1253
|
+
for (const c of calls) {
|
|
1254
|
+
if (c.kind === 'write') seenWrite = true
|
|
1255
|
+
else if (seenWrite && c.kind === 'memoRead') return 'unsafe'
|
|
1256
|
+
else if (seenWrite && c.kind === 'risky') risky = true
|
|
1257
|
+
}
|
|
1258
|
+
if (!seenWrite) return 'unverified'
|
|
1259
|
+
return risky ? 'unverified' : 'safe'
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Pair each turn handler id (`<Component>#handler:<slotId>:<event>`) with its
|
|
1264
|
+
* `EventBinding`, matching the event `domBinding`'s slotId to the binding by
|
|
1265
|
+
* source line (the binding itself has no slotId). Used to feed the safety
|
|
1266
|
+
* oracle a candidate's handler body + setter calls.
|
|
1267
|
+
*/
|
|
1268
|
+
function turnToEventBinding(graph: ComponentGraph, events: EventBinding[]): Map<string, EventBinding> {
|
|
1269
|
+
const byLine = new Map<number, EventBinding>()
|
|
1270
|
+
for (const e of events) if (e.loc) byLine.set(e.loc.start.line, e)
|
|
1271
|
+
const out = new Map<string, EventBinding>()
|
|
1272
|
+
for (const b of graph.domBindings) {
|
|
1273
|
+
if (b.type !== 'event' || !b.loc) continue
|
|
1274
|
+
const eventName = b.label.match(/^(\w+)\s+handler/)?.[1]
|
|
1275
|
+
const binding = byLine.get(b.loc.start.line)
|
|
1276
|
+
if (eventName && binding) out.set(`${graph.componentName}#handler:${b.slotId}:${eventName}`, binding)
|
|
1277
|
+
}
|
|
1278
|
+
return out
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// -- Dynamic report (SR1–SR4 + analyses, SR7) ---------------------------------
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Compact rollup of the non-actionable runtime bookkeeping ids — a count plus a
|
|
1285
|
+
* small sample. The full per-id list could run to hundreds of entries for a
|
|
1286
|
+
* loop-heavy component (a calendar's 6-week grid emits ~90 binding ids × mount
|
|
1287
|
+
* + interaction ≈ 180), drowning a JSON consumer in noise it can't act on
|
|
1288
|
+
* (#1849 B7). The text report only ever printed the count, so the array carried
|
|
1289
|
+
* no information the summary doesn't.
|
|
1290
|
+
*/
|
|
1291
|
+
export interface DiagnosticsSummary {
|
|
1292
|
+
/** Total distinct anonymous runtime bookkeeping ids encountered. */
|
|
1293
|
+
count: number
|
|
1294
|
+
/** A few example ids (hottest first) for a sanity check — not exhaustive. */
|
|
1295
|
+
sample: string[]
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
export interface ProfileCoverage {
|
|
1299
|
+
/** Distinct handlers exercised (turns observed). */
|
|
1300
|
+
handlersFired: number
|
|
1301
|
+
/** Handlers the IR knows about (`buildEventSummary`). */
|
|
1302
|
+
handlersTotal: number
|
|
1303
|
+
/** SR4 ids the IR could not resolve — the honest, actionable gap. */
|
|
1304
|
+
unattributed: UnattributedId[]
|
|
1305
|
+
/**
|
|
1306
|
+
* Anonymous runtime bookkeeping ids (`s*`/`e*`/`m*`/`r*`) that have no source
|
|
1307
|
+
* node — non-actionable noise, kept out of `unattributed` so the gap list
|
|
1308
|
+
* stays meaningful, but summarized here so nothing is silently dropped.
|
|
1309
|
+
*/
|
|
1310
|
+
diagnostics: DiagnosticsSummary
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
export interface ProfileReport {
|
|
1314
|
+
kind: 'profile'
|
|
1315
|
+
componentName: string
|
|
1316
|
+
sourceFile: string
|
|
1317
|
+
/** Scenario label (e.g. `'auto'` or a scenario file name). */
|
|
1318
|
+
scenario: string
|
|
1319
|
+
/** Total recorded events. */
|
|
1320
|
+
events: number
|
|
1321
|
+
/** Distinct interaction turns. */
|
|
1322
|
+
turns: number
|
|
1323
|
+
hotSubscribers: HotSubscribersResult
|
|
1324
|
+
wastedReReruns: WastedReRunsResult
|
|
1325
|
+
batchAdvisor: BatchAdvisorResult
|
|
1326
|
+
coverage: ProfileCoverage
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
export interface ProfileReportInput {
|
|
1330
|
+
source: string
|
|
1331
|
+
filePath: string
|
|
1332
|
+
componentName?: string
|
|
1333
|
+
scenario: string
|
|
1334
|
+
/** The recorded event log from driving the instrumented component (SR2). */
|
|
1335
|
+
events: readonly ProfilerEvent[]
|
|
1336
|
+
/**
|
|
1337
|
+
* Other sources compiled for the same run — the composed sub-components of a
|
|
1338
|
+
* scenario-file story. Their graphs are merged into the id index so events
|
|
1339
|
+
* from those components resolve too.
|
|
1340
|
+
*/
|
|
1341
|
+
extraSources?: readonly { source: string; filePath: string }[]
|
|
1342
|
+
/** Keep only the top-N hot subscribers by `totalMs` (`--top`). Default: all. */
|
|
1343
|
+
topN?: number
|
|
1344
|
+
/** Drop hot subscribers below this `totalMs` floor (`--hot-ms`). Default: all. */
|
|
1345
|
+
minMs?: number
|
|
1346
|
+
/**
|
|
1347
|
+
* `wastedRatio` threshold (`--wasted-pct`) for the wasted-re-runs analysis, a
|
|
1348
|
+
* fraction in `[0,1]`. Default 0.5.
|
|
1349
|
+
*/
|
|
1350
|
+
wastedRatio?: number
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* Assemble a dynamic profile (SR1–SR4 + analyses, SR7) from a recorded event
|
|
1355
|
+
* stream. Pure: the DOM run that *produces* `events` lives in the driver (the
|
|
1356
|
+
* CLI's scenario harness); this joins them to the IR and ranks findings.
|
|
1357
|
+
* Deterministic — same stream + same source ⇒ same report.
|
|
1358
|
+
*/
|
|
1359
|
+
export function buildProfileReport(input: ProfileReportInput): ProfileReport {
|
|
1360
|
+
const { source, filePath, componentName, scenario, events } = input
|
|
1361
|
+
|
|
1362
|
+
const primary = buildComponentAnalysis(source, filePath, componentName).graph
|
|
1363
|
+
// All sources contributing to the merged index (primary first).
|
|
1364
|
+
const allSources = [{ source, filePath }, ...(input.extraSources ?? [])]
|
|
1365
|
+
|
|
1366
|
+
const index: IdIndex = new Map()
|
|
1367
|
+
// turn id → the handler binding + the component graph that owns it (so the
|
|
1368
|
+
// safety oracle reasons over the right component's memos).
|
|
1369
|
+
const turnBindings = new Map<string, { binding: EventBinding; graph: ComponentGraph }>()
|
|
1370
|
+
let handlersTotal = 0
|
|
1371
|
+
// Per-file lines of the `createEffect` calls the compiler instrumented (top
|
|
1372
|
+
// level, carry a `__bfId`). Subtracted from a source scan to find the
|
|
1373
|
+
// uninstrumented ones behind anonymous `e<n>` ids (#1849 B6).
|
|
1374
|
+
const instrumentedEffectLines = new Map<string, Set<number>>()
|
|
1375
|
+
|
|
1376
|
+
for (const s of allSources) {
|
|
1377
|
+
// A source file may declare several components (e.g. a headless set:
|
|
1378
|
+
// Collapsible + CollapsibleTrigger + CollapsibleContent). Build the TS
|
|
1379
|
+
// program once and reuse it for every component — re-parsing per component
|
|
1380
|
+
// is the dominant cost (a 25-component file like `chart` is ~30s otherwise).
|
|
1381
|
+
const program = createProgramForFile(s.source, s.filePath)?.program
|
|
1382
|
+
let componentNames: string[]
|
|
1383
|
+
try {
|
|
1384
|
+
componentNames = listComponentFunctions(s.source, s.filePath)
|
|
1385
|
+
} catch {
|
|
1386
|
+
componentNames = []
|
|
1387
|
+
}
|
|
1388
|
+
if (componentNames.length === 0) componentNames = [undefined as unknown as string]
|
|
1389
|
+
for (const name of componentNames) {
|
|
1390
|
+
let graph: ComponentGraph
|
|
1391
|
+
try {
|
|
1392
|
+
graph = buildComponentAnalysis(s.source, s.filePath, name, program).graph
|
|
1393
|
+
} catch {
|
|
1394
|
+
continue
|
|
1395
|
+
}
|
|
1396
|
+
for (const [k, v] of buildIdIndex(graph)) index.set(k, v)
|
|
1397
|
+
for (const e of graph.effects) {
|
|
1398
|
+
const set = instrumentedEffectLines.get(e.loc.file) ?? new Set<number>()
|
|
1399
|
+
set.add(e.loc.line)
|
|
1400
|
+
instrumentedEffectLines.set(e.loc.file, set)
|
|
1401
|
+
}
|
|
1402
|
+
try {
|
|
1403
|
+
const summary = buildEventSummary(s.source, s.filePath, name, program)
|
|
1404
|
+
handlersTotal += summary.events.length
|
|
1405
|
+
for (const [turn, binding] of turnToEventBinding(graph, summary.events)) {
|
|
1406
|
+
turnBindings.set(turn, { binding, graph })
|
|
1407
|
+
}
|
|
1408
|
+
} catch {
|
|
1409
|
+
/* a component the analyzer can't summarize contributes no handlers */
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// Candidate sites for uninstrumented `createEffect` ids, across every source
|
|
1415
|
+
// in the run (a compound scenario spans several files), so an `e<n>` row can
|
|
1416
|
+
// cite where to look (#1849 B6). The per-file AST scan is only ever consumed
|
|
1417
|
+
// when a runtime fallback `e<n>` id is in the stream, so skip it entirely in
|
|
1418
|
+
// the common case (every effect compiler-instrumented) where it would be
|
|
1419
|
+
// wasted work.
|
|
1420
|
+
const hasFallbackEffectId = events.some(
|
|
1421
|
+
e => e.subscriber !== undefined && isFallbackEffectId(e.subscriber),
|
|
1422
|
+
)
|
|
1423
|
+
const uninstrumentedCandidates: EffectCandidate[] = []
|
|
1424
|
+
if (hasFallbackEffectId) {
|
|
1425
|
+
for (const s of allSources) {
|
|
1426
|
+
uninstrumentedCandidates.push(
|
|
1427
|
+
...findUninstrumentedEffects(s.source, s.filePath, instrumentedEffectLines.get(s.filePath) ?? new Set()),
|
|
1428
|
+
)
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const hotSubscribers = analyzeHotSubscribers(events, index, {
|
|
1433
|
+
topN: input.topN,
|
|
1434
|
+
minMs: input.minMs,
|
|
1435
|
+
uninstrumentedCandidates,
|
|
1436
|
+
})
|
|
1437
|
+
const wastedReReruns = analyzeWastedReReruns(events, index, { wastedRatio: input.wastedRatio })
|
|
1438
|
+
const batchAdvisor = analyzeBatchAdvisor(events, index)
|
|
1439
|
+
const { unattributed, diagnostics } = joinProfilerEvents(events, index)
|
|
1440
|
+
|
|
1441
|
+
// Safety oracle (§4.2.3): upgrade each candidate from 'unverified'.
|
|
1442
|
+
for (const c of batchAdvisor.candidates) {
|
|
1443
|
+
const hit = turnBindings.get(c.turn)
|
|
1444
|
+
if (!hit) continue
|
|
1445
|
+
c.safety = assessBatchSafety({
|
|
1446
|
+
handler: hit.binding.handler,
|
|
1447
|
+
setterNames: hit.binding.setterCalls.map(s => s.setter),
|
|
1448
|
+
hasIndirectSetters: hit.binding.setterCalls.some(s => s.via && s.via.length > 0),
|
|
1449
|
+
writtenSignals: hit.binding.setterCalls.map(s => s.signal).filter((s): s is string => s !== null),
|
|
1450
|
+
graph: hit.graph,
|
|
1451
|
+
})
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Count distinct turn *invocations* (turnSeq), not handler ids — N clicks of
|
|
1455
|
+
// one handler are N turns. Handlers exercised (coverage) counts distinct ids.
|
|
1456
|
+
const turnSeqs = new Set<string>()
|
|
1457
|
+
const handlerIds = new Set<string>()
|
|
1458
|
+
for (const e of events) {
|
|
1459
|
+
if (e.turn !== null) {
|
|
1460
|
+
turnSeqs.add(String(e.turnSeq ?? e.turn))
|
|
1461
|
+
handlerIds.add(e.turn)
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
return {
|
|
1466
|
+
kind: 'profile',
|
|
1467
|
+
componentName: primary.componentName,
|
|
1468
|
+
sourceFile: primary.sourceFile,
|
|
1469
|
+
scenario,
|
|
1470
|
+
events: events.length,
|
|
1471
|
+
turns: turnSeqs.size,
|
|
1472
|
+
hotSubscribers,
|
|
1473
|
+
wastedReReruns,
|
|
1474
|
+
batchAdvisor,
|
|
1475
|
+
coverage: {
|
|
1476
|
+
handlersFired: handlerIds.size,
|
|
1477
|
+
handlersTotal,
|
|
1478
|
+
unattributed,
|
|
1479
|
+
// Roll the (potentially hundreds of) bookkeeping ids up to a count + a
|
|
1480
|
+
// small sample so JSON consumers aren't flooded (#1849 B7). `diagnostics`
|
|
1481
|
+
// is already sorted hottest-first by `joinProfilerEvents`.
|
|
1482
|
+
diagnostics: { count: diagnostics.length, sample: diagnostics.slice(0, 3).map(d => d.id) },
|
|
1483
|
+
},
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
export function formatProfileReport(r: ProfileReport): string {
|
|
1488
|
+
const lines: string[] = []
|
|
1489
|
+
lines.push(`${r.componentName} — profile (scenario: ${r.scenario})`)
|
|
1490
|
+
lines.push(` ${r.events} events across ${r.turns} turn(s)`)
|
|
1491
|
+
// No interactions measured: either the component has no handlers (use the
|
|
1492
|
+
// static budget) or its handlers live in composed children the auto scenario
|
|
1493
|
+
// couldn't reach (use a --scenario file). Say so plainly rather than leaving
|
|
1494
|
+
// the user with mount-only, mostly-unresolved noise.
|
|
1495
|
+
if (r.turns === 0) {
|
|
1496
|
+
lines.push(
|
|
1497
|
+
r.coverage.handlersTotal === 0
|
|
1498
|
+
? ' note: no event handlers — run `bf debug profile <component>` for the static budget.'
|
|
1499
|
+
: ' note: no interactions measured (handlers are likely in composed children) — try `--scenario <story.tsx>`.',
|
|
1500
|
+
)
|
|
1501
|
+
}
|
|
1502
|
+
lines.push('')
|
|
1503
|
+
lines.push(formatHotSubscribers(r.hotSubscribers))
|
|
1504
|
+
lines.push('')
|
|
1505
|
+
lines.push(formatWastedReReruns(r.wastedReReruns))
|
|
1506
|
+
lines.push('')
|
|
1507
|
+
lines.push(formatBatchAdvisor(r.batchAdvisor))
|
|
1508
|
+
lines.push('')
|
|
1509
|
+
const c = r.coverage
|
|
1510
|
+
lines.push(`coverage: ${c.handlersFired}/${c.handlersTotal} handlers exercised`)
|
|
1511
|
+
if (c.unattributed.length > 0) {
|
|
1512
|
+
lines.push(` ⚠ ${c.unattributed.length} unattributed id(s): ${c.unattributed.slice(0, 3).map(u => u.id).join(', ')}`)
|
|
1513
|
+
}
|
|
1514
|
+
// Anonymous runtime bookkeeping ids (s*/e*/m*/r*) are noted but flagged
|
|
1515
|
+
// non-actionable, so the report is honest about coverage without crying wolf.
|
|
1516
|
+
if (c.diagnostics.count > 0) {
|
|
1517
|
+
lines.push(` · ${c.diagnostics.count} anonymous runtime id(s) (non-actionable bookkeeping)`)
|
|
1518
|
+
}
|
|
1519
|
+
return lines.join('\n')
|
|
1520
|
+
}
|