@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 +63 -0
- package/src/index.ts +26 -0
- package/src/noop.ts +79 -0
- package/src/profileScene.ts +56 -0
- package/src/profiler.test.ts +178 -0
- package/src/react/index.ts +5 -0
- package/src/react/noop.ts +9 -0
- package/src/react/overlay.tsx +215 -0
- package/src/react/styles.ts +115 -0
- package/src/recorder.ts +182 -0
- package/src/registry.ts +100 -0
- package/src/report.ts +78 -0
- package/src/trace.ts +93 -0
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
|
+
}
|
package/src/recorder.ts
ADDED
|
@@ -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
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -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))
|