@playfast/reform-profiler 1.0.1

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/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@playfast/reform-profiler",
3
+ "playbook": "./playbook",
4
+ "version": "1.0.1",
5
+ "type": "module",
6
+ "description": "Performance profiler for reform scenes — a recording Instrumentation, Chrome DevTools trace export, terminal reports, and a React overlay. Dev builds get the real recorder; prod builds resolve to no-op stubs.",
7
+ "keywords": [
8
+ "reform",
9
+ "effect",
10
+ "profiler",
11
+ "performance",
12
+ "devtools"
13
+ ],
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/playfast/reform.git",
18
+ "directory": "packages/reform-profiler"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/playfast/reform/issues"
22
+ },
23
+ "sideEffects": false,
24
+ "exports": {
25
+ "./package.json": "./package.json",
26
+ ".": {
27
+ "development": "./src/index.ts",
28
+ "default": "./src/noop.ts"
29
+ },
30
+ "./react": {
31
+ "development": "./src/react/index.ts",
32
+ "default": "./src/react/noop.ts"
33
+ },
34
+ "./recorder": "./src/index.ts"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "README.md"
39
+ ],
40
+ "scripts": {
41
+ "clean": "rm -rf dist .tsbuildinfo",
42
+ "check": "tsc --noEmit",
43
+ "build": "tsc -p tsconfig.build.json",
44
+ "test": "vitest run",
45
+ "test:watch": "vitest",
46
+ "coverage": "vitest run --coverage",
47
+ "lint": "oxlint src",
48
+ "lint:fix": "oxlint --fix src"
49
+ },
50
+ "peerDependencies": {
51
+ "effect": "*",
52
+ "@playfast/reform": "*",
53
+ "react": "^19.0.0"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "react": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "publishConfig": {
61
+ "access": "public"
62
+ }
63
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ // @playfast/reform-profiler — the REAL entry, resolved under the `development`
2
+ // export condition (and unconditionally via `./recorder`, for tests and
3
+ // headless scripts whose runner doesn't set dev conditions). Production
4
+ // bundles resolve `.` to `./noop.ts` instead.
5
+
6
+ export {
7
+ makeProfiler,
8
+ type ProfileKind,
9
+ type ProfileRecord,
10
+ type ProfilerHandle,
11
+ type ProfilerOptionsExternalApi,
12
+ type ProfileSnapshot,
13
+ type ProfileStat,
14
+ } from './recorder'
15
+ export { type ProfiledScene, profileScene } from './profileScene'
16
+ export { type ChromeTrace, type ChromeTraceEvent, toChromeTrace, traceJson } from './trace'
17
+ export { formatReport } from './report'
18
+ export {
19
+ claimOverlay,
20
+ listProfilers,
21
+ type ProfilerRegistration,
22
+ profilersVersion,
23
+ registerProfiler,
24
+ releaseOverlay,
25
+ subscribeProfilers,
26
+ } from './registry'
package/src/noop.ts ADDED
@@ -0,0 +1,79 @@
1
+ import { noopInstrumentation, type Scene, type UiContract } from '@playfast/reform'
2
+ import type { ProfiledScene } from './profileScene'
3
+ import type {
4
+ ProfilerHandle,
5
+ ProfilerOptionsExternalApi,
6
+ ProfileSnapshot,
7
+ } from './recorder'
8
+ import type { ProfilerRegistration } from './registry'
9
+ import type { ChromeTrace } from './trace'
10
+
11
+ // @playfast/reform-profiler — the PRODUCTION stub, resolved under the
12
+ // `default` export condition. Same surface as `./index.ts`, zero recording:
13
+ // `profileScene` hands the scene back untouched, every reader yields empty
14
+ // data, and none of the recorder/trace/report machinery is reachable from a
15
+ // prod bundle. Types re-export from the real modules (erased at runtime).
16
+
17
+ export type {
18
+ ProfileKind,
19
+ ProfileRecord,
20
+ ProfilerHandle,
21
+ ProfilerOptionsExternalApi,
22
+ ProfileSnapshot,
23
+ ProfileStat,
24
+ } from './recorder'
25
+ export type { ProfiledScene } from './profileScene'
26
+ export type { ChromeTrace, ChromeTraceEvent } from './trace'
27
+ export type { ProfilerRegistration } from './registry'
28
+
29
+ const emptySnapshot: ProfileSnapshot = {
30
+ events: new Map(),
31
+ reducers: new Map(),
32
+ states: new Map(),
33
+ calcs: new Map(),
34
+ queries: new Map(),
35
+ procedures: new Map(),
36
+ renders: new Map(),
37
+ frames: { count: 0, totalMs: 0 },
38
+ records: [],
39
+ dropped: 0,
40
+ }
41
+
42
+ /** The shared inert handle every stub call hands back. */
43
+ export const noopProfiler: ProfilerHandle = {
44
+ ...noopInstrumentation,
45
+ label: 'noop',
46
+ snapshot: () => emptySnapshot,
47
+ clear: () => {},
48
+ subscribe: () => () => {},
49
+ getVersion: () => 0,
50
+ }
51
+
52
+ export const makeProfiler = (_options: ProfilerOptionsExternalApi = {}): ProfilerHandle =>
53
+ noopProfiler
54
+
55
+ export const profileScene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
56
+ base: Scene<C, S>,
57
+ _options: ProfilerOptionsExternalApi = {},
58
+ ): ProfiledScene<C, S> => ({ scene: base, profiler: noopProfiler })
59
+
60
+ const emptyTrace: ChromeTrace = { traceEvents: [], displayTimeUnit: 'ms' }
61
+
62
+ export const toChromeTrace = (_profiler: ProfilerHandle): ChromeTrace => emptyTrace
63
+
64
+ export const traceJson = (_profiler: ProfilerHandle): string =>
65
+ '{"traceEvents":[],"displayTimeUnit":"ms"}'
66
+
67
+ export const formatReport = (_profiler: ProfilerHandle): string => ''
68
+
69
+ export const registerProfiler = (_registration: ProfilerRegistration): (() => void) => () => {}
70
+
71
+ export const listProfilers = (): ReadonlyArray<ProfilerRegistration> => []
72
+
73
+ export const subscribeProfilers = (_listener: () => void): (() => void) => () => {}
74
+
75
+ export const profilersVersion = (): number => 0
76
+
77
+ export const claimOverlay = (_owner: object): boolean => false
78
+
79
+ export const releaseOverlay = (_owner: object): void => {}
@@ -0,0 +1,56 @@
1
+ import { Effect, Layer } from 'effect'
2
+ import { profiledScene, type Scene, type UiContract } from '@playfast/reform'
3
+ import { makeProfiler, type ProfilerHandle, type ProfilerOptionsExternalApi } from './recorder'
4
+ import { registerProfiler } from './registry'
5
+
6
+ /** A scene wired to record into `profiler`, plus the handle to read it back. */
7
+ export interface ProfiledScene<
8
+ C extends UiContract = UiContract,
9
+ S extends ReadonlyArray<unknown> = ReadonlyArray<unknown>,
10
+ > {
11
+ readonly scene: Scene<C, S>
12
+ readonly profiler: ProfilerHandle
13
+ }
14
+
15
+ /**
16
+ * Opt a CLOSED scene into profiling: every engine hook (events, reducers,
17
+ * states, calcs, queries, procedures) records into a fresh profiler, and hosts
18
+ * (React `Compose`, the proof engine) pick it up for render spans via
19
+ * `sceneInstrumentation`. The original scene is untouched — hand the returned
20
+ * `scene` to `<Reform/>`, `drive`, or `makeAppRuntime`.
21
+ *
22
+ * The profiler registers in the overlay registry while a runtime built from
23
+ * the scene is live (register on layer build, unregister on scope close), so
24
+ * `<ReformProfiler/>` shows one tab per live profiled runtime.
25
+ *
26
+ * In a production build (`default` export condition) this module is replaced
27
+ * by a passthrough stub — the scene comes back untouched and the handle is a
28
+ * shared no-op.
29
+ */
30
+ export const profileScene = <C extends UiContract, S extends ReadonlyArray<unknown>>(
31
+ base: Scene<C, S>,
32
+ options: ProfilerOptionsExternalApi = {},
33
+ ): ProfiledScene<C, S> => {
34
+ const profiler = makeProfiler({
35
+ label: options.label ?? base.composition.manifest.title,
36
+ ...(options.capacity !== undefined ? { capacity: options.capacity } : {}),
37
+ })
38
+ const lifecycle = Layer.scopedDiscard(
39
+ Effect.acquireRelease(
40
+ Effect.sync(() => registerProfiler({ label: profiler.label, handle: profiler })),
41
+ (unregister) => Effect.sync(unregister),
42
+ ),
43
+ )
44
+ const wrapped = profiledScene(base, profiler)
45
+ return {
46
+ // The lifecycle layer rides the first provide layer (`merge` keeps its
47
+ // services), so registration follows the runtime's real build/dispose.
48
+ scene: {
49
+ ...wrapped,
50
+ provide: wrapped.provide.map((layer, index) =>
51
+ index === 0 ? Layer.merge(layer, lifecycle) : layer,
52
+ ),
53
+ },
54
+ profiler,
55
+ }
56
+ }
@@ -0,0 +1,178 @@
1
+ import { Schema as S } from 'effect'
2
+ import { expect, test } from 'vitest'
3
+ import {
4
+ Composition,
5
+ Event,
6
+ Engine,
7
+ makeAppRuntime,
8
+ mount,
9
+ provide,
10
+ Reducer,
11
+ scene,
12
+ sceneInstrumentation,
13
+ State,
14
+ StateGroup,
15
+ Ui,
16
+ ui,
17
+ } from '@playfast/reform'
18
+ import { Layer } from 'effect'
19
+ import { makeProfiler } from './recorder'
20
+ import { profileScene } from './profileScene'
21
+ import { listProfilers } from './registry'
22
+ import { toChromeTrace, traceJson } from './trace'
23
+ import { formatReport } from './report'
24
+
25
+ const settle = (ms = 20) => new Promise((resolve) => setTimeout(resolve, ms))
26
+
27
+ // ── recorder ──────────────────────────────────────────────────────────────────
28
+
29
+ test('aggregates count and time per name, per kind', () => {
30
+ const profiler = makeProfiler({ label: 'agg' })
31
+ profiler.eventDispatched('Bumped', 'High')
32
+ profiler.eventDispatched('Bumped', 'Normal')
33
+ profiler.eventDispatched('Reset', 'High')
34
+ profiler.reducerRun('fold', 'Bumped')()
35
+ profiler.reducerRun('fold', 'Bumped')()
36
+ profiler.stateUpdated('count')
37
+
38
+ const snapshot = profiler.snapshot()
39
+ expect(snapshot.events.get('Bumped')?.count).toBe(2)
40
+ expect(snapshot.events.get('Reset')?.count).toBe(1)
41
+ expect(snapshot.reducers.get('fold')?.count).toBe(2)
42
+ expect(snapshot.reducers.get('fold')?.totalMs).toBeGreaterThanOrEqual(0)
43
+ expect(snapshot.states.get('count')?.count).toBe(1)
44
+ expect(snapshot.records.length).toBe(6)
45
+ expect(snapshot.dropped).toBe(0)
46
+ })
47
+
48
+ test('the ring buffer wraps: newest records survive, aggregates keep the full tally', () => {
49
+ const profiler = makeProfiler({ capacity: 4 })
50
+ const tags = ['a', 'b', 'c', 'd', 'e', 'f']
51
+ tags.forEach((tag) => profiler.eventDispatched(tag, 'Normal'))
52
+
53
+ const snapshot = profiler.snapshot()
54
+ expect(snapshot.records.map((record) => record.name)).toEqual(['c', 'd', 'e', 'f'])
55
+ expect(snapshot.dropped).toBe(2)
56
+ // Evicted records still count in the aggregates.
57
+ expect(snapshot.events.size).toBe(6)
58
+ expect(snapshot.events.get('a')?.count).toBe(1)
59
+ })
60
+
61
+ test('clear drops records, aggregates, and bumps the version', () => {
62
+ const profiler = makeProfiler()
63
+ profiler.eventDispatched('x', 'High')
64
+ const before = profiler.getVersion()
65
+ profiler.clear()
66
+ expect(profiler.getVersion()).toBeGreaterThan(before)
67
+ const snapshot = profiler.snapshot()
68
+ expect(snapshot.records).toEqual([])
69
+ expect(snapshot.events.size).toBe(0)
70
+ expect(snapshot.dropped).toBe(0)
71
+ })
72
+
73
+ test('subscribers are notified once per burst (coalesced)', async () => {
74
+ const profiler = makeProfiler()
75
+ const calls = { count: 0 }
76
+ const unsubscribe = profiler.subscribe(() => {
77
+ calls.count += 1
78
+ })
79
+ profiler.eventDispatched('a', 'High')
80
+ profiler.eventDispatched('b', 'High')
81
+ profiler.eventDispatched('c', 'High')
82
+ await settle(0)
83
+ expect(calls.count).toBe(1)
84
+ unsubscribe()
85
+ profiler.eventDispatched('d', 'High')
86
+ await settle(0)
87
+ expect(calls.count).toBe(1)
88
+ })
89
+
90
+ // ── trace export ──────────────────────────────────────────────────────────────
91
+
92
+ test('toChromeTrace emits metadata lanes, X spans, and i instants', () => {
93
+ const profiler = makeProfiler({ label: 'traced' })
94
+ profiler.eventDispatched('Bumped', 'High')
95
+ profiler.reducerRun('fold', 'Bumped')()
96
+
97
+ const trace = toChromeTrace(profiler)
98
+ const meta = trace.traceEvents.filter((event) => event.ph === 'M')
99
+ expect(meta.some((event) => event.name === 'process_name')).toBe(true)
100
+ expect(meta.filter((event) => event.name === 'thread_name').length).toBe(8)
101
+
102
+ const instant = trace.traceEvents.find((event) => event.ph === 'i')
103
+ expect(instant?.name).toBe('Bumped')
104
+ const span = trace.traceEvents.find((event) => event.ph === 'X')
105
+ expect(span?.name).toBe('fold')
106
+ if (span?.ph === 'X') {
107
+ expect(span.dur).toBeGreaterThanOrEqual(0)
108
+ expect(typeof span.ts).toBe('number')
109
+ }
110
+
111
+ const parsed: unknown = JSON.parse(traceJson(profiler))
112
+ expect(parsed).toEqual(trace)
113
+ })
114
+
115
+ // ── profileScene end-to-end over the headless app runtime ────────────────────
116
+
117
+ class Count extends State.make('profiler-count', S.Number) {}
118
+ class States extends StateGroup.make(Count) {}
119
+ class Bumped extends Event.make('ProfilerBumped', S.Struct({})) {}
120
+ class Bump extends Reducer.make('ProfilerBump', { states: [Count], events: [Bumped] }) {}
121
+ const BumpLive = Reducer.live(Bump, (n) => n + 1)
122
+
123
+ class RootUi extends ui('ProfilerRoot')<{ props: { count: number } }>() {}
124
+ class Root extends Composition.make('ProfilerRoot', {
125
+ title: 'ProfilerRoot',
126
+ states: [States],
127
+ events: [Bumped],
128
+ ui: RootUi,
129
+ }) {}
130
+ const RootLive = Composition.live(Root, function* () {
131
+ const count = yield* StateGroup.select(States, 'profiler-count')
132
+ return mount({ props: { count }, slots: {} })
133
+ })
134
+
135
+ const app = RootLive.pipe(
136
+ Layer.provideMerge(provide(RootUi, Ui.make(RootUi, () => null))),
137
+ Layer.provideMerge(
138
+ Layer.mergeAll(BumpLive).pipe(
139
+ Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(States, { 'profiler-count': 0 }))),
140
+ ),
141
+ ),
142
+ )
143
+ const baseScene = scene(Root, { provide: [app], boot: [Event.construct(Bumped, {})] })
144
+
145
+ test('profileScene records engine activity through makeAppRuntime and registers while live', async () => {
146
+ const { scene: profiled, profiler } = profileScene(baseScene)
147
+ expect(sceneInstrumentation(profiled)).toBe(profiler)
148
+ expect(profiler.label).toBe('ProfilerRoot')
149
+
150
+ const runtime = makeAppRuntime(profiled)
151
+ expect(listProfilers().map((entry) => entry.handle)).toContain(profiler)
152
+
153
+ runtime.boot()
154
+ runtime.dispatch('High', Event.construct(Bumped, {}))
155
+ await settle()
156
+
157
+ const snapshot = profiler.snapshot()
158
+ expect(snapshot.events.get('ProfilerBumped')?.count).toBe(2)
159
+ expect(snapshot.reducers.get('ProfilerBump')?.count).toBe(2)
160
+ expect(snapshot.states.get('profiler-count')?.count).toBe(2)
161
+ expect(snapshot.frames.count).toBeGreaterThanOrEqual(1)
162
+
163
+ const report = formatReport(profiler)
164
+ expect(report).toContain('ProfilerRoot')
165
+ expect(report).toContain('ProfilerBump')
166
+
167
+ runtime.dispose()
168
+ await settle()
169
+ expect(listProfilers().map((entry) => entry.handle)).not.toContain(profiler)
170
+
171
+ // The base scene is untouched: no instrumentation carrier, nothing recorded.
172
+ const before = profiler.snapshot().events.get('ProfilerBumped')?.count
173
+ const plain = makeAppRuntime(baseScene)
174
+ plain.dispatch('High', Event.construct(Bumped, {}))
175
+ await settle()
176
+ expect(profiler.snapshot().events.get('ProfilerBumped')?.count).toBe(before)
177
+ plain.dispose()
178
+ })
@@ -0,0 +1,5 @@
1
+ // @playfast/reform-profiler/react — dev entry (`development` export condition).
2
+ // The floating profiler panel plus the browser trace download. The prod build
3
+ // resolves `./noop.ts` instead, so none of this reaches a production bundle.
4
+
5
+ export { downloadTrace, ReformProfiler } from './overlay'
@@ -0,0 +1,9 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { ProfilerHandle } from '../recorder'
3
+
4
+ // @playfast/reform-profiler/react — the PRODUCTION stub (`default` export
5
+ // condition). `<ReformProfiler/>` renders nothing; `downloadTrace` is inert.
6
+
7
+ export const ReformProfiler = (): ReactNode => null
8
+
9
+ export const downloadTrace = (_profiler: ProfilerHandle): void => {}
@@ -0,0 +1,215 @@
1
+ import type { ReactNode } from 'react'
2
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
3
+ import type { ProfilerHandle, ProfileStat } from '../recorder'
4
+ import {
5
+ claimOverlay,
6
+ listProfilers,
7
+ profilersVersion,
8
+ releaseOverlay,
9
+ subscribeProfilers,
10
+ } from '../registry'
11
+ import { traceJson } from '../trace'
12
+ import {
13
+ bodyStyle,
14
+ buttonStyle,
15
+ cellStyle,
16
+ headerStyle,
17
+ nameCellStyle,
18
+ palette,
19
+ panelStyle,
20
+ pillStyle,
21
+ rootStyle,
22
+ sectionTitleStyle,
23
+ tableStyle,
24
+ tabStyle,
25
+ } from './styles'
26
+
27
+ // <ReformProfiler/> — the dev overlay. Plain React + useSyncExternalStore, NOT
28
+ // built on reform: the profiler must not profile itself. One floating panel per
29
+ // page (singleton election through the registry), one tab per live profiled
30
+ // runtime, per-kind aggregate tables, Chrome-trace export, clear.
31
+
32
+ /** Save the profiler's Chrome DevTools trace as a `.json` download (browser only). */
33
+ export const downloadTrace = (profiler: ProfilerHandle): void => {
34
+ const blob = new Blob([traceJson(profiler)], { type: 'application/json' })
35
+ const url = URL.createObjectURL(blob)
36
+ const anchor = document.createElement('a')
37
+ anchor.href = url
38
+ anchor.download = `reform-trace-${profiler.label}.json`
39
+ anchor.click()
40
+ URL.revokeObjectURL(url)
41
+ }
42
+
43
+ // ── data shaping ─────────────────────────────────────────────────────────────
44
+
45
+ interface Row {
46
+ readonly name: string
47
+ readonly stat: ProfileStat
48
+ }
49
+
50
+ const sortedRows = (stats: ReadonlyMap<string, ProfileStat>): ReadonlyArray<Row> =>
51
+ [...stats.entries()]
52
+ .map(([name, stat]): Row => ({ name, stat }))
53
+ .toSorted(
54
+ (left, right) => right.stat.totalMs - left.stat.totalMs || right.stat.count - left.stat.count,
55
+ )
56
+
57
+ const MS_DECIMALS = 2
58
+ const formatMs = (durationMs: number): string => durationMs.toFixed(MS_DECIMALS)
59
+ const avgMs = (stat: ProfileStat): string =>
60
+ formatMs(stat.count === 0 ? 0 : stat.totalMs / stat.count)
61
+
62
+ interface SectionProps {
63
+ readonly title: string
64
+ readonly rows: ReadonlyArray<Row>
65
+ /** Timed sections get total/avg columns; instant sections only a count. */
66
+ readonly timed: boolean
67
+ }
68
+
69
+ const Section = ({ title, rows, timed }: SectionProps): ReactNode => {
70
+ if (rows.length === 0) {
71
+ return null
72
+ }
73
+ return (
74
+ <div>
75
+ <div style={sectionTitleStyle}>{title}</div>
76
+ <table style={tableStyle}>
77
+ <thead>
78
+ <tr>
79
+ <th style={nameCellStyle}>name</th>
80
+ <th style={cellStyle}>count</th>
81
+ {timed ? <th style={cellStyle}>total ms</th> : null}
82
+ {timed ? <th style={cellStyle}>avg ms</th> : null}
83
+ </tr>
84
+ </thead>
85
+ <tbody>
86
+ {rows.map((row) => (
87
+ <tr key={row.name}>
88
+ <td style={nameCellStyle}>{row.name}</td>
89
+ <td style={cellStyle}>{row.stat.count}</td>
90
+ {timed ? <td style={cellStyle}>{formatMs(row.stat.totalMs)}</td> : null}
91
+ {timed ? <td style={cellStyle}>{avgMs(row.stat)}</td> : null}
92
+ </tr>
93
+ ))}
94
+ </tbody>
95
+ </table>
96
+ </div>
97
+ )
98
+ }
99
+
100
+ interface DroppedNoteProps {
101
+ readonly count: number
102
+ }
103
+
104
+ const DroppedNote = ({ count }: DroppedNoteProps): ReactNode => {
105
+ if (count === 0) {
106
+ return null
107
+ }
108
+ return <div style={{ color: palette.dim }}>({count} early records evicted from the ring buffer)</div>
109
+ }
110
+
111
+ interface ProfilerBodyProps {
112
+ readonly profiler: ProfilerHandle
113
+ }
114
+
115
+ const ProfilerBody = ({ profiler }: ProfilerBodyProps): ReactNode => {
116
+ const subscribe = useCallback(
117
+ (onStoreChange: () => void) => profiler.subscribe(onStoreChange),
118
+ [profiler],
119
+ )
120
+ const getVersion = useCallback(() => profiler.getVersion(), [profiler])
121
+ useSyncExternalStore(subscribe, getVersion, getVersion)
122
+ const snapshot = profiler.snapshot()
123
+ const frameRows: ReadonlyArray<Row> =
124
+ snapshot.frames.count === 0 ? [] : [{ name: 'frame', stat: snapshot.frames }]
125
+ return (
126
+ <div style={bodyStyle}>
127
+ <Section title="Renders" rows={sortedRows(snapshot.renders)} timed={true} />
128
+ <Section title="Events" rows={sortedRows(snapshot.events)} timed={false} />
129
+ <Section title="Reducers" rows={sortedRows(snapshot.reducers)} timed={true} />
130
+ <Section title="State updates" rows={sortedRows(snapshot.states)} timed={false} />
131
+ <Section title="Calcs" rows={sortedRows(snapshot.calcs)} timed={true} />
132
+ <Section title="Queries" rows={sortedRows(snapshot.queries)} timed={true} />
133
+ <Section title="Procedures" rows={sortedRows(snapshot.procedures)} timed={true} />
134
+ <Section title="Frames" rows={frameRows} timed={true} />
135
+ <DroppedNote count={snapshot.dropped} />
136
+ </div>
137
+ )
138
+ }
139
+
140
+ // ── the overlay ──────────────────────────────────────────────────────────────
141
+
142
+ /**
143
+ * The profiler panel. Render it once anywhere in the page (outside `<Reform>` —
144
+ * it is not a reform composition); it shows one tab per live `profileScene`d
145
+ * runtime. Extra instances render nothing until the elected one unmounts.
146
+ */
147
+ export const ReformProfiler = (): ReactNode => {
148
+ const owner = useRef({}).current
149
+ const [claimed, setClaimed] = useState(false)
150
+ useEffect(() => {
151
+ setClaimed(claimOverlay(owner))
152
+ const unsubscribe = subscribeProfilers(() => setClaimed(claimOverlay(owner)))
153
+ return () => {
154
+ unsubscribe()
155
+ releaseOverlay(owner)
156
+ }
157
+ }, [owner])
158
+
159
+ const registryVersion = useSyncExternalStore(
160
+ subscribeProfilers,
161
+ profilersVersion,
162
+ profilersVersion,
163
+ )
164
+ const registrations = listProfilers()
165
+ const [expanded, setExpanded] = useState(false)
166
+ const [tab, setTab] = useState(0)
167
+ void registryVersion
168
+
169
+ if (!claimed || registrations.length === 0) {
170
+ return null
171
+ }
172
+ const selected = registrations[Math.min(tab, registrations.length - 1)]
173
+ if (selected === undefined) {
174
+ return null
175
+ }
176
+ if (!expanded) {
177
+ return (
178
+ <div style={rootStyle}>
179
+ <button type="button" style={pillStyle} onClick={() => setExpanded(true)}>
180
+ ⚡ reform profiler
181
+ </button>
182
+ </div>
183
+ )
184
+ }
185
+ return (
186
+ <div style={rootStyle}>
187
+ <div style={panelStyle}>
188
+ <div style={headerStyle}>
189
+ <span style={{ color: palette.accent, fontWeight: 600 }}>⚡ reform</span>
190
+ {registrations.map((registration, index) => (
191
+ <button
192
+ key={registration.label + String(index)}
193
+ type="button"
194
+ style={tabStyle(registration === selected)}
195
+ onClick={() => setTab(index)}
196
+ >
197
+ {registration.label}
198
+ </button>
199
+ ))}
200
+ <span style={{ flex: 1 }} />
201
+ <button type="button" style={buttonStyle} onClick={() => downloadTrace(selected.handle)}>
202
+ export
203
+ </button>
204
+ <button type="button" style={buttonStyle} onClick={() => selected.handle.clear()}>
205
+ clear
206
+ </button>
207
+ <button type="button" style={buttonStyle} onClick={() => setExpanded(false)}>
208
+ ×
209
+ </button>
210
+ </div>
211
+ <ProfilerBody profiler={selected.handle} />
212
+ </div>
213
+ </div>
214
+ )
215
+ }
@@ -0,0 +1,115 @@
1
+ import type { CSSProperties } from 'react'
2
+
3
+ // Inline styles for the overlay panel — the profiler carries no CSS dependency
4
+ // and must not disturb the host page (fixed position, own stacking context).
5
+
6
+ interface Palette {
7
+ readonly bg: string
8
+ readonly panel: string
9
+ readonly border: string
10
+ readonly text: string
11
+ readonly dim: string
12
+ readonly accent: string
13
+ }
14
+
15
+ export const palette: Palette = {
16
+ bg: '#16181d',
17
+ panel: '#1d2026',
18
+ border: '#31353d',
19
+ text: '#d7dae0',
20
+ dim: '#8a8f98',
21
+ accent: '#e8a33d',
22
+ }
23
+
24
+ export const rootStyle: CSSProperties = {
25
+ position: 'fixed',
26
+ right: 12,
27
+ bottom: 12,
28
+ zIndex: 2147483000,
29
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
30
+ fontSize: 11,
31
+ lineHeight: 1.5,
32
+ color: palette.text,
33
+ }
34
+
35
+ export const pillStyle: CSSProperties = {
36
+ background: palette.bg,
37
+ border: `1px solid ${palette.border}`,
38
+ borderRadius: 999,
39
+ padding: '4px 12px',
40
+ cursor: 'pointer',
41
+ color: palette.accent,
42
+ fontSize: 11,
43
+ fontFamily: 'inherit',
44
+ }
45
+
46
+ export const panelStyle: CSSProperties = {
47
+ background: palette.bg,
48
+ border: `1px solid ${palette.border}`,
49
+ borderRadius: 8,
50
+ width: 420,
51
+ maxHeight: '60vh',
52
+ display: 'flex',
53
+ flexDirection: 'column',
54
+ overflow: 'hidden',
55
+ boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
56
+ }
57
+
58
+ export const headerStyle: CSSProperties = {
59
+ display: 'flex',
60
+ alignItems: 'center',
61
+ gap: 8,
62
+ padding: '6px 10px',
63
+ borderBottom: `1px solid ${palette.border}`,
64
+ background: palette.panel,
65
+ }
66
+
67
+ export const buttonStyle: CSSProperties = {
68
+ background: 'transparent',
69
+ border: `1px solid ${palette.border}`,
70
+ borderRadius: 4,
71
+ color: palette.text,
72
+ padding: '1px 8px',
73
+ cursor: 'pointer',
74
+ fontSize: 11,
75
+ fontFamily: 'inherit',
76
+ }
77
+
78
+ export const tabStyle = (active: boolean): CSSProperties => ({
79
+ ...buttonStyle,
80
+ borderColor: active ? palette.accent : palette.border,
81
+ color: active ? palette.accent : palette.dim,
82
+ })
83
+
84
+ export const bodyStyle: CSSProperties = {
85
+ overflowY: 'auto',
86
+ padding: '4px 10px 10px',
87
+ }
88
+
89
+ export const tableStyle: CSSProperties = {
90
+ width: '100%',
91
+ borderCollapse: 'collapse',
92
+ marginBottom: 8,
93
+ }
94
+
95
+ export const cellStyle: CSSProperties = {
96
+ padding: '1px 6px 1px 0',
97
+ textAlign: 'right',
98
+ whiteSpace: 'nowrap',
99
+ color: palette.dim,
100
+ }
101
+
102
+ export const nameCellStyle: CSSProperties = {
103
+ ...cellStyle,
104
+ textAlign: 'left',
105
+ width: '100%',
106
+ color: palette.text,
107
+ overflowWrap: 'anywhere',
108
+ whiteSpace: 'normal',
109
+ }
110
+
111
+ export const sectionTitleStyle: CSSProperties = {
112
+ color: palette.accent,
113
+ margin: '8px 0 2px',
114
+ fontWeight: 600,
115
+ }
@@ -0,0 +1,182 @@
1
+ import { type Instrumentation, makeScheduler, type SpanEnd } from '@playfast/reform'
2
+
3
+ // The recording Instrumentation. One profiler per profiled scene: every engine
4
+ // hook appends a fixed-shape record into a ring buffer and folds it into a
5
+ // per-name aggregate, then coalesces subscriber notifications through core's
6
+ // microtask scheduler — so the overlay re-renders once per burst, not per event.
7
+ // All hot-path work is synchronous and allocation-light (one record object, one
8
+ // aggregate fold); the buffer is fixed-size so a long session cannot grow
9
+ // memory unboundedly (old records are evicted, aggregates keep the full tally).
10
+
11
+ /** What a record measures. `event`/`state` are instants; the rest are spans. */
12
+ export type ProfileKind =
13
+ | 'event'
14
+ | 'reducer'
15
+ | 'state'
16
+ | 'calc'
17
+ | 'query'
18
+ | 'procedure'
19
+ | 'render'
20
+ | 'frame'
21
+
22
+ export interface ProfileRecord {
23
+ readonly kind: ProfileKind
24
+ /** The definition name — event tag, reducer/calc/query/procedure/composition name. */
25
+ readonly name: string
26
+ /** Context: the trigger tag, `channel:tag`, priority, or frame event count. Empty when none. */
27
+ readonly detail: string
28
+ /** Start in ms since the page/process time origin (`performance.now()`). */
29
+ readonly start: number
30
+ /** Duration in ms; `0` for instant records. */
31
+ readonly dur: number
32
+ }
33
+
34
+ /** Aggregate for one name within a kind. Instant kinds carry `totalMs: 0`. */
35
+ export interface ProfileStat {
36
+ readonly count: number
37
+ readonly totalMs: number
38
+ }
39
+
40
+ /** A consistent copy of everything recorded so far. */
41
+ export interface ProfileSnapshot {
42
+ readonly events: ReadonlyMap<string, ProfileStat>
43
+ readonly reducers: ReadonlyMap<string, ProfileStat>
44
+ readonly states: ReadonlyMap<string, ProfileStat>
45
+ readonly calcs: ReadonlyMap<string, ProfileStat>
46
+ readonly queries: ReadonlyMap<string, ProfileStat>
47
+ readonly procedures: ReadonlyMap<string, ProfileStat>
48
+ readonly renders: ReadonlyMap<string, ProfileStat>
49
+ readonly frames: ProfileStat
50
+ /** Raw records, oldest → newest, at most `capacity` of them. */
51
+ readonly records: ReadonlyArray<ProfileRecord>
52
+ /** Records evicted by the ring buffer (aggregates still include them). */
53
+ readonly dropped: number
54
+ }
55
+
56
+ // Boundary config object: optional fields are the public, JSON-like surface
57
+ // (`ExternalApi` postfix exempts them from `no-optional-fields`).
58
+ export interface ProfilerOptionsExternalApi {
59
+ /** Tab title in the overlay / report heading. Defaults to the scene's composition title. */
60
+ readonly label?: string
61
+ /** Ring-buffer size in records (default 50,000). Aggregates are unaffected. */
62
+ readonly capacity?: number
63
+ }
64
+
65
+ /**
66
+ * The live profiler: a recording {@link Instrumentation} plus the read side the
67
+ * overlay, trace export, and reports consume. `subscribe`/`getVersion` follow
68
+ * the `useSyncExternalStore` contract (notifications microtask-coalesced).
69
+ */
70
+ export interface ProfilerHandle extends Instrumentation {
71
+ readonly label: string
72
+ readonly snapshot: () => ProfileSnapshot
73
+ /** Drop all records and aggregates (the overlay's Clear button). */
74
+ readonly clear: () => void
75
+ readonly subscribe: (listener: () => void) => () => void
76
+ /** Monotone change counter — bumped once per recorded entry and per `clear`. */
77
+ readonly getVersion: () => number
78
+ }
79
+
80
+ const zeroStat: ProfileStat = { count: 0, totalMs: 0 }
81
+
82
+ const DEFAULT_CAPACITY = 50_000
83
+
84
+ export const makeProfiler = (options: ProfilerOptionsExternalApi = {}): ProfilerHandle => {
85
+ const capacity = Math.max(1, options.capacity ?? DEFAULT_CAPACITY)
86
+ const label = options.label ?? 'reform'
87
+ // Ring buffer: fixed slots + a monotone write counter (`written % capacity`
88
+ // is the next slot). In-place-mutated latch object, like the hosts' `status`.
89
+ const emptyBuffer = (): Array<ProfileRecord | undefined> =>
90
+ Array.from({ length: capacity }, (): ProfileRecord | undefined => undefined)
91
+ const cursor = { buffer: emptyBuffer(), written: 0, version: 0 }
92
+ const stats: Record<ProfileKind, Map<string, ProfileStat>> = {
93
+ event: new Map(),
94
+ reducer: new Map(),
95
+ state: new Map(),
96
+ calc: new Map(),
97
+ query: new Map(),
98
+ procedure: new Map(),
99
+ render: new Map(),
100
+ frame: new Map(),
101
+ }
102
+
103
+ const listeners = new Set<() => void>()
104
+ const scheduler = makeScheduler()
105
+ const notify = (): void => {
106
+ cursor.version += 1
107
+ scheduler.schedule(listeners)
108
+ }
109
+
110
+ const record = (entry: ProfileRecord): void => {
111
+ cursor.buffer[cursor.written % capacity] = entry
112
+ cursor.written += 1
113
+ const bucket = stats[entry.kind]
114
+ const prev = bucket.get(entry.name) ?? zeroStat
115
+ bucket.set(entry.name, { count: prev.count + 1, totalMs: prev.totalMs + entry.dur })
116
+ notify()
117
+ }
118
+
119
+ interface Site {
120
+ readonly kind: ProfileKind
121
+ readonly name: string
122
+ readonly detail: string
123
+ }
124
+
125
+ const instant = (site: Site): void =>
126
+ record({ ...site, start: performance.now(), dur: 0 })
127
+
128
+ const span = (site: Site): SpanEnd => {
129
+ const start = performance.now()
130
+ return () => record({ ...site, start, dur: performance.now() - start })
131
+ }
132
+
133
+ const snapshot = (): ProfileSnapshot => {
134
+ const size = Math.min(cursor.written, capacity)
135
+ const first = cursor.written - size
136
+ const records = Array.from(
137
+ { length: size },
138
+ (_, index) => cursor.buffer[(first + index) % capacity],
139
+ ).filter((candidate): candidate is ProfileRecord => candidate !== undefined)
140
+ return {
141
+ events: new Map(stats.event),
142
+ reducers: new Map(stats.reducer),
143
+ states: new Map(stats.state),
144
+ calcs: new Map(stats.calc),
145
+ queries: new Map(stats.query),
146
+ procedures: new Map(stats.procedure),
147
+ renders: new Map(stats.render),
148
+ frames: stats.frame.get('frame') ?? zeroStat,
149
+ records,
150
+ dropped: first,
151
+ }
152
+ }
153
+
154
+ return {
155
+ label,
156
+ snapshot,
157
+ clear: () => {
158
+ cursor.buffer = emptyBuffer()
159
+ cursor.written = 0
160
+ Object.values(stats).forEach((bucket) => bucket.clear())
161
+ notify()
162
+ },
163
+ subscribe: (listener) => {
164
+ listeners.add(listener)
165
+ return () => {
166
+ listeners.delete(listener)
167
+ }
168
+ },
169
+ getVersion: () => cursor.version,
170
+
171
+ // ── Instrumentation ────────────────────────────────────────────────────
172
+ eventDispatched: (tag, priority) => instant({ kind: 'event', name: tag, detail: priority }),
173
+ reducerRun: (name, eventTag) => span({ kind: 'reducer', name, detail: eventTag }),
174
+ stateUpdated: (name) => instant({ kind: 'state', name, detail: '' }),
175
+ calcRecomputed: (name) => span({ kind: 'calc', name, detail: '' }),
176
+ queryRun: (name) => span({ kind: 'query', name, detail: '' }),
177
+ procedureRun: (name, channel, eventTag) =>
178
+ span({ kind: 'procedure', name, detail: `${channel}:${eventTag}` }),
179
+ uiRendered: (composition) => span({ kind: 'render', name: composition, detail: '' }),
180
+ frame: (eventCount) => span({ kind: 'frame', name: 'frame', detail: String(eventCount) }),
181
+ }
182
+ }
@@ -0,0 +1,100 @@
1
+ import { GlobalValue, Option } from 'effect'
2
+ import type { ProfilerHandle } from './recorder'
3
+
4
+ // Module-level registry of the profilers whose runtimes are currently live.
5
+ // The overlay reads it to render one tab per profiled runtime; `profileScene`
6
+ // registers on layer build and unregisters when the runtime's scope closes.
7
+ // Kept in a `globalValue` so duplicated bundles / HMR share one registry.
8
+
9
+ export interface ProfilerRegistration {
10
+ readonly label: string
11
+ readonly handle: ProfilerHandle
12
+ }
13
+
14
+ interface RegistryState {
15
+ /** Live registrations with a build refcount (StrictMode double-builds share one entry). */
16
+ readonly entries: Map<ProfilerHandle, { readonly registration: ProfilerRegistration; readonly count: number }>
17
+ readonly listeners: Set<() => void>
18
+ readonly version: { current: number }
19
+ /** The overlay instance currently elected to render the panel. */
20
+ readonly overlay: { owner: Option.Option<object> }
21
+ }
22
+
23
+ const state: RegistryState = GlobalValue.globalValue(
24
+ Symbol.for('reform-profiler/registry'),
25
+ (): RegistryState => ({
26
+ entries: new Map(),
27
+ listeners: new Set(),
28
+ version: { current: 0 },
29
+ overlay: { owner: Option.none() },
30
+ }),
31
+ )
32
+
33
+ const notify = (): void => {
34
+ state.version.current += 1
35
+ ;[...state.listeners].forEach((listener) => listener())
36
+ }
37
+
38
+ /**
39
+ * Register a live profiler. Refcounted by handle: the same profiler built twice
40
+ * (React StrictMode) stays one tab, and disappears when the LAST build closes.
41
+ * Returns the matching unregister.
42
+ */
43
+ export const registerProfiler = (registration: ProfilerRegistration): (() => void) => {
44
+ const existing = state.entries.get(registration.handle)
45
+ state.entries.set(registration.handle, {
46
+ registration,
47
+ count: existing === undefined ? 1 : existing.count + 1,
48
+ })
49
+ if (existing === undefined) {
50
+ notify()
51
+ }
52
+ return () => {
53
+ const current = state.entries.get(registration.handle)
54
+ if (current === undefined) {
55
+ return
56
+ }
57
+ if (current.count <= 1) {
58
+ state.entries.delete(registration.handle)
59
+ notify()
60
+ } else {
61
+ state.entries.set(registration.handle, { ...current, count: current.count - 1 })
62
+ }
63
+ }
64
+ }
65
+
66
+ /** The live registrations, in registration order. */
67
+ export const listProfilers = (): ReadonlyArray<ProfilerRegistration> =>
68
+ [...state.entries.values()].map((entry) => entry.registration)
69
+
70
+ /** Subscribe to registry changes (register/unregister). Sync, rare. */
71
+ export const subscribeProfilers = (listener: () => void): (() => void) => {
72
+ state.listeners.add(listener)
73
+ return () => {
74
+ state.listeners.delete(listener)
75
+ }
76
+ }
77
+
78
+ /** Monotone registry change counter (`useSyncExternalStore` snapshot). */
79
+ export const profilersVersion = (): number => state.version.current
80
+
81
+ /**
82
+ * Singleton election for the overlay panel: the first mounted `<ReformProfiler/>`
83
+ * claims rendering; later instances render nothing. Returns true when `owner`
84
+ * holds the claim (idempotent for the current claimant).
85
+ */
86
+ export const claimOverlay = (owner: object): boolean => {
87
+ if (Option.isNone(state.overlay.owner)) {
88
+ state.overlay.owner = Option.some(owner)
89
+ return true
90
+ }
91
+ return Option.contains(state.overlay.owner, owner)
92
+ }
93
+
94
+ /** Release the panel claim so the next mounted instance can take over. */
95
+ export const releaseOverlay = (owner: object): void => {
96
+ if (Option.contains(state.overlay.owner, owner)) {
97
+ state.overlay.owner = Option.none()
98
+ notify()
99
+ }
100
+ }
package/src/report.ts ADDED
@@ -0,0 +1,78 @@
1
+ import type { ProfilerHandle, ProfileStat } from './recorder'
2
+
3
+ // Terminal report — the overlay's tables as aligned plain text, so a headless
4
+ // drive script can `console.log(formatReport(profiler))` after a scenario.
5
+
6
+ interface Row {
7
+ readonly name: string
8
+ readonly stat: ProfileStat
9
+ }
10
+
11
+ const sortedRows = (stats: ReadonlyMap<string, ProfileStat>): ReadonlyArray<Row> =>
12
+ [...stats.entries()]
13
+ .map(([name, stat]): Row => ({ name, stat }))
14
+ .toSorted(
15
+ (left, right) => right.stat.totalMs - left.stat.totalMs || right.stat.count - left.stat.count,
16
+ )
17
+
18
+ const MS_DECIMALS = 2
19
+ const formatMs = (durationMs: number): string => durationMs.toFixed(MS_DECIMALS)
20
+
21
+ // Fixed column widths: fit the 'count'/'total ms'/'avg ms' headers plus room
22
+ // for six digits; the name column stretches to the longest name per section.
23
+ const COUNT_WIDTH = 7
24
+ const TOTAL_WIDTH = 9
25
+ const AVG_WIDTH = 8
26
+ const ruleOverhead = '── '.length + ' '.length
27
+
28
+ interface Section {
29
+ readonly title: string
30
+ readonly rows: ReadonlyArray<Row>
31
+ /** Timed sections get total/avg columns; instant sections only a count. */
32
+ readonly timed: boolean
33
+ }
34
+
35
+ const section = ({ title, rows, timed }: Section): ReadonlyArray<string> => {
36
+ if (rows.length === 0) {
37
+ return []
38
+ }
39
+ const nameWidth = Math.max('name'.length, ...rows.map((row) => row.name.length))
40
+ const header = timed
41
+ ? `${'name'.padEnd(nameWidth)} ${'count'.padStart(COUNT_WIDTH)} ${'total ms'.padStart(TOTAL_WIDTH)} ${'avg ms'.padStart(AVG_WIDTH)}`
42
+ : `${'name'.padEnd(nameWidth)} ${'count'.padStart(COUNT_WIDTH)}`
43
+ const lines = rows.map((row) =>
44
+ timed
45
+ ? `${row.name.padEnd(nameWidth)} ${String(row.stat.count).padStart(COUNT_WIDTH)} ${formatMs(row.stat.totalMs).padStart(TOTAL_WIDTH)} ${formatMs(row.stat.count === 0 ? 0 : row.stat.totalMs / row.stat.count).padStart(AVG_WIDTH)}`
46
+ : `${row.name.padEnd(nameWidth)} ${String(row.stat.count).padStart(COUNT_WIDTH)}`,
47
+ )
48
+ return [
49
+ `── ${title} ${'─'.repeat(Math.max(0, header.length - title.length - ruleOverhead))}`,
50
+ header,
51
+ ...lines,
52
+ '',
53
+ ]
54
+ }
55
+
56
+ /** The profile so far as aligned text tables, one section per non-empty kind. */
57
+ export const formatReport = (profiler: ProfilerHandle): string => {
58
+ const snapshot = profiler.snapshot()
59
+ const frameRows: ReadonlyArray<Row> =
60
+ snapshot.frames.count === 0 ? [] : [{ name: 'frame', stat: snapshot.frames }]
61
+ const dropped =
62
+ snapshot.dropped === 0
63
+ ? []
64
+ : [`(${snapshot.dropped} early records evicted from the ring buffer)`, '']
65
+ return [
66
+ `reform profile — ${profiler.label}`,
67
+ '',
68
+ ...section({ title: 'Events', rows: sortedRows(snapshot.events), timed: false }),
69
+ ...section({ title: 'Reducers', rows: sortedRows(snapshot.reducers), timed: true }),
70
+ ...section({ title: 'State updates', rows: sortedRows(snapshot.states), timed: false }),
71
+ ...section({ title: 'Calcs', rows: sortedRows(snapshot.calcs), timed: true }),
72
+ ...section({ title: 'Queries', rows: sortedRows(snapshot.queries), timed: true }),
73
+ ...section({ title: 'Procedures', rows: sortedRows(snapshot.procedures), timed: true }),
74
+ ...section({ title: 'Renders', rows: sortedRows(snapshot.renders), timed: true }),
75
+ ...section({ title: 'Frames', rows: frameRows, timed: true }),
76
+ ...dropped,
77
+ ].join('\n')
78
+ }
package/src/trace.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { ProfileKind, ProfilerHandle } from './recorder'
2
+
3
+ // Chrome DevTools trace export — the Trace Event Format (`ph:'X'` complete
4
+ // events on named thread lanes, `ph:'i'` instants, `ph:'M'` metadata). The
5
+ // output loads in the Chrome DevTools Performance panel ("Load profile…") and
6
+ // in Perfetto. Platform-neutral: headless scripts `Bun.write('trace.json',
7
+ // traceJson(profiler))`; the browser download button lives in the react entry.
8
+
9
+ /** One Trace Event Format entry. Times are µs. */
10
+ export type ChromeTraceEvent =
11
+ | {
12
+ readonly ph: 'X'
13
+ readonly name: string
14
+ readonly cat: string
15
+ readonly ts: number
16
+ readonly dur: number
17
+ readonly pid: number
18
+ readonly tid: number
19
+ readonly args: Readonly<Record<string, string>>
20
+ }
21
+ | {
22
+ readonly ph: 'i'
23
+ readonly name: string
24
+ readonly cat: string
25
+ readonly ts: number
26
+ readonly pid: number
27
+ readonly tid: number
28
+ readonly s: 't'
29
+ readonly args: Readonly<Record<string, string>>
30
+ }
31
+ | {
32
+ readonly ph: 'M'
33
+ readonly name: 'process_name' | 'thread_name'
34
+ readonly ts: 0
35
+ readonly pid: number
36
+ readonly tid: number
37
+ readonly args: { readonly name: string }
38
+ }
39
+
40
+ export interface ChromeTrace {
41
+ readonly traceEvents: ReadonlyArray<ChromeTraceEvent>
42
+ readonly displayTimeUnit: 'ms'
43
+ }
44
+
45
+ // One thread lane per kind, so the Performance panel shows parallel tracks.
46
+ const lanes: Record<ProfileKind, { readonly tid: number; readonly title: string }> = {
47
+ frame: { tid: 1, title: 'frames' },
48
+ event: { tid: 2, title: 'events' },
49
+ reducer: { tid: 3, title: 'reducers' },
50
+ state: { tid: 4, title: 'state updates' },
51
+ calc: { tid: 5, title: 'calcs' },
52
+ query: { tid: 6, title: 'queries' },
53
+ procedure: { tid: 7, title: 'procedures' },
54
+ render: { tid: 8, title: 'renders' },
55
+ }
56
+
57
+ const instantKinds: ReadonlySet<ProfileKind> = new Set(['event', 'state'])
58
+
59
+ const pid = 1
60
+ const US_PER_MS = 1000
61
+
62
+ /** Project the profiler's raw records into a Chrome DevTools trace object. */
63
+ export const toChromeTrace = (profiler: ProfilerHandle): ChromeTrace => {
64
+ const { records } = profiler.snapshot()
65
+ const meta: ReadonlyArray<ChromeTraceEvent> = [
66
+ { ph: 'M', name: 'process_name', ts: 0, pid, tid: 0, args: { name: `reform: ${profiler.label}` } },
67
+ ...Object.values(lanes).map(
68
+ (lane): ChromeTraceEvent => ({
69
+ ph: 'M',
70
+ name: 'thread_name',
71
+ ts: 0,
72
+ pid,
73
+ tid: lane.tid,
74
+ args: { name: lane.title },
75
+ }),
76
+ ),
77
+ ]
78
+ const body = records.map((record): ChromeTraceEvent => {
79
+ const startUs = record.start * US_PER_MS
80
+ const cat = record.kind
81
+ const tid = lanes[record.kind].tid
82
+ const args = record.detail === '' ? {} : { detail: record.detail }
83
+ return instantKinds.has(record.kind)
84
+ ? { ph: 'i', name: record.name, cat, ts: startUs, pid, tid, s: 't', args }
85
+ : { ph: 'X', name: record.name, cat, ts: startUs, dur: record.dur * US_PER_MS, pid, tid, args }
86
+ })
87
+ return { traceEvents: [...meta, ...body], displayTimeUnit: 'ms' }
88
+ }
89
+
90
+ /** `toChromeTrace` as a JSON string, ready to write to a `.json` file. */
91
+ export const traceJson = (profiler: ProfilerHandle): string =>
92
+ // oxlint-disable-next-line reform-rules/no-json-parse-stringify -- Trace Event Format is a foreign wire format consumed by Chrome DevTools/Perfetto, not Schema-typed data
93
+ JSON.stringify(toChromeTrace(profiler))