Skip to content

Commit c6456e9

Browse files
committed
feat(scan): replay collection runtime
collect detailed browser timing on interaction implement react scan rrweb replayer plugin refactor monitoring interaction and component collection
1 parent f982809 commit c6456e9

27 files changed

+4690
-387
lines changed

packages/scan/package.json

+37-9
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
{
22
"name": "react-scan",
3-
"version": "0.0.54",
3+
"version": "0.0.1083",
44
"description": "Scan your React app for renders",
5-
"keywords": ["react", "react-scan", "react scan", "render", "performance"],
5+
"keywords": [
6+
"react",
7+
"react-scan",
8+
"react scan",
9+
"render",
10+
"performance"
11+
],
612
"homepage": "https://react-scan.million.dev",
713
"bugs": {
814
"url": "https://github.com/aidenybai/react-scan/issues"
@@ -161,17 +167,27 @@
161167
"types": "dist/index.d.ts",
162168
"typesVersions": {
163169
"*": {
164-
"monitoring": ["./dist/core/monitor/index.d.ts"],
165-
"monitoring/next": ["./dist/core/monitor/params/next.d.ts"],
170+
"monitoring": [
171+
"./dist/core/monitor/index.d.ts"
172+
],
173+
"monitoring/next": [
174+
"./dist/core/monitor/params/next.d.ts"
175+
],
166176
"monitoring/react-router-legacy": [
167177
"./dist/core/monitor/params/react-router-v5.d.ts"
168178
],
169179
"monitoring/react-router": [
170180
"./dist/core/monitor/params/react-router-v6.d.ts"
171181
],
172-
"monitoring/remix": ["./dist/core/monitor/params/remix.d.ts"],
173-
"monitoring/astro": ["./dist/core/monitor/params/astro/index.ts"],
174-
"react-component-name/vite": ["./dist/react-component-name/vite.d.ts"],
182+
"monitoring/remix": [
183+
"./dist/core/monitor/params/remix.d.ts"
184+
],
185+
"monitoring/astro": [
186+
"./dist/core/monitor/params/astro/index.ts"
187+
],
188+
"react-component-name/vite": [
189+
"./dist/react-component-name/vite.d.ts"
190+
],
175191
"react-component-name/webpack": [
176192
"./dist/react-component-name/webpack.d.ts"
177193
],
@@ -187,11 +203,20 @@
187203
"react-component-name/rollup": [
188204
"./dist/react-component-name/rollup.d.ts"
189205
],
190-
"react-component-name/astro": ["./dist/react-component-name/astro.d.ts"]
206+
"react-component-name/astro": [
207+
"./dist/react-component-name/astro.d.ts"
208+
]
191209
}
192210
},
193211
"bin": "bin/cli.js",
194-
"files": ["dist", "bin", "package.json", "README.md", "LICENSE", "auto.d.ts"],
212+
"files": [
213+
"dist",
214+
"bin",
215+
"package.json",
216+
"README.md",
217+
"LICENSE",
218+
"auto.d.ts"
219+
],
195220
"scripts": {
196221
"build": "npm run build:css && NODE_ENV=production tsup",
197222
"postbuild": "pnpm copy-astro && node ../../scripts/version-warning.mjs",
@@ -217,6 +242,7 @@
217242
"@clack/prompts": "^0.8.2",
218243
"@preact/signals": "^1.3.1",
219244
"@rollup/pluginutils": "^5.1.3",
245+
"@rrweb/types": "2.0.0-alpha.18",
220246
"@types/node": "^20.17.9",
221247
"bippy": "^0.0.25",
222248
"esbuild": "^0.24.0",
@@ -225,6 +251,8 @@
225251
"mri": "^1.2.0",
226252
"playwright": "^1.49.0",
227253
"preact": "^10.25.1",
254+
"rrweb": "2.0.0-alpha.4",
255+
"rrweb-snapshot": "2.0.0-alpha.4",
228256
"tsx": "^4.0.0"
229257
},
230258
"devDependencies": {

packages/scan/src/auto-monitor.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'bippy'; // implicit init RDT hook
22
import { Store } from 'src';
33
import { scanMonitoring } from 'src/core/monitor';
4-
import { initPerformanceMonitoring } from 'src/core/monitor/performance';
4+
// import { initPerformanceMonitoring } from 'src/core/monitor/performance';
55
import { Device } from 'src/core/monitor/types';
66

77
if (typeof window !== 'undefined') {
@@ -28,9 +28,10 @@ if (typeof window !== 'undefined') {
2828
route: '<mock-route>',
2929
commit: '<mock-commit>',
3030
branch: '<mock-branch>',
31+
interactionListeningForRenders: null,
3132
};
32-
scanMonitoring({
33-
enabled: true,
34-
});
35-
initPerformanceMonitoring();
33+
// scanMonitoring({
34+
// enabled: true,
35+
// });
36+
// initPerformanceMonitoring();
3637
}

packages/scan/src/auto.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import 'bippy'; // implicit init RDT hook
22
import { scan } from './index';
33

44
if (typeof window !== 'undefined') {
5-
scan();
5+
scan({
6+
dangerouslyForceRunInProduction: true,
7+
});
68
window.reactScan = scan;
79
}
810

packages/scan/src/core/index.ts

+28-5
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ export type MonitoringOptions = Pick<
172172
interface Monitor {
173173
pendingRequests: number;
174174
interactions: Array<InternalInteraction>;
175+
interactionListeningForRenders:
176+
| ((fiber: Fiber, renders: Array<Render>) => void)
177+
| null;
175178
session: ReturnType<typeof getSession>;
176179
url: string | null;
177180
route: string | null;
@@ -381,12 +384,14 @@ export const reportRender = (fiber: Fiber, renders: Array<Render>) => {
381384

382385
// Get data from both current and alternate fibers
383386
const currentData = Store.reportData.get(reportFiber);
384-
const alternateData = fiber.alternate ? Store.reportData.get(fiber.alternate) : null;
387+
const alternateData = fiber.alternate
388+
? Store.reportData.get(fiber.alternate)
389+
: null;
385390

386391
// More efficient null checks and Math.max
387392
const existingCount = Math.max(
388393
(currentData && currentData.count) || 0,
389-
(alternateData && alternateData.count) || 0
394+
(alternateData && alternateData.count) || 0,
390395
);
391396

392397
// Create single shared object for both fibers
@@ -395,7 +400,7 @@ export const reportRender = (fiber: Fiber, renders: Array<Render>) => {
395400
time: selfTime || 0,
396401
renders,
397402
displayName,
398-
type: getType(fiber.type) || null
403+
type: getType(fiber.type) || null,
399404
};
400405

401406
// Store in both fibers
@@ -461,7 +466,12 @@ const updateScheduledOutlines = (fiber: Fiber, renders: Array<Render>) => {
461466
for (let i = 0, len = renders.length; i < len; i++) {
462467
const render = renders[i];
463468
const domFiber = getNearestHostFiber(fiber);
464-
if (!domFiber || !domFiber.stateNode || !(domFiber.stateNode instanceof Element)) continue;
469+
if (
470+
!domFiber ||
471+
!domFiber.stateNode ||
472+
!(domFiber.stateNode instanceof Element)
473+
)
474+
continue;
465475

466476
if (ReactScanInternals.scheduledOutlines.has(fiber)) {
467477
const existingOutline = ReactScanInternals.scheduledOutlines.get(fiber)!;
@@ -512,6 +522,10 @@ export const getIsProduction = () => {
512522
return isProduction;
513523
};
514524

525+
export const attachReplayCanvas = () => {
526+
startFlushOutlineInterval();
527+
};
528+
515529
export const start = () => {
516530
if (typeof window === 'undefined') return;
517531

@@ -540,6 +554,13 @@ export const start = () => {
540554

541555
const instrumentation = createInstrumentation('devtools', {
542556
onActive() {
557+
const rdtHook = getRDTHook();
558+
for (const renderer of rdtHook.renderers.values()) {
559+
const buildType = detectReactBuildType(renderer);
560+
if (buildType === 'production') {
561+
isProduction = true;
562+
}
563+
}
543564
const existingRoot = document.querySelector('react-scan-root');
544565
if (existingRoot) {
545566
return;
@@ -556,7 +577,9 @@ export const start = () => {
556577
void audioContext.resume();
557578
};
558579

559-
window.addEventListener('pointerdown', createAudioContextOnInteraction, { once: true });
580+
window.addEventListener('pointerdown', createAudioContextOnInteraction, {
581+
once: true,
582+
});
560583

561584
const container = document.createElement('div');
562585
container.id = 'react-scan-root';

packages/scan/src/core/instrumentation.ts

+37-10
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,42 @@ let lastTime = performance.now();
2323
let frameCount = 0;
2424
let initedFps = false;
2525

26-
const updateFPS = () => {
26+
let fpsListeners: Array<(fps: number) => void> = [];
27+
28+
export const listenToFps = (listener: (fps: number) => void) => {
29+
// console.log('oushed', listener);
30+
31+
fpsListeners.push(listener);
32+
33+
return () => {
34+
// console.log('unsub listener');
35+
36+
fpsListeners = fpsListeners.filter(
37+
(currListener) => currListener !== listener,
38+
);
39+
};
40+
};
41+
42+
const updateFPS = (onChange?: (fps: number) => void) => {
2743
frameCount++;
2844
const now = performance.now();
29-
if (now - lastTime >= 1000) {
30-
fps = frameCount;
45+
const timeSinceLastUpdate = now - lastTime;
46+
47+
if (timeSinceLastUpdate >= 500) {
48+
const calculatedFPS = Math.round((frameCount / timeSinceLastUpdate) * 1000);
49+
50+
if (calculatedFPS !== fps) {
51+
for (const listener of fpsListeners) {
52+
listener(calculatedFPS);
53+
}
54+
}
55+
56+
fps = calculatedFPS;
3157
frameCount = 0;
3258
lastTime = now;
3359
}
34-
requestAnimationFrame(updateFPS);
60+
61+
requestAnimationFrame(() => updateFPS(onChange));
3562
};
3663

3764
export const getFPS = () => {
@@ -361,30 +388,30 @@ export const createInstrumentation = (
361388

362389
const changes: Array<RenderChange> = [];
363390

364-
const propsChanges = getChangedPropsDetailed(fiber).map(change => ({
391+
const propsChanges = getChangedPropsDetailed(fiber).map((change) => ({
365392
type: 'props' as const,
366393
name: change.name,
367394
value: change.value,
368395
prevValue: change.prevValue,
369-
unstable: false
396+
unstable: false,
370397
}));
371398

372-
const stateChanges = getStateChanges(fiber).map(change => ({
399+
const stateChanges = getStateChanges(fiber).map((change) => ({
373400
type: 'state' as const,
374401
name: change.name,
375402
value: change.value,
376403
prevValue: change.prevValue,
377404
count: change.count,
378-
unstable: false
405+
unstable: false,
379406
}));
380407

381-
const contextChanges = getContextChanges(fiber).map(change => ({
408+
const contextChanges = getContextChanges(fiber).map((change) => ({
382409
type: 'context' as const,
383410
name: change.name,
384411
value: change.value,
385412
prevValue: change.prevValue,
386413
count: change.count,
387-
unstable: false
414+
unstable: false,
388415
}));
389416

390417
changes.push(...propsChanges, ...stateChanges, ...contextChanges);

packages/scan/src/core/monitor/index.ts

+24-18
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,9 @@ import {
1010
} from '..';
1111
import { createInstrumentation, type Render } from '../instrumentation';
1212
import { updateFiberRenderData } from '../utils';
13-
import { initPerformanceMonitoring } from './performance';
1413
import { getSession } from './utils';
1514
import { flush } from './network';
16-
import { computeRoute } from './params/utils';
15+
import { scanWithRecord } from 'src/core/monitor/session-replay/record';
1716

1817
// max retries before the set of components do not get reported (avoid memory leaks of the set of fibers stored on the component aggregation)
1918
const MAX_RETRIES_BEFORE_COMPONENT_GC = 7;
@@ -50,30 +49,23 @@ export const Monitoring = ({
5049
}: MonitoringProps) => {
5150
if (!apiKey)
5251
throw new Error('Please provide a valid API key for React Scan monitoring');
53-
url ??= 'https://monitoring.react-scan.com/api/v1/ingest';
54-
52+
// url ??= "https://monitoring.react-scan.com/api/v1/ingest";
5553
Store.monitor.value ??= {
5654
pendingRequests: 0,
55+
url: 'http://localhost:4200/api/ingest',
56+
apiKey,
5757
interactions: [],
5858
session: getSession({ commit, branch }).catch(() => null),
59-
url,
60-
apiKey,
6159
route,
62-
commit,
63-
branch,
64-
};
60+
branch: 'main',
61+
commit: '0x00000',
6562

66-
// When using Monitoring without framework, we need to compute the route from the path and params
67-
if (!route && path && params) {
68-
Store.monitor.value.route = computeRoute(path, params);
69-
} else if (typeof window !== 'undefined') {
70-
Store.monitor.value.route =
71-
route ?? path ?? new URL(window.location.toString()).pathname; // this is inaccurate on vanilla react if the path is not provided but used for session route
72-
}
63+
interactionListeningForRenders: null,
64+
};
7365

7466
useEffect(() => {
67+
scanWithRecord();
7568
scanMonitoring({ enabled: true });
76-
return initPerformanceMonitoring();
7769
}, []);
7870

7971
return null;
@@ -101,6 +93,7 @@ export const startMonitoring = () => {
10193

10294
flushInterval = setInterval(() => {
10395
try {
96+
10497
void flush();
10598
} catch {
10699
/* */
@@ -126,6 +119,7 @@ export const startMonitoring = () => {
126119
if (isCompositeFiber(fiber)) {
127120
aggregateComponentRenderToInteraction(fiber, renders);
128121
}
122+
publishToListeningInteraction(fiber, renders);
129123
ReactScanInternals.options.value.onRender?.(fiber, renders);
130124
},
131125
onCommitFinish() {
@@ -151,7 +145,7 @@ const aggregateComponentRenderToInteraction = (
151145
const displayName = getDisplayName(fiber.type);
152146
if (!displayName) return; // TODO(nisarg): it may be useful to somehow report the first ancestor with a display name instead of completely ignoring
153147

154-
let component = lastInteraction.components.get(displayName); // TODO(nisarg): Same names are grouped together which is wrong.
148+
let component = lastInteraction.components.get(displayName); // TODO(rob): we can be more precise with fiber types, but display name is fine for now
155149

156150
if (!component) {
157151
component = {
@@ -181,3 +175,15 @@ const aggregateComponentRenderToInteraction = (
181175
component.selfTime += selfTime;
182176
}
183177
};
178+
179+
const publishToListeningInteraction = (
180+
fiber: Fiber,
181+
renders: Array<Render>,
182+
) => {
183+
const monitor = Store.monitor.value;
184+
if (!monitor || !monitor.interactionListeningForRenders) {
185+
return;
186+
}
187+
188+
monitor.interactionListeningForRenders(fiber, renders);
189+
};

0 commit comments

Comments
 (0)