termcast 1.5.0 → 1.6.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/build.d.ts.map +1 -1
- package/dist/build.js +22 -5
- package/dist/build.js.map +1 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +7 -1
- package/dist/compile.js.map +1 -1
- package/dist/components/bar-graph.d.ts +4 -4
- package/dist/components/bar-graph.js +2 -2
- package/dist/components/list.d.ts +7 -0
- package/dist/components/list.d.ts.map +1 -1
- package/dist/components/list.js +74 -11
- package/dist/components/list.js.map +1 -1
- package/dist/examples/list-detail-height-ratchet.d.ts +2 -0
- package/dist/examples/list-detail-height-ratchet.d.ts.map +1 -0
- package/dist/examples/list-detail-height-ratchet.js +26 -0
- package/dist/examples/list-detail-height-ratchet.js.map +1 -0
- package/dist/extensions/dev.d.ts.map +1 -1
- package/dist/extensions/dev.js +1 -0
- package/dist/extensions/dev.js.map +1 -1
- package/dist/globals.js +8 -0
- package/dist/globals.js.map +1 -1
- package/dist/package-json.d.ts +2 -0
- package/dist/package-json.d.ts.map +1 -1
- package/dist/package-json.js +20 -17
- package/dist/package-json.js.map +1 -1
- package/dist/profiler.d.ts +2 -0
- package/dist/profiler.d.ts.map +1 -0
- package/dist/profiler.js +390 -0
- package/dist/profiler.js.map +1 -0
- package/package.json +14 -15
- package/src/build.tsx +27 -5
- package/src/cli.tsx +0 -0
- package/src/compile.tsx +9 -1
- package/src/compile.vitest.tsx +8 -8
- package/src/components/bar-graph.tsx +9 -9
- package/src/components/list.tsx +92 -11
- package/src/examples/action-shortcut.vitest.tsx +4 -4
- package/src/examples/actions-context.vitest.tsx +2 -2
- package/src/examples/bar-graph-weekly.vitest.tsx +97 -97
- package/src/examples/github.vitest.tsx +17 -26
- package/src/examples/graph-bar-chart.vitest.tsx +36 -36
- package/src/examples/graph-polymarket.vitest.tsx +24 -24
- package/src/examples/graph-row.vitest.tsx +4 -4
- package/src/examples/graph-styles.vitest.tsx +65 -65
- package/src/examples/horizontal-bar-graph-weekly.vitest.tsx +52 -52
- package/src/examples/list-detail-height-ratchet.tsx +48 -0
- package/src/examples/list-detail-height-ratchet.vitest.tsx +161 -0
- package/src/examples/list-detail-metadata.vitest.tsx +49 -49
- package/src/examples/list-dropdown-default.vitest.tsx +27 -27
- package/src/examples/list-fetch-data.vitest.tsx +3 -3
- package/src/examples/list-loading-empty-view.vitest.tsx +1 -1
- package/src/examples/list-no-actions.vitest.tsx +3 -3
- package/src/examples/list-scrollbox.vitest.tsx +6 -6
- package/src/examples/list-spacing-mode.vitest.tsx +1 -1
- package/src/examples/list-with-detail.vitest.tsx +9 -9
- package/src/examples/list-with-dropdown.vitest.tsx +6 -6
- package/src/examples/list-with-sections.vitest.tsx +20 -20
- package/src/examples/list-with-toast.vitest.tsx +4 -4
- package/src/examples/simple-candle-chart.vitest.tsx +61 -59
- package/src/examples/simple-navigation.vitest.tsx +25 -25
- package/src/examples/simple-progress-bar.vitest.tsx +7 -7
- package/src/examples/swift-extension.vitest.tsx +3 -3
- package/src/examples/toast-action.vitest.tsx +4 -4
- package/src/extensions/dev.tsx +2 -1
- package/src/extensions/dev.vitest.tsx +17 -17
- package/src/globals.ts +9 -0
- package/src/package-json.tsx +24 -23
- package/src/profiler.tsx +487 -0
package/src/profiler.tsx
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
// React component profiler for termcast.
|
|
2
|
+
//
|
|
3
|
+
// Captures React 19.2+ PerformanceMeasure entries emitted by the development
|
|
4
|
+
// reconciler (react-reconciler/cjs/react-reconciler.development.js) and writes
|
|
5
|
+
// a .cpuprofile file on process exit. Analyze with profano:
|
|
6
|
+
//
|
|
7
|
+
// TERMCAST_REACT_PROFILE=1 termcast dev ./my-extension
|
|
8
|
+
// bunx profano ./tmp/react-profile-*.cpuprofile --sort self
|
|
9
|
+
// bunx profano ./tmp/react-profile-*.cpuprofile --sort total
|
|
10
|
+
//
|
|
11
|
+
// The reconciler emits performance.measure() calls with:
|
|
12
|
+
// - name: "\u200b" + componentName (component renders)
|
|
13
|
+
// - name: trigger string like "Mount", "Cascading Update", etc.
|
|
14
|
+
// - detail.devtools.track: "Components ⚛" for component renders
|
|
15
|
+
//
|
|
16
|
+
// NOTE: React also emits some entries via console.timeStamp() which are not
|
|
17
|
+
// captured by PerformanceObserver. This profiler captures a useful subset of
|
|
18
|
+
// React's performance track data, not every component render.
|
|
19
|
+
//
|
|
20
|
+
// The profile builds a best-effort call tree from time containment: if measure
|
|
21
|
+
// A fully contains measure B in time, B becomes a child of A. Partially
|
|
22
|
+
// overlapping measures are attached to the nearest containing ancestor.
|
|
23
|
+
// This gives meaningful self vs total times in profano output:
|
|
24
|
+
// - total time = all time inside a measure (including children)
|
|
25
|
+
// - self time = time not attributed to any child measure
|
|
26
|
+
//
|
|
27
|
+
// Requirements:
|
|
28
|
+
// - React 19.2+ in development mode (NODE_ENV !== 'production')
|
|
29
|
+
// - PerformanceObserver available (Node.js 16+, Bun)
|
|
30
|
+
|
|
31
|
+
import fs from 'node:fs'
|
|
32
|
+
import path from 'node:path'
|
|
33
|
+
import { logger } from './logger'
|
|
34
|
+
|
|
35
|
+
interface ReactMeasure {
|
|
36
|
+
name: string
|
|
37
|
+
duration: number
|
|
38
|
+
startTime: number
|
|
39
|
+
track: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const measures: ReactMeasure[] = []
|
|
43
|
+
let observerInstalled = false
|
|
44
|
+
let profileWritten = false
|
|
45
|
+
let perfObserver: PerformanceObserver | null = null
|
|
46
|
+
|
|
47
|
+
function writeProfileOnce(): void {
|
|
48
|
+
if (profileWritten) {
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
profileWritten = true
|
|
52
|
+
// Drain any queued records not yet delivered by the observer callback,
|
|
53
|
+
// so the last measures before exit are captured.
|
|
54
|
+
if (perfObserver) {
|
|
55
|
+
for (const entry of perfObserver.takeRecords()) {
|
|
56
|
+
const detail = (entry as any).detail
|
|
57
|
+
if (!detail?.devtools?.track) {
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
measures.push({
|
|
61
|
+
name: entry.name,
|
|
62
|
+
duration: entry.duration,
|
|
63
|
+
startTime: entry.startTime,
|
|
64
|
+
track: detail.devtools.track,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
writeProfile()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function installProfiler(): void {
|
|
72
|
+
if (observerInstalled) {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (typeof PerformanceObserver === 'undefined') {
|
|
76
|
+
logger.error('PerformanceObserver not available, profiling disabled')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
observerInstalled = true
|
|
81
|
+
|
|
82
|
+
perfObserver = new PerformanceObserver((list) => {
|
|
83
|
+
for (const entry of list.getEntries()) {
|
|
84
|
+
const detail = (entry as any).detail
|
|
85
|
+
if (!detail?.devtools?.track) {
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
measures.push({
|
|
89
|
+
name: entry.name,
|
|
90
|
+
duration: entry.duration,
|
|
91
|
+
startTime: entry.startTime,
|
|
92
|
+
track: detail.devtools.track,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
perfObserver.observe({ type: 'measure', buffered: true })
|
|
98
|
+
|
|
99
|
+
// Hook into the devtools fiber tree to collect component source locations
|
|
100
|
+
// from _debugStack on each commit. This builds the componentSourceMap
|
|
101
|
+
// incrementally as components render.
|
|
102
|
+
installFiberHook()
|
|
103
|
+
|
|
104
|
+
// Write profile on exit signals. The named function reference is used so
|
|
105
|
+
// removeListener actually removes the correct handler, preventing recursion
|
|
106
|
+
// when process.kill re-raises the signal.
|
|
107
|
+
const handleSignal = (signal: NodeJS.Signals) => {
|
|
108
|
+
writeProfileOnce()
|
|
109
|
+
process.removeListener(signal, handleSignal)
|
|
110
|
+
process.kill(process.pid, signal)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
process.on('SIGINT', handleSignal)
|
|
114
|
+
process.on('SIGTERM', handleSignal)
|
|
115
|
+
process.on('exit', writeProfileOnce)
|
|
116
|
+
|
|
117
|
+
logger.log('React profiler installed. Profile will be written on exit.')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface CallFrame {
|
|
121
|
+
functionName: string
|
|
122
|
+
scriptId: string
|
|
123
|
+
url: string
|
|
124
|
+
lineNumber: number
|
|
125
|
+
columnNumber: number
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface ProfileNode {
|
|
129
|
+
id: number
|
|
130
|
+
callFrame: CallFrame
|
|
131
|
+
children: number[]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface Span {
|
|
135
|
+
startUs: number
|
|
136
|
+
endUs: number
|
|
137
|
+
name: string
|
|
138
|
+
track: string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Build a call tree from time containment: if span A fully contains span B,
|
|
142
|
+
// B is a child of A. Each unique (track, name) pair can appear at multiple
|
|
143
|
+
// tree positions when it occurs inside different parent spans.
|
|
144
|
+
//
|
|
145
|
+
// The algorithm: sort spans longest-first so parents come before children.
|
|
146
|
+
// For each span, walk the tree from root finding the deepest ancestor that
|
|
147
|
+
// contains it, then attach a new node there. Each tree node gets a unique ID
|
|
148
|
+
// even if the same component name appears multiple times (different call sites).
|
|
149
|
+
function buildCallTree({ spans, sourceMap }: { spans: Span[]; sourceMap: Map<string, string> }): {
|
|
150
|
+
nodes: ProfileNode[]
|
|
151
|
+
spanToLeafId: Map<number, number>
|
|
152
|
+
} {
|
|
153
|
+
const ROOT_ID = 1
|
|
154
|
+
const IDLE_ID = 2
|
|
155
|
+
const nodes: ProfileNode[] = [
|
|
156
|
+
{
|
|
157
|
+
id: ROOT_ID,
|
|
158
|
+
callFrame: { functionName: '(root)', scriptId: '0', url: '', lineNumber: -1, columnNumber: -1 },
|
|
159
|
+
children: [IDLE_ID],
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: IDLE_ID,
|
|
163
|
+
callFrame: { functionName: '(idle)', scriptId: '0', url: '', lineNumber: -1, columnNumber: -1 },
|
|
164
|
+
children: [],
|
|
165
|
+
},
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
let nextId = 3
|
|
169
|
+
|
|
170
|
+
// Track which tree node each span maps to, plus its time range
|
|
171
|
+
interface TreeEntry {
|
|
172
|
+
nodeId: number
|
|
173
|
+
startUs: number
|
|
174
|
+
endUs: number
|
|
175
|
+
children: TreeEntry[]
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const rootEntry: TreeEntry = {
|
|
179
|
+
nodeId: ROOT_ID,
|
|
180
|
+
startUs: -Infinity,
|
|
181
|
+
endUs: Infinity,
|
|
182
|
+
children: [],
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Sort spans by duration descending so parents (longer) are inserted first
|
|
186
|
+
const indexed = spans.map((s, i) => ({ ...s, originalIndex: i }))
|
|
187
|
+
indexed.sort((a, b) => (b.endUs - b.startUs) - (a.endUs - a.startUs))
|
|
188
|
+
|
|
189
|
+
// Map from original span index to the leaf node ID for sampling
|
|
190
|
+
const spanToLeafId = new Map<number, number>()
|
|
191
|
+
|
|
192
|
+
for (const span of indexed) {
|
|
193
|
+
// Find deepest ancestor in the tree that fully contains this span
|
|
194
|
+
const parent = findDeepestContainer(rootEntry, span.startUs, span.endUs)
|
|
195
|
+
|
|
196
|
+
const id = nextId++
|
|
197
|
+
const newEntry: TreeEntry = {
|
|
198
|
+
nodeId: id,
|
|
199
|
+
startUs: span.startUs,
|
|
200
|
+
endUs: span.endUs,
|
|
201
|
+
children: [],
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Resolve source file path from the component name.
|
|
205
|
+
// Falls back to the React track name (e.g. "Components ⚛") for scheduler
|
|
206
|
+
// events and components not found in source.
|
|
207
|
+
// scriptId is stable per source identity so profano aggregates repeated
|
|
208
|
+
// renders of the same component into one row.
|
|
209
|
+
const sourcePath = sourceMap.get(span.name)
|
|
210
|
+
const sourceMatch = sourcePath ? /^(.*):(\d+)$/.exec(sourcePath) : null
|
|
211
|
+
const url = sourceMatch ? sourceMatch[1] : (sourcePath || span.track)
|
|
212
|
+
const lineNumber = sourceMatch ? Number(sourceMatch[2]) : -1
|
|
213
|
+
const scriptId = sourcePath || `${span.track}:${span.name}`
|
|
214
|
+
|
|
215
|
+
nodes.push({
|
|
216
|
+
id,
|
|
217
|
+
callFrame: {
|
|
218
|
+
functionName: span.name,
|
|
219
|
+
scriptId,
|
|
220
|
+
url,
|
|
221
|
+
lineNumber,
|
|
222
|
+
columnNumber: -1,
|
|
223
|
+
},
|
|
224
|
+
children: [],
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Add as child of parent in both the tree and the profile nodes
|
|
228
|
+
parent.children.push(newEntry)
|
|
229
|
+
const parentNode = nodes.find((n) => n.id === parent.nodeId)!
|
|
230
|
+
if (!parentNode.children.includes(id)) {
|
|
231
|
+
parentNode.children.push(id)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
spanToLeafId.set(span.originalIndex, id)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { nodes, spanToLeafId }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function findDeepestContainer(
|
|
241
|
+
entry: { nodeId: number; startUs: number; endUs: number; children: Array<{ nodeId: number; startUs: number; endUs: number; children: any[] }> },
|
|
242
|
+
startUs: number,
|
|
243
|
+
endUs: number,
|
|
244
|
+
): { nodeId: number; startUs: number; endUs: number; children: any[] } {
|
|
245
|
+
// Check children for a tighter fit
|
|
246
|
+
for (const child of entry.children) {
|
|
247
|
+
if (child.startUs <= startUs && child.endUs >= endUs) {
|
|
248
|
+
return findDeepestContainer(child, startUs, endUs)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return entry
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Component name → source file:line mapping built from React's fiber _debugStack.
|
|
255
|
+
// Populated at runtime by hooking into __REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot
|
|
256
|
+
// which gives us the actual fiber tree with debug stack traces. Each fiber's _debugStack
|
|
257
|
+
// is an Error whose second stack frame points to where the JSX element was created.
|
|
258
|
+
// This is much more accurate than regex scanning source files.
|
|
259
|
+
const componentSourceMap = new Map<string, string>()
|
|
260
|
+
|
|
261
|
+
// Extract "file:line" from a fiber's _debugStack Error.
|
|
262
|
+
// The stack trace format varies between Bun and Node:
|
|
263
|
+
// at functionName (/path/to/file.tsx:42:5) — named frame
|
|
264
|
+
// at /path/to/file.tsx:42:5 — anonymous frame
|
|
265
|
+
// We want the first non-React-internal frame. node_modules paths are kept
|
|
266
|
+
// so components from third-party packages show their real location.
|
|
267
|
+
function parseFrameLocation(frame: string): { filePath: string; line: string } | null {
|
|
268
|
+
// Named frame: at fn (/path/file.tsx:1:2)
|
|
269
|
+
const parenthesized = /\((.+):(\d+):\d+\)\s*$/.exec(frame)
|
|
270
|
+
if (parenthesized) {
|
|
271
|
+
return { filePath: parenthesized[1], line: parenthesized[2] }
|
|
272
|
+
}
|
|
273
|
+
// Anonymous frame: at /path/file.tsx:1:2
|
|
274
|
+
const bare = /^\s*at (.+):(\d+):\d+\s*$/.exec(frame)
|
|
275
|
+
if (bare) {
|
|
276
|
+
return { filePath: bare[1], line: bare[2] }
|
|
277
|
+
}
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function extractSourceFromFiber(fiber: any): string | null {
|
|
282
|
+
const debugStack = fiber._debugStack
|
|
283
|
+
if (!debugStack) {
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
const stack = debugStack.stack || String(debugStack)
|
|
287
|
+
const frames = stack.split('\n')
|
|
288
|
+
for (const frame of frames) {
|
|
289
|
+
// Skip only React internals, keep everything else including node_modules
|
|
290
|
+
if (
|
|
291
|
+
frame.includes('react.development') ||
|
|
292
|
+
frame.includes('react-jsx') ||
|
|
293
|
+
frame.includes('react-reconciler')
|
|
294
|
+
) {
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
const loc = parseFrameLocation(frame)
|
|
298
|
+
if (loc) {
|
|
299
|
+
const relativePath = path.relative(process.cwd(), loc.filePath)
|
|
300
|
+
return `${relativePath}:${loc.line}`
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return null
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Get display name from a fiber, handling memo/forwardRef wrappers.
|
|
307
|
+
// React wraps components in objects with .type or .render for these HOCs.
|
|
308
|
+
function getFiberComponentName(fiber: any): string | null {
|
|
309
|
+
const type = fiber.type
|
|
310
|
+
if (!type) {
|
|
311
|
+
return null
|
|
312
|
+
}
|
|
313
|
+
// Direct function component or class
|
|
314
|
+
if (type.displayName || type.name) {
|
|
315
|
+
return type.displayName || type.name
|
|
316
|
+
}
|
|
317
|
+
// memo(Component) — type is { $$typeof: REACT_MEMO_TYPE, type: innerComponent }
|
|
318
|
+
if (type.type?.displayName || type.type?.name) {
|
|
319
|
+
return type.type.displayName || type.type.name
|
|
320
|
+
}
|
|
321
|
+
// forwardRef(Component) — type is { $$typeof: REACT_FORWARD_REF_TYPE, render: fn }
|
|
322
|
+
if (type.render?.displayName || type.render?.name) {
|
|
323
|
+
return type.render.displayName || type.render.name
|
|
324
|
+
}
|
|
325
|
+
return null
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Iterative fiber tree walk to avoid stack overflow on large flat lists.
|
|
329
|
+
// Sibling chains can be hundreds deep; recursion would overflow.
|
|
330
|
+
function walkFiberTree(root: any): void {
|
|
331
|
+
const stack: any[] = root ? [root] : []
|
|
332
|
+
while (stack.length > 0) {
|
|
333
|
+
const fiber = stack.pop()
|
|
334
|
+
const name = getFiberComponentName(fiber)
|
|
335
|
+
if (name && !componentSourceMap.has(name)) {
|
|
336
|
+
const source = extractSourceFromFiber(fiber)
|
|
337
|
+
if (source) {
|
|
338
|
+
componentSourceMap.set(name, source)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (fiber.sibling) {
|
|
342
|
+
stack.push(fiber.sibling)
|
|
343
|
+
}
|
|
344
|
+
if (fiber.child) {
|
|
345
|
+
stack.push(fiber.child)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function installFiberHook(): void {
|
|
351
|
+
const hook = (globalThis as any).__REACT_DEVTOOLS_GLOBAL_HOOK__
|
|
352
|
+
if (!hook) {
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
// Preserve all arguments and `this` so React Refresh and other hooks
|
|
356
|
+
// that depend on priorityLevel and didError continue to work.
|
|
357
|
+
const originalOnCommit = hook.onCommitFiberRoot
|
|
358
|
+
hook.onCommitFiberRoot = function (this: any, ...args: unknown[]) {
|
|
359
|
+
const root = args[1] as { current?: any } | undefined
|
|
360
|
+
try {
|
|
361
|
+
walkFiberTree(root?.current)
|
|
362
|
+
} finally {
|
|
363
|
+
return originalOnCommit?.apply(this, args)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function writeProfile(): void {
|
|
369
|
+
if (measures.length === 0) {
|
|
370
|
+
logger.log('No React performance measures captured, skipping profile write')
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const TICK = 1000 // microseconds per sample (1ms resolution)
|
|
375
|
+
|
|
376
|
+
// componentSourceMap was populated incrementally by the fiber hook during rendering
|
|
377
|
+
const sourceMap = componentSourceMap
|
|
378
|
+
|
|
379
|
+
const sorted = [...measures].sort((a, b) => a.startTime - b.startTime)
|
|
380
|
+
const t0 = sorted[0].startTime
|
|
381
|
+
const endUs = Math.round(
|
|
382
|
+
(Math.max(...sorted.map((m) => m.startTime + m.duration)) - t0) * 1000,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
// Convert measures to spans with microsecond timestamps
|
|
386
|
+
const spans: Span[] = sorted.map((m) => ({
|
|
387
|
+
startUs: Math.round((m.startTime - t0) * 1000),
|
|
388
|
+
endUs: Math.round((m.startTime + m.duration - t0) * 1000),
|
|
389
|
+
name: m.name.replace('\u200b', ''),
|
|
390
|
+
track: m.track,
|
|
391
|
+
}))
|
|
392
|
+
|
|
393
|
+
// Build call tree from time containment, passing sourceMap for file paths
|
|
394
|
+
const { nodes, spanToLeafId } = buildCallTree({ spans, sourceMap })
|
|
395
|
+
|
|
396
|
+
// Generate samples only over active span windows and compress idle gaps.
|
|
397
|
+
// Instead of iterating every tick across the full timeline (which is O(ticks * spans)
|
|
398
|
+
// and can hang for long sessions), collect all span boundaries, sort them, and only
|
|
399
|
+
// sample within active windows. Idle gaps between windows become a single idle sample
|
|
400
|
+
// with a large timeDelta.
|
|
401
|
+
const samples: number[] = []
|
|
402
|
+
const timeDeltas: number[] = []
|
|
403
|
+
|
|
404
|
+
const IDLE_ID = 2
|
|
405
|
+
|
|
406
|
+
// Collect unique boundary times from all spans
|
|
407
|
+
const boundaries = new Set<number>()
|
|
408
|
+
for (const span of spans) {
|
|
409
|
+
boundaries.add(span.startUs)
|
|
410
|
+
boundaries.add(span.endUs)
|
|
411
|
+
}
|
|
412
|
+
// Add timeline start/end
|
|
413
|
+
boundaries.add(0)
|
|
414
|
+
boundaries.add(endUs)
|
|
415
|
+
|
|
416
|
+
const sortedBoundaries = [...boundaries].sort((a, b) => a - b)
|
|
417
|
+
|
|
418
|
+
// Sort spans narrowest-first for fast deepest-leaf lookup
|
|
419
|
+
const spansByNarrowest = spans
|
|
420
|
+
.map((s, i) => ({ ...s, idx: i }))
|
|
421
|
+
.sort((a, b) => (a.endUs - a.startUs) - (b.endUs - b.startUs))
|
|
422
|
+
|
|
423
|
+
// For each window between consecutive boundaries, determine if any span is
|
|
424
|
+
// active. If yes, sample at TICK resolution. If no, emit one idle sample.
|
|
425
|
+
for (let w = 0; w < sortedBoundaries.length - 1; w++) {
|
|
426
|
+
const windowStart = sortedBoundaries[w]
|
|
427
|
+
const windowEnd = sortedBoundaries[w + 1]
|
|
428
|
+
if (windowStart >= windowEnd) {
|
|
429
|
+
continue
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Check if any span is active at the midpoint of this window
|
|
433
|
+
const mid = windowStart + Math.floor((windowEnd - windowStart) / 2)
|
|
434
|
+
let hasActiveSpan = false
|
|
435
|
+
for (const span of spansByNarrowest) {
|
|
436
|
+
if (mid >= span.startUs && mid < span.endUs) {
|
|
437
|
+
hasActiveSpan = true
|
|
438
|
+
break
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (!hasActiveSpan) {
|
|
443
|
+
// Compress idle window into a single sample
|
|
444
|
+
samples.push(IDLE_ID)
|
|
445
|
+
timeDeltas.push(windowEnd - windowStart)
|
|
446
|
+
continue
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Sample at TICK resolution within this active window.
|
|
450
|
+
// Use Math.min so the last sample's timeDelta covers only the remainder,
|
|
451
|
+
// preventing inflation when the window is shorter than TICK or not divisible.
|
|
452
|
+
for (let t = windowStart; t < windowEnd; t += TICK) {
|
|
453
|
+
const nextT = Math.min(t + TICK, windowEnd)
|
|
454
|
+
let leafId = IDLE_ID
|
|
455
|
+
for (const span of spansByNarrowest) {
|
|
456
|
+
if (t >= span.startUs && t < span.endUs) {
|
|
457
|
+
leafId = spanToLeafId.get(span.idx) ?? IDLE_ID
|
|
458
|
+
break
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
samples.push(leafId)
|
|
462
|
+
timeDeltas.push(nextT - t)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const profile = {
|
|
467
|
+
nodes,
|
|
468
|
+
samples,
|
|
469
|
+
startTime: 0,
|
|
470
|
+
endTime: endUs,
|
|
471
|
+
timeDeltas,
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Write to ./tmp/react-profile.cpuprofile relative to cwd
|
|
475
|
+
const outDir = path.join(process.cwd(), 'tmp')
|
|
476
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
477
|
+
const outPath = path.join(outDir, `react-profile-${Date.now()}.cpuprofile`)
|
|
478
|
+
fs.writeFileSync(outPath, JSON.stringify(profile))
|
|
479
|
+
|
|
480
|
+
const activeSamples = samples.filter((s) => s !== 2).length
|
|
481
|
+
logger.log(
|
|
482
|
+
`Wrote React profile: ${outPath} (${measures.length} measures, ${nodes.length} nodes, ${activeSamples} active / ${samples.length} total samples)`,
|
|
483
|
+
)
|
|
484
|
+
console.error(
|
|
485
|
+
`\nReact profile written: ${outPath}\nAnalyze with: npx profano ${outPath} --sort self`,
|
|
486
|
+
)
|
|
487
|
+
}
|