@playfast/reform-profiler 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/index.ts +1 -6
- package/src/noop.ts +49 -18
- package/src/profileScene.ts +85 -36
- package/src/profiler.test.ts +8 -10
- package/src/react/index.ts +0 -4
- package/src/react/noop.ts +0 -3
- package/src/react/overlay.tsx +4 -17
- package/src/react/styles.ts +0 -3
- package/src/recorder.ts +3 -34
- package/src/registry.ts +7 -21
- package/src/report.ts +0 -7
- package/src/trace.ts +19 -12
package/package.json
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-profiler",
|
|
3
|
-
"
|
|
4
|
-
"version": "1.0.1",
|
|
5
|
-
"type": "module",
|
|
3
|
+
"version": "1.1.0",
|
|
6
4
|
"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
5
|
"keywords": [
|
|
8
|
-
"
|
|
6
|
+
"devtools",
|
|
9
7
|
"effect",
|
|
10
|
-
"profiler",
|
|
11
8
|
"performance",
|
|
12
|
-
"
|
|
9
|
+
"profiler",
|
|
10
|
+
"reform"
|
|
13
11
|
],
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/playfast/reform/issues"
|
|
14
|
+
},
|
|
14
15
|
"license": "MIT",
|
|
15
16
|
"repository": {
|
|
16
17
|
"type": "git",
|
|
17
18
|
"url": "https://github.com/playfast/reform.git",
|
|
18
19
|
"directory": "packages/reform-profiler"
|
|
19
20
|
},
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
23
26
|
"sideEffects": false,
|
|
24
27
|
"exports": {
|
|
25
28
|
"./package.json": "./package.json",
|
|
@@ -33,10 +36,9 @@
|
|
|
33
36
|
},
|
|
34
37
|
"./recorder": "./src/index.ts"
|
|
35
38
|
},
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
40
42
|
"scripts": {
|
|
41
43
|
"clean": "rm -rf dist .tsbuildinfo",
|
|
42
44
|
"check": "tsc --noEmit",
|
|
@@ -48,8 +50,8 @@
|
|
|
48
50
|
"lint:fix": "oxlint --fix src"
|
|
49
51
|
},
|
|
50
52
|
"peerDependencies": {
|
|
51
|
-
"effect": "*",
|
|
52
53
|
"@playfast/reform": "*",
|
|
54
|
+
"effect": "*",
|
|
53
55
|
"react": "^19.0.0"
|
|
54
56
|
},
|
|
55
57
|
"peerDependenciesMeta": {
|
|
@@ -57,7 +59,5 @@
|
|
|
57
59
|
"optional": true
|
|
58
60
|
}
|
|
59
61
|
},
|
|
60
|
-
"
|
|
61
|
-
"access": "public"
|
|
62
|
-
}
|
|
62
|
+
"playbook": "./playbook"
|
|
63
63
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
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
1
|
export {
|
|
7
2
|
makeProfiler,
|
|
8
3
|
type ProfileKind,
|
|
@@ -12,7 +7,7 @@ export {
|
|
|
12
7
|
type ProfileSnapshot,
|
|
13
8
|
type ProfileStat,
|
|
14
9
|
} from './recorder'
|
|
15
|
-
export { type ProfiledScene, profileScene } from './profileScene'
|
|
10
|
+
export { type CapturedProfiledScene, type ProfiledScene, profileScene } from './profileScene'
|
|
16
11
|
export { type ChromeTrace, type ChromeTraceEvent, toChromeTrace, traceJson } from './trace'
|
|
17
12
|
export { formatReport } from './report'
|
|
18
13
|
export {
|
package/src/noop.ts
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import type {
|
|
4
|
-
|
|
5
|
-
ProfilerOptionsExternalApi,
|
|
6
|
-
ProfileSnapshot,
|
|
7
|
-
} from './recorder'
|
|
1
|
+
import { type CapturedScene, type Scene, type UiContract } from '@playfast/reform'
|
|
2
|
+
import { noopInstrumentation } from '@playfast/reform/internal'
|
|
3
|
+
import type { CapturedProfiledScene, ProfiledScene } from './profileScene'
|
|
4
|
+
import type { ProfilerHandle, ProfilerOptionsExternalApi, ProfileSnapshot } from './recorder'
|
|
8
5
|
import type { ProfilerRegistration } from './registry'
|
|
9
6
|
import type { ChromeTrace } from './trace'
|
|
10
7
|
|
|
11
|
-
//
|
|
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).
|
|
8
|
+
// Production stub (`default` export condition): same surface, zero recording.
|
|
16
9
|
|
|
17
10
|
export type {
|
|
18
11
|
ProfileKind,
|
|
@@ -22,7 +15,7 @@ export type {
|
|
|
22
15
|
ProfileSnapshot,
|
|
23
16
|
ProfileStat,
|
|
24
17
|
} from './recorder'
|
|
25
|
-
export type { ProfiledScene } from './profileScene'
|
|
18
|
+
export type { CapturedProfiledScene, ProfiledScene } from './profileScene'
|
|
26
19
|
export type { ChromeTrace, ChromeTraceEvent } from './trace'
|
|
27
20
|
export type { ProfilerRegistration } from './registry'
|
|
28
21
|
|
|
@@ -39,7 +32,6 @@ const emptySnapshot: ProfileSnapshot = {
|
|
|
39
32
|
dropped: 0,
|
|
40
33
|
}
|
|
41
34
|
|
|
42
|
-
/** The shared inert handle every stub call hands back. */
|
|
43
35
|
export const noopProfiler: ProfilerHandle = {
|
|
44
36
|
...noopInstrumentation,
|
|
45
37
|
label: 'noop',
|
|
@@ -52,10 +44,45 @@ export const noopProfiler: ProfilerHandle = {
|
|
|
52
44
|
export const makeProfiler = (_options: ProfilerOptionsExternalApi = {}): ProfilerHandle =>
|
|
53
45
|
noopProfiler
|
|
54
46
|
|
|
55
|
-
|
|
47
|
+
const profileCapturedScene = <
|
|
48
|
+
C extends UiContract,
|
|
49
|
+
S extends ReadonlyArray<unknown>,
|
|
50
|
+
Services,
|
|
51
|
+
P,
|
|
52
|
+
N extends string,
|
|
53
|
+
Identity,
|
|
54
|
+
>(
|
|
55
|
+
base: CapturedScene<C, S, Services, P, N, Identity>,
|
|
56
|
+
): CapturedProfiledScene<C, S, Services, P, N, Identity> => ({
|
|
57
|
+
scene: base,
|
|
58
|
+
profiler: noopProfiler,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
export function profileScene<
|
|
62
|
+
C extends UiContract,
|
|
63
|
+
S extends ReadonlyArray<unknown>,
|
|
64
|
+
Services,
|
|
65
|
+
P,
|
|
66
|
+
N extends string,
|
|
67
|
+
Identity,
|
|
68
|
+
>(
|
|
69
|
+
base: CapturedScene<C, S, Services, P, N, Identity>,
|
|
70
|
+
_options?: ProfilerOptionsExternalApi,
|
|
71
|
+
): CapturedProfiledScene<C, S, Services, P, N, Identity>
|
|
72
|
+
export function profileScene<C extends UiContract, S extends ReadonlyArray<unknown>>(
|
|
73
|
+
base: Scene<C, S>,
|
|
74
|
+
_options?: ProfilerOptionsExternalApi,
|
|
75
|
+
): ProfiledScene<C, S>
|
|
76
|
+
export function profileScene<C extends UiContract, S extends ReadonlyArray<unknown>>(
|
|
56
77
|
base: Scene<C, S>,
|
|
57
78
|
_options: ProfilerOptionsExternalApi = {},
|
|
58
|
-
): ProfiledScene<C, S>
|
|
79
|
+
): ProfiledScene<C, S> {
|
|
80
|
+
return base.capture<ProfiledScene<C, S>>(
|
|
81
|
+
<Services, P, N extends string, Identity>(
|
|
82
|
+
exactScene: CapturedScene<C, S, Services, P, N, Identity>,
|
|
83
|
+
): ProfiledScene<C, S> => profileCapturedScene(exactScene),
|
|
84
|
+
)
|
|
85
|
+
}
|
|
59
86
|
|
|
60
87
|
const emptyTrace: ChromeTrace = { traceEvents: [], displayTimeUnit: 'ms' }
|
|
61
88
|
|
|
@@ -66,11 +93,15 @@ export const traceJson = (_profiler: ProfilerHandle): string =>
|
|
|
66
93
|
|
|
67
94
|
export const formatReport = (_profiler: ProfilerHandle): string => ''
|
|
68
95
|
|
|
69
|
-
export const registerProfiler =
|
|
96
|
+
export const registerProfiler =
|
|
97
|
+
(_registration: ProfilerRegistration): (() => void) =>
|
|
98
|
+
() => {}
|
|
70
99
|
|
|
71
100
|
export const listProfilers = (): ReadonlyArray<ProfilerRegistration> => []
|
|
72
101
|
|
|
73
|
-
export const subscribeProfilers =
|
|
102
|
+
export const subscribeProfilers =
|
|
103
|
+
(_listener: () => void): (() => void) =>
|
|
104
|
+
() => {}
|
|
74
105
|
|
|
75
106
|
export const profilersVersion = (): number => 0
|
|
76
107
|
|
package/src/profileScene.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Effect, Layer } from 'effect'
|
|
2
|
-
import { profiledScene, type Scene, type UiContract } from '@playfast/reform'
|
|
2
|
+
import { type CapturedScene, profiledScene, type Scene, type UiContract } from '@playfast/reform'
|
|
3
|
+
import { type AnySceneCapture, type SceneCapture } from '@playfast/reform/internal'
|
|
3
4
|
import { makeProfiler, type ProfilerHandle, type ProfilerOptionsExternalApi } from './recorder'
|
|
4
5
|
import { registerProfiler } from './registry'
|
|
5
6
|
|
|
6
|
-
/** A scene wired to record into `profiler`, plus the handle to read it back. */
|
|
7
7
|
export interface ProfiledScene<
|
|
8
8
|
C extends UiContract = UiContract,
|
|
9
9
|
S extends ReadonlyArray<unknown> = ReadonlyArray<unknown>,
|
|
@@ -12,45 +12,94 @@ export interface ProfiledScene<
|
|
|
12
12
|
readonly profiler: ProfilerHandle
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
15
|
+
export interface CapturedProfiledScene<
|
|
16
|
+
C extends UiContract,
|
|
17
|
+
S extends ReadonlyArray<unknown>,
|
|
18
|
+
Services,
|
|
19
|
+
P,
|
|
20
|
+
N extends string,
|
|
21
|
+
Identity,
|
|
22
|
+
> extends ProfiledScene<C, S> {
|
|
23
|
+
readonly scene: CapturedScene<C, S, Services, P, N, Identity>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const makeLifecycle = (profiler: ProfilerHandle) =>
|
|
27
|
+
Layer.scopedDiscard(
|
|
28
|
+
Effect.acquireRelease(
|
|
29
|
+
Effect.sync(() => registerProfiler({ label: profiler.label, handle: profiler })),
|
|
30
|
+
(unregister) => Effect.sync(unregister),
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const withLifecycle = <
|
|
35
|
+
C extends UiContract,
|
|
36
|
+
S extends ReadonlyArray<unknown>,
|
|
37
|
+
Services,
|
|
38
|
+
P,
|
|
39
|
+
N extends string,
|
|
40
|
+
Identity,
|
|
41
|
+
>(
|
|
42
|
+
base: CapturedScene<C, S, Services, P, N, Identity>,
|
|
43
|
+
lifecycle: ReturnType<typeof makeLifecycle>,
|
|
44
|
+
): CapturedScene<C, S, Services, P, N, Identity> => {
|
|
45
|
+
const captured: CapturedScene<C, S, Services, P, N, Identity> = {
|
|
46
|
+
kind: 'Scene',
|
|
47
|
+
composition: base.composition,
|
|
48
|
+
// Lifecycle on the first provide layer tracks the runtime's build/dispose scope.
|
|
49
|
+
provide: base.provide.map((layer, index) =>
|
|
50
|
+
index === 0 ? Layer.merge(layer, lifecycle) : layer,
|
|
51
|
+
),
|
|
52
|
+
...(base.boot === undefined ? {} : { boot: base.boot }),
|
|
53
|
+
...(base.instrumentation === undefined ? {} : { instrumentation: base.instrumentation }),
|
|
54
|
+
capture: <Result>(capture: SceneCapture<C, S, Result>): Result => capture(captured),
|
|
55
|
+
captureAny: <Result>(capture: AnySceneCapture<Result>): Result => capture(captured),
|
|
56
|
+
}
|
|
57
|
+
return captured
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const profileCapturedScene = <
|
|
61
|
+
C extends UiContract,
|
|
62
|
+
S extends ReadonlyArray<unknown>,
|
|
63
|
+
Services,
|
|
64
|
+
P,
|
|
65
|
+
N extends string,
|
|
66
|
+
Identity,
|
|
67
|
+
>(
|
|
68
|
+
base: CapturedScene<C, S, Services, P, N, Identity>,
|
|
69
|
+
profiler: ProfilerHandle,
|
|
70
|
+
lifecycle: ReturnType<typeof makeLifecycle>,
|
|
71
|
+
): CapturedProfiledScene<C, S, Services, P, N, Identity> => ({
|
|
72
|
+
scene: withLifecycle(profiledScene(base, profiler), lifecycle),
|
|
73
|
+
profiler,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export function profileScene<
|
|
77
|
+
C extends UiContract,
|
|
78
|
+
S extends ReadonlyArray<unknown>,
|
|
79
|
+
Services,
|
|
80
|
+
P,
|
|
81
|
+
N extends string,
|
|
82
|
+
Identity,
|
|
83
|
+
>(
|
|
84
|
+
base: CapturedScene<C, S, Services, P, N, Identity>,
|
|
85
|
+
options?: ProfilerOptionsExternalApi,
|
|
86
|
+
): CapturedProfiledScene<C, S, Services, P, N, Identity>
|
|
87
|
+
export function profileScene<C extends UiContract, S extends ReadonlyArray<unknown>>(
|
|
88
|
+
base: Scene<C, S>,
|
|
89
|
+
options?: ProfilerOptionsExternalApi,
|
|
90
|
+
): ProfiledScene<C, S>
|
|
91
|
+
export function profileScene<C extends UiContract, S extends ReadonlyArray<unknown>>(
|
|
31
92
|
base: Scene<C, S>,
|
|
32
93
|
options: ProfilerOptionsExternalApi = {},
|
|
33
|
-
): ProfiledScene<C, S>
|
|
94
|
+
): ProfiledScene<C, S> {
|
|
34
95
|
const profiler = makeProfiler({
|
|
35
96
|
label: options.label ?? base.composition.manifest.title,
|
|
36
97
|
...(options.capacity !== undefined ? { capacity: options.capacity } : {}),
|
|
37
98
|
})
|
|
38
|
-
const lifecycle =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
),
|
|
99
|
+
const lifecycle = makeLifecycle(profiler)
|
|
100
|
+
return base.capture<ProfiledScene<C, S>>(
|
|
101
|
+
<Services, P, N extends string, Identity>(
|
|
102
|
+
exactScene: CapturedScene<C, S, Services, P, N, Identity>,
|
|
103
|
+
): ProfiledScene<C, S> => profileCapturedScene(exactScene, profiler, lifecycle),
|
|
43
104
|
)
|
|
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
105
|
}
|
package/src/profiler.test.ts
CHANGED
|
@@ -24,8 +24,6 @@ import { formatReport } from './report'
|
|
|
24
24
|
|
|
25
25
|
const settle = (ms = 20) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
26
26
|
|
|
27
|
-
// ── recorder ──────────────────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
27
|
test('aggregates count and time per name, per kind', () => {
|
|
30
28
|
const profiler = makeProfiler({ label: 'agg' })
|
|
31
29
|
profiler.eventDispatched('Bumped', 'High')
|
|
@@ -53,7 +51,6 @@ test('the ring buffer wraps: newest records survive, aggregates keep the full ta
|
|
|
53
51
|
const snapshot = profiler.snapshot()
|
|
54
52
|
expect(snapshot.records.map((record) => record.name)).toEqual(['c', 'd', 'e', 'f'])
|
|
55
53
|
expect(snapshot.dropped).toBe(2)
|
|
56
|
-
// Evicted records still count in the aggregates.
|
|
57
54
|
expect(snapshot.events.size).toBe(6)
|
|
58
55
|
expect(snapshot.events.get('a')?.count).toBe(1)
|
|
59
56
|
})
|
|
@@ -87,8 +84,6 @@ test('subscribers are notified once per burst (coalesced)', async () => {
|
|
|
87
84
|
expect(calls.count).toBe(1)
|
|
88
85
|
})
|
|
89
86
|
|
|
90
|
-
// ── trace export ──────────────────────────────────────────────────────────────
|
|
91
|
-
|
|
92
87
|
test('toChromeTrace emits metadata lanes, X spans, and i instants', () => {
|
|
93
88
|
const profiler = makeProfiler({ label: 'traced' })
|
|
94
89
|
profiler.eventDispatched('Bumped', 'High')
|
|
@@ -112,8 +107,6 @@ test('toChromeTrace emits metadata lanes, X spans, and i instants', () => {
|
|
|
112
107
|
expect(parsed).toEqual(trace)
|
|
113
108
|
})
|
|
114
109
|
|
|
115
|
-
// ── profileScene end-to-end over the headless app runtime ────────────────────
|
|
116
|
-
|
|
117
110
|
class Count extends State.make('profiler-count', S.Number) {}
|
|
118
111
|
class States extends StateGroup.make(Count) {}
|
|
119
112
|
class Bumped extends Event.make('ProfilerBumped', S.Struct({})) {}
|
|
@@ -121,19 +114,25 @@ class Bump extends Reducer.make('ProfilerBump', { states: [Count], events: [Bump
|
|
|
121
114
|
const BumpLive = Reducer.live(Bump, (n) => n + 1)
|
|
122
115
|
|
|
123
116
|
class RootUi extends ui('ProfilerRoot')<{ props: { count: number } }>() {}
|
|
117
|
+
|
|
124
118
|
class Root extends Composition.make('ProfilerRoot', {
|
|
125
119
|
title: 'ProfilerRoot',
|
|
126
120
|
states: [States],
|
|
127
121
|
events: [Bumped],
|
|
128
122
|
ui: RootUi,
|
|
129
|
-
}) {}
|
|
123
|
+
})<Root>() {}
|
|
130
124
|
const RootLive = Composition.live(Root, function* () {
|
|
131
125
|
const count = yield* StateGroup.select(States, 'profiler-count')
|
|
132
126
|
return mount({ props: { count }, slots: {} })
|
|
133
127
|
})
|
|
134
128
|
|
|
135
129
|
const app = RootLive.pipe(
|
|
136
|
-
Layer.provideMerge(
|
|
130
|
+
Layer.provideMerge(
|
|
131
|
+
provide(
|
|
132
|
+
RootUi,
|
|
133
|
+
Ui.make(RootUi, () => null),
|
|
134
|
+
),
|
|
135
|
+
),
|
|
137
136
|
Layer.provideMerge(
|
|
138
137
|
Layer.mergeAll(BumpLive).pipe(
|
|
139
138
|
Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(States, { 'profiler-count': 0 }))),
|
|
@@ -168,7 +167,6 @@ test('profileScene records engine activity through makeAppRuntime and registers
|
|
|
168
167
|
await settle()
|
|
169
168
|
expect(listProfilers().map((entry) => entry.handle)).not.toContain(profiler)
|
|
170
169
|
|
|
171
|
-
// The base scene is untouched: no instrumentation carrier, nothing recorded.
|
|
172
170
|
const before = profiler.snapshot().events.get('ProfilerBumped')?.count
|
|
173
171
|
const plain = makeAppRuntime(baseScene)
|
|
174
172
|
plain.dispatch('High', Event.construct(Bumped, {}))
|
package/src/react/index.ts
CHANGED
|
@@ -1,5 +1 @@
|
|
|
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
1
|
export { downloadTrace, ReformProfiler } from './overlay'
|
package/src/react/noop.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import type { ProfilerHandle } from '../recorder'
|
|
3
3
|
|
|
4
|
-
// @playfast/reform-profiler/react — the PRODUCTION stub (`default` export
|
|
5
|
-
// condition). `<ReformProfiler/>` renders nothing; `downloadTrace` is inert.
|
|
6
|
-
|
|
7
4
|
export const ReformProfiler = (): ReactNode => null
|
|
8
5
|
|
|
9
6
|
export const downloadTrace = (_profiler: ProfilerHandle): void => {}
|
package/src/react/overlay.tsx
CHANGED
|
@@ -24,12 +24,7 @@ import {
|
|
|
24
24
|
tabStyle,
|
|
25
25
|
} from './styles'
|
|
26
26
|
|
|
27
|
-
//
|
|
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). */
|
|
27
|
+
// Plain React (not reform) so the profiler does not profile itself.
|
|
33
28
|
export const downloadTrace = (profiler: ProfilerHandle): void => {
|
|
34
29
|
const blob = new Blob([traceJson(profiler)], { type: 'application/json' })
|
|
35
30
|
const url = URL.createObjectURL(blob)
|
|
@@ -40,8 +35,6 @@ export const downloadTrace = (profiler: ProfilerHandle): void => {
|
|
|
40
35
|
URL.revokeObjectURL(url)
|
|
41
36
|
}
|
|
42
37
|
|
|
43
|
-
// ── data shaping ─────────────────────────────────────────────────────────────
|
|
44
|
-
|
|
45
38
|
interface Row {
|
|
46
39
|
readonly name: string
|
|
47
40
|
readonly stat: ProfileStat
|
|
@@ -62,7 +55,6 @@ const avgMs = (stat: ProfileStat): string =>
|
|
|
62
55
|
interface SectionProps {
|
|
63
56
|
readonly title: string
|
|
64
57
|
readonly rows: ReadonlyArray<Row>
|
|
65
|
-
/** Timed sections get total/avg columns; instant sections only a count. */
|
|
66
58
|
readonly timed: boolean
|
|
67
59
|
}
|
|
68
60
|
|
|
@@ -105,7 +97,9 @@ const DroppedNote = ({ count }: DroppedNoteProps): ReactNode => {
|
|
|
105
97
|
if (count === 0) {
|
|
106
98
|
return null
|
|
107
99
|
}
|
|
108
|
-
return
|
|
100
|
+
return (
|
|
101
|
+
<div style={{ color: palette.dim }}>({count} early records evicted from the ring buffer)</div>
|
|
102
|
+
)
|
|
109
103
|
}
|
|
110
104
|
|
|
111
105
|
interface ProfilerBodyProps {
|
|
@@ -137,13 +131,6 @@ const ProfilerBody = ({ profiler }: ProfilerBodyProps): ReactNode => {
|
|
|
137
131
|
)
|
|
138
132
|
}
|
|
139
133
|
|
|
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
134
|
export const ReformProfiler = (): ReactNode => {
|
|
148
135
|
const owner = useRef({}).current
|
|
149
136
|
const [claimed, setClaimed] = useState(false)
|
package/src/react/styles.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import type { CSSProperties } from 'react'
|
|
2
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
3
|
interface Palette {
|
|
7
4
|
readonly bg: string
|
|
8
5
|
readonly panel: string
|
package/src/recorder.ts
CHANGED
|
@@ -1,14 +1,5 @@
|
|
|
1
|
-
import { type Instrumentation, makeScheduler, type SpanEnd } from '@playfast/reform'
|
|
1
|
+
import { type Instrumentation, makeScheduler, type SpanEnd } from '@playfast/reform/internal'
|
|
2
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
3
|
export type ProfileKind =
|
|
13
4
|
| 'event'
|
|
14
5
|
| 'reducer'
|
|
@@ -21,23 +12,17 @@ export type ProfileKind =
|
|
|
21
12
|
|
|
22
13
|
export interface ProfileRecord {
|
|
23
14
|
readonly kind: ProfileKind
|
|
24
|
-
/** The definition name — event tag, reducer/calc/query/procedure/composition name. */
|
|
25
15
|
readonly name: string
|
|
26
|
-
/** Context: the trigger tag, `channel:tag`, priority, or frame event count. Empty when none. */
|
|
27
16
|
readonly detail: string
|
|
28
|
-
/** Start in ms since the page/process time origin (`performance.now()`). */
|
|
29
17
|
readonly start: number
|
|
30
|
-
/** Duration in ms; `0` for instant records. */
|
|
31
18
|
readonly dur: number
|
|
32
19
|
}
|
|
33
20
|
|
|
34
|
-
/** Aggregate for one name within a kind. Instant kinds carry `totalMs: 0`. */
|
|
35
21
|
export interface ProfileStat {
|
|
36
22
|
readonly count: number
|
|
37
23
|
readonly totalMs: number
|
|
38
24
|
}
|
|
39
25
|
|
|
40
|
-
/** A consistent copy of everything recorded so far. */
|
|
41
26
|
export interface ProfileSnapshot {
|
|
42
27
|
readonly events: ReadonlyMap<string, ProfileStat>
|
|
43
28
|
readonly reducers: ReadonlyMap<string, ProfileStat>
|
|
@@ -47,33 +32,20 @@ export interface ProfileSnapshot {
|
|
|
47
32
|
readonly procedures: ReadonlyMap<string, ProfileStat>
|
|
48
33
|
readonly renders: ReadonlyMap<string, ProfileStat>
|
|
49
34
|
readonly frames: ProfileStat
|
|
50
|
-
/** Raw records, oldest → newest, at most `capacity` of them. */
|
|
51
35
|
readonly records: ReadonlyArray<ProfileRecord>
|
|
52
|
-
/** Records evicted by the ring buffer (aggregates still include them). */
|
|
53
36
|
readonly dropped: number
|
|
54
37
|
}
|
|
55
38
|
|
|
56
|
-
// Boundary config object: optional fields are the public, JSON-like surface
|
|
57
|
-
// (`ExternalApi` postfix exempts them from `no-optional-fields`).
|
|
58
39
|
export interface ProfilerOptionsExternalApi {
|
|
59
|
-
/** Tab title in the overlay / report heading. Defaults to the scene's composition title. */
|
|
60
40
|
readonly label?: string
|
|
61
|
-
/** Ring-buffer size in records (default 50,000). Aggregates are unaffected. */
|
|
62
41
|
readonly capacity?: number
|
|
63
42
|
}
|
|
64
43
|
|
|
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
44
|
export interface ProfilerHandle extends Instrumentation {
|
|
71
45
|
readonly label: string
|
|
72
46
|
readonly snapshot: () => ProfileSnapshot
|
|
73
|
-
/** Drop all records and aggregates (the overlay's Clear button). */
|
|
74
47
|
readonly clear: () => void
|
|
75
48
|
readonly subscribe: (listener: () => void) => () => void
|
|
76
|
-
/** Monotone change counter — bumped once per recorded entry and per `clear`. */
|
|
77
49
|
readonly getVersion: () => number
|
|
78
50
|
}
|
|
79
51
|
|
|
@@ -84,8 +56,7 @@ const DEFAULT_CAPACITY = 50_000
|
|
|
84
56
|
export const makeProfiler = (options: ProfilerOptionsExternalApi = {}): ProfilerHandle => {
|
|
85
57
|
const capacity = Math.max(1, options.capacity ?? DEFAULT_CAPACITY)
|
|
86
58
|
const label = options.label ?? 'reform'
|
|
87
|
-
//
|
|
88
|
-
// is the next slot). In-place-mutated latch object, like the hosts' `status`.
|
|
59
|
+
// Fixed ring: written % capacity is next slot; aggregates keep full tally when records evict.
|
|
89
60
|
const emptyBuffer = (): Array<ProfileRecord | undefined> =>
|
|
90
61
|
Array.from({ length: capacity }, (): ProfileRecord | undefined => undefined)
|
|
91
62
|
const cursor = { buffer: emptyBuffer(), written: 0, version: 0 }
|
|
@@ -122,8 +93,7 @@ export const makeProfiler = (options: ProfilerOptionsExternalApi = {}): Profiler
|
|
|
122
93
|
readonly detail: string
|
|
123
94
|
}
|
|
124
95
|
|
|
125
|
-
const instant = (site: Site): void =>
|
|
126
|
-
record({ ...site, start: performance.now(), dur: 0 })
|
|
96
|
+
const instant = (site: Site): void => record({ ...site, start: performance.now(), dur: 0 })
|
|
127
97
|
|
|
128
98
|
const span = (site: Site): SpanEnd => {
|
|
129
99
|
const start = performance.now()
|
|
@@ -168,7 +138,6 @@ export const makeProfiler = (options: ProfilerOptionsExternalApi = {}): Profiler
|
|
|
168
138
|
},
|
|
169
139
|
getVersion: () => cursor.version,
|
|
170
140
|
|
|
171
|
-
// ── Instrumentation ────────────────────────────────────────────────────
|
|
172
141
|
eventDispatched: (tag, priority) => instant({ kind: 'event', name: tag, detail: priority }),
|
|
173
142
|
reducerRun: (name, eventTag) => span({ kind: 'reducer', name, detail: eventTag }),
|
|
174
143
|
stateUpdated: (name) => instant({ kind: 'state', name, detail: '' }),
|
package/src/registry.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { GlobalValue, Option } from 'effect'
|
|
2
2
|
import type { ProfilerHandle } from './recorder'
|
|
3
3
|
|
|
4
|
-
//
|
|
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.
|
|
4
|
+
// globalValue so duplicated bundles / HMR share one registry.
|
|
8
5
|
|
|
9
6
|
export interface ProfilerRegistration {
|
|
10
7
|
readonly label: string
|
|
@@ -12,11 +9,13 @@ export interface ProfilerRegistration {
|
|
|
12
9
|
}
|
|
13
10
|
|
|
14
11
|
interface RegistryState {
|
|
15
|
-
|
|
16
|
-
readonly entries: Map<
|
|
12
|
+
// Refcount: StrictMode double-build shares one entry.
|
|
13
|
+
readonly entries: Map<
|
|
14
|
+
ProfilerHandle,
|
|
15
|
+
{ readonly registration: ProfilerRegistration; readonly count: number }
|
|
16
|
+
>
|
|
17
17
|
readonly listeners: Set<() => void>
|
|
18
18
|
readonly version: { current: number }
|
|
19
|
-
/** The overlay instance currently elected to render the panel. */
|
|
20
19
|
readonly overlay: { owner: Option.Option<object> }
|
|
21
20
|
}
|
|
22
21
|
|
|
@@ -35,11 +34,6 @@ const notify = (): void => {
|
|
|
35
34
|
;[...state.listeners].forEach((listener) => listener())
|
|
36
35
|
}
|
|
37
36
|
|
|
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
37
|
export const registerProfiler = (registration: ProfilerRegistration): (() => void) => {
|
|
44
38
|
const existing = state.entries.get(registration.handle)
|
|
45
39
|
state.entries.set(registration.handle, {
|
|
@@ -63,11 +57,9 @@ export const registerProfiler = (registration: ProfilerRegistration): (() => voi
|
|
|
63
57
|
}
|
|
64
58
|
}
|
|
65
59
|
|
|
66
|
-
/** The live registrations, in registration order. */
|
|
67
60
|
export const listProfilers = (): ReadonlyArray<ProfilerRegistration> =>
|
|
68
61
|
[...state.entries.values()].map((entry) => entry.registration)
|
|
69
62
|
|
|
70
|
-
/** Subscribe to registry changes (register/unregister). Sync, rare. */
|
|
71
63
|
export const subscribeProfilers = (listener: () => void): (() => void) => {
|
|
72
64
|
state.listeners.add(listener)
|
|
73
65
|
return () => {
|
|
@@ -75,14 +67,9 @@ export const subscribeProfilers = (listener: () => void): (() => void) => {
|
|
|
75
67
|
}
|
|
76
68
|
}
|
|
77
69
|
|
|
78
|
-
/** Monotone registry change counter (`useSyncExternalStore` snapshot). */
|
|
79
70
|
export const profilersVersion = (): number => state.version.current
|
|
80
71
|
|
|
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
|
-
*/
|
|
72
|
+
// First mounted overlay claims render; later instances no-op until claim released.
|
|
86
73
|
export const claimOverlay = (owner: object): boolean => {
|
|
87
74
|
if (Option.isNone(state.overlay.owner)) {
|
|
88
75
|
state.overlay.owner = Option.some(owner)
|
|
@@ -91,7 +78,6 @@ export const claimOverlay = (owner: object): boolean => {
|
|
|
91
78
|
return Option.contains(state.overlay.owner, owner)
|
|
92
79
|
}
|
|
93
80
|
|
|
94
|
-
/** Release the panel claim so the next mounted instance can take over. */
|
|
95
81
|
export const releaseOverlay = (owner: object): void => {
|
|
96
82
|
if (Option.contains(state.overlay.owner, owner)) {
|
|
97
83
|
state.overlay.owner = Option.none()
|
package/src/report.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import type { ProfilerHandle, ProfileStat } from './recorder'
|
|
2
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
3
|
interface Row {
|
|
7
4
|
readonly name: string
|
|
8
5
|
readonly stat: ProfileStat
|
|
@@ -18,8 +15,6 @@ const sortedRows = (stats: ReadonlyMap<string, ProfileStat>): ReadonlyArray<Row>
|
|
|
18
15
|
const MS_DECIMALS = 2
|
|
19
16
|
const formatMs = (durationMs: number): string => durationMs.toFixed(MS_DECIMALS)
|
|
20
17
|
|
|
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
18
|
const COUNT_WIDTH = 7
|
|
24
19
|
const TOTAL_WIDTH = 9
|
|
25
20
|
const AVG_WIDTH = 8
|
|
@@ -28,7 +23,6 @@ const ruleOverhead = '── '.length + ' '.length
|
|
|
28
23
|
interface Section {
|
|
29
24
|
readonly title: string
|
|
30
25
|
readonly rows: ReadonlyArray<Row>
|
|
31
|
-
/** Timed sections get total/avg columns; instant sections only a count. */
|
|
32
26
|
readonly timed: boolean
|
|
33
27
|
}
|
|
34
28
|
|
|
@@ -53,7 +47,6 @@ const section = ({ title, rows, timed }: Section): ReadonlyArray<string> => {
|
|
|
53
47
|
]
|
|
54
48
|
}
|
|
55
49
|
|
|
56
|
-
/** The profile so far as aligned text tables, one section per non-empty kind. */
|
|
57
50
|
export const formatReport = (profiler: ProfilerHandle): string => {
|
|
58
51
|
const snapshot = profiler.snapshot()
|
|
59
52
|
const frameRows: ReadonlyArray<Row> =
|
package/src/trace.ts
CHANGED
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
import type { ProfileKind, ProfilerHandle } from './recorder'
|
|
2
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
3
|
export type ChromeTraceEvent =
|
|
11
4
|
| {
|
|
12
5
|
readonly ph: 'X'
|
|
@@ -42,7 +35,6 @@ export interface ChromeTrace {
|
|
|
42
35
|
readonly displayTimeUnit: 'ms'
|
|
43
36
|
}
|
|
44
37
|
|
|
45
|
-
// One thread lane per kind, so the Performance panel shows parallel tracks.
|
|
46
38
|
const lanes: Record<ProfileKind, { readonly tid: number; readonly title: string }> = {
|
|
47
39
|
frame: { tid: 1, title: 'frames' },
|
|
48
40
|
event: { tid: 2, title: 'events' },
|
|
@@ -59,11 +51,17 @@ const instantKinds: ReadonlySet<ProfileKind> = new Set(['event', 'state'])
|
|
|
59
51
|
const pid = 1
|
|
60
52
|
const US_PER_MS = 1000
|
|
61
53
|
|
|
62
|
-
/** Project the profiler's raw records into a Chrome DevTools trace object. */
|
|
63
54
|
export const toChromeTrace = (profiler: ProfilerHandle): ChromeTrace => {
|
|
64
55
|
const { records } = profiler.snapshot()
|
|
65
56
|
const meta: ReadonlyArray<ChromeTraceEvent> = [
|
|
66
|
-
{
|
|
57
|
+
{
|
|
58
|
+
ph: 'M',
|
|
59
|
+
name: 'process_name',
|
|
60
|
+
ts: 0,
|
|
61
|
+
pid,
|
|
62
|
+
tid: 0,
|
|
63
|
+
args: { name: `reform: ${profiler.label}` },
|
|
64
|
+
},
|
|
67
65
|
...Object.values(lanes).map(
|
|
68
66
|
(lane): ChromeTraceEvent => ({
|
|
69
67
|
ph: 'M',
|
|
@@ -80,14 +78,23 @@ export const toChromeTrace = (profiler: ProfilerHandle): ChromeTrace => {
|
|
|
80
78
|
const cat = record.kind
|
|
81
79
|
const tid = lanes[record.kind].tid
|
|
82
80
|
const args = record.detail === '' ? {} : { detail: record.detail }
|
|
81
|
+
const complete: ChromeTraceEvent = {
|
|
82
|
+
ph: 'X',
|
|
83
|
+
name: record.name,
|
|
84
|
+
cat,
|
|
85
|
+
ts: startUs,
|
|
86
|
+
dur: record.dur * US_PER_MS,
|
|
87
|
+
pid,
|
|
88
|
+
tid,
|
|
89
|
+
args,
|
|
90
|
+
}
|
|
83
91
|
return instantKinds.has(record.kind)
|
|
84
92
|
? { ph: 'i', name: record.name, cat, ts: startUs, pid, tid, s: 't', args }
|
|
85
|
-
:
|
|
93
|
+
: complete
|
|
86
94
|
})
|
|
87
95
|
return { traceEvents: [...meta, ...body], displayTimeUnit: 'ms' }
|
|
88
96
|
}
|
|
89
97
|
|
|
90
|
-
/** `toChromeTrace` as a JSON string, ready to write to a `.json` file. */
|
|
91
98
|
export const traceJson = (profiler: ProfilerHandle): string =>
|
|
92
99
|
// 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
100
|
JSON.stringify(toChromeTrace(profiler))
|