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.
Files changed (68) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/build.js +22 -5
  3. package/dist/build.js.map +1 -1
  4. package/dist/compile.d.ts.map +1 -1
  5. package/dist/compile.js +7 -1
  6. package/dist/compile.js.map +1 -1
  7. package/dist/components/bar-graph.d.ts +4 -4
  8. package/dist/components/bar-graph.js +2 -2
  9. package/dist/components/list.d.ts +7 -0
  10. package/dist/components/list.d.ts.map +1 -1
  11. package/dist/components/list.js +74 -11
  12. package/dist/components/list.js.map +1 -1
  13. package/dist/examples/list-detail-height-ratchet.d.ts +2 -0
  14. package/dist/examples/list-detail-height-ratchet.d.ts.map +1 -0
  15. package/dist/examples/list-detail-height-ratchet.js +26 -0
  16. package/dist/examples/list-detail-height-ratchet.js.map +1 -0
  17. package/dist/extensions/dev.d.ts.map +1 -1
  18. package/dist/extensions/dev.js +1 -0
  19. package/dist/extensions/dev.js.map +1 -1
  20. package/dist/globals.js +8 -0
  21. package/dist/globals.js.map +1 -1
  22. package/dist/package-json.d.ts +2 -0
  23. package/dist/package-json.d.ts.map +1 -1
  24. package/dist/package-json.js +20 -17
  25. package/dist/package-json.js.map +1 -1
  26. package/dist/profiler.d.ts +2 -0
  27. package/dist/profiler.d.ts.map +1 -0
  28. package/dist/profiler.js +390 -0
  29. package/dist/profiler.js.map +1 -0
  30. package/package.json +14 -15
  31. package/src/build.tsx +27 -5
  32. package/src/cli.tsx +0 -0
  33. package/src/compile.tsx +9 -1
  34. package/src/compile.vitest.tsx +8 -8
  35. package/src/components/bar-graph.tsx +9 -9
  36. package/src/components/list.tsx +92 -11
  37. package/src/examples/action-shortcut.vitest.tsx +4 -4
  38. package/src/examples/actions-context.vitest.tsx +2 -2
  39. package/src/examples/bar-graph-weekly.vitest.tsx +97 -97
  40. package/src/examples/github.vitest.tsx +17 -26
  41. package/src/examples/graph-bar-chart.vitest.tsx +36 -36
  42. package/src/examples/graph-polymarket.vitest.tsx +24 -24
  43. package/src/examples/graph-row.vitest.tsx +4 -4
  44. package/src/examples/graph-styles.vitest.tsx +65 -65
  45. package/src/examples/horizontal-bar-graph-weekly.vitest.tsx +52 -52
  46. package/src/examples/list-detail-height-ratchet.tsx +48 -0
  47. package/src/examples/list-detail-height-ratchet.vitest.tsx +161 -0
  48. package/src/examples/list-detail-metadata.vitest.tsx +49 -49
  49. package/src/examples/list-dropdown-default.vitest.tsx +27 -27
  50. package/src/examples/list-fetch-data.vitest.tsx +3 -3
  51. package/src/examples/list-loading-empty-view.vitest.tsx +1 -1
  52. package/src/examples/list-no-actions.vitest.tsx +3 -3
  53. package/src/examples/list-scrollbox.vitest.tsx +6 -6
  54. package/src/examples/list-spacing-mode.vitest.tsx +1 -1
  55. package/src/examples/list-with-detail.vitest.tsx +9 -9
  56. package/src/examples/list-with-dropdown.vitest.tsx +6 -6
  57. package/src/examples/list-with-sections.vitest.tsx +20 -20
  58. package/src/examples/list-with-toast.vitest.tsx +4 -4
  59. package/src/examples/simple-candle-chart.vitest.tsx +61 -59
  60. package/src/examples/simple-navigation.vitest.tsx +25 -25
  61. package/src/examples/simple-progress-bar.vitest.tsx +7 -7
  62. package/src/examples/swift-extension.vitest.tsx +3 -3
  63. package/src/examples/toast-action.vitest.tsx +4 -4
  64. package/src/extensions/dev.tsx +2 -1
  65. package/src/extensions/dev.vitest.tsx +17 -17
  66. package/src/globals.ts +9 -0
  67. package/src/package-json.tsx +24 -23
  68. package/src/profiler.tsx +487 -0
@@ -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
+ }