Skip to content

Commit 2b5b313

Browse files
authored
Memory leak (#149)
* chore(scan): optimize perf of core scan - use IntersectionObserver over getClientBoundingRect to minimize reflows - optimize fast serialize sample renders to run isRenderUnncessaryOn dont merge outlines when there are significant active outlines flush async instead of in hot path/ avoid promise creation every render dont draw outlines that are out of viewport * chore(scan): fix memory leak during frequent re-renders refactor core outline logic to aggregate data at time of collection optimize toolbar subscriptions to not impact perf when not active dont waste computation on report render in monitoring * fix(toolbar): cancel animation on cleanup * fix log * fixups * fix toolbar bug * cleanup * add back minification * add back scan in example * remove force run in production * fix(scan): dont draw when tab is not visible * rm old lint suppression * disable isRenderUnnecessary * rm debug file
1 parent 569763d commit 2b5b313

File tree

13 files changed

+658
-457
lines changed

13 files changed

+658
-457
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ module.exports = {
1818
sourceType: 'module',
1919
},
2020
rules: {
21+
"@typescript-eslint/restrict-plus-operands":"off",
2122
"@typescript-eslint/no-unused-vars":"warn",
2223
'@typescript-eslint/explicit-function-return-type': 'off',
2324
'import/no-default-export': 'off',

git

Whitespace-only changes.

packages/scan/src/cli.mts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ const applyStealthScripts = async (context: BrowserContext) => {
8888
window.navigator.permissions.query = (parameters: any) =>
8989
parameters.name === 'notifications'
9090
? Promise.resolve({
91-
state: Notification.permission,
92-
} as PermissionStatus)
91+
state: Notification.permission,
92+
} as PermissionStatus)
9393
: originalQuery(parameters);
9494
});
9595
};
@@ -139,7 +139,8 @@ const init = async () => {
139139
const installPromise = new Promise<void>((resolve, reject) => {
140140
const runInstall = () => {
141141
confirm({
142-
message: 'No drivers found. Install Playwright Chromium driver to continue?',
142+
message:
143+
'No drivers found. Install Playwright Chromium driver to continue?',
143144
}).then((shouldInstall) => {
144145
if (isCancel(shouldInstall)) {
145146
cancel('Operation cancelled.');
@@ -157,7 +158,10 @@ const init = async () => {
157158

158159
installProcess.on('close', (code) => {
159160
if (!code) resolve();
160-
else reject(new Error(`Installation process exited with code ${code}`));
161+
else
162+
reject(
163+
new Error(`Installation process exited with code ${code}`),
164+
);
161165
});
162166

163167
installProcess.on('error', reject);

packages/scan/src/core/index.ts

Lines changed: 74 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,26 @@ import {
1212
traverseFiber,
1313
detectReactBuildType,
1414
} from 'bippy';
15-
import {
16-
type ActiveOutline,
17-
flushOutlines,
18-
type PendingOutline,
19-
} from '@web-utils/outline';
15+
import { flushOutlines, type Outline } from '@web-utils/outline';
2016
import { log, logIntro } from '@web-utils/log';
2117
import {
2218
createInspectElementStateMachine,
2319
type States,
2420
} from '@web-inspect-element/inspect-state-machine';
2521
import { playGeigerClickSound } from '@web-utils/geiger';
2622
import { ICONS } from '@web-assets/svgs/svgs';
27-
import { updateFiberRenderData, type RenderData } from 'src/core/utils';
23+
import {
24+
aggregateChanges,
25+
aggregateRender,
26+
updateFiberRenderData,
27+
type RenderData,
28+
} from 'src/core/utils';
2829
import { readLocalStorage, saveLocalStorage } from '@web-utils/helpers';
2930
import { initReactScanOverlay } from './web/overlay';
30-
import { createInstrumentation, type Render } from './instrumentation';
31+
import {
32+
createInstrumentation,
33+
type Render,
34+
} from './instrumentation';
3135
import { createToolbar } from './web/toolbar';
3236
import type { InternalInteraction } from './monitor/types';
3337
import { type getSession } from './monitor/utils';
@@ -70,6 +74,8 @@ export interface Options {
7074
/**
7175
* Log renders to the console
7276
*
77+
* WARNING: This can add significant overhead when the app re-renders frequently
78+
*
7379
* @default false
7480
*/
7581
log?: boolean;
@@ -130,8 +136,8 @@ export interface Options {
130136
onCommitStart?: () => void;
131137
onRender?: (fiber: Fiber, renders: Array<Render>) => void;
132138
onCommitFinish?: () => void;
133-
onPaintStart?: (outlines: Array<PendingOutline>) => void;
134-
onPaintFinish?: (outlines: Array<PendingOutline>) => void;
139+
onPaintStart?: (outlines: Array<Outline>) => void;
140+
onPaintFinish?: (outlines: Array<Outline>) => void;
135141
}
136142

137143
export type MonitoringOptions = Pick<
@@ -169,12 +175,15 @@ interface StoreType {
169175
lastReportTime: Signal<number>;
170176
}
171177

178+
export type OutlineKey = `${string}-${string}`;
179+
172180
export interface Internals {
173181
instrumentation: ReturnType<typeof createInstrumentation> | null;
174182
componentAllowList: WeakMap<React.ComponentType<any>, Options> | null;
175183
options: Signal<Options>;
176-
scheduledOutlines: Array<PendingOutline>;
177-
activeOutlines: Array<ActiveOutline>;
184+
scheduledOutlines: Map<Fiber, Outline>; // we clear t,his nearly immediately, so no concern of mem leak on the fiber
185+
// outlines at the same coordinates always get merged together, so we pre-compute the merge ahead of time when aggregating in activeOutlines
186+
activeOutlines: Map<OutlineKey, Outline>; // we re-use the outline object on the scheduled outline
178187
onRender: ((fiber: Fiber, renders: Array<Render>) => void) | null;
179188
Store: StoreType;
180189
}
@@ -210,8 +219,8 @@ export const ReactScanInternals: Internals = {
210219
dangerouslyForceRunInProduction: false,
211220
}),
212221
onRender: null,
213-
scheduledOutlines: [],
214-
activeOutlines: [],
222+
scheduledOutlines: new Map(),
223+
activeOutlines: new Map(),
215224
Store,
216225
};
217226

@@ -340,7 +349,7 @@ export const reportRender = (fiber: Fiber, renders: Array<Render>) => {
340349
const { selfTime } = getTimings(fiber.type);
341350
const displayName = getDisplayName(fiber.type);
342351

343-
Store.lastReportTime.value = performance.now();
352+
Store.lastReportTime.value = Date.now();
344353

345354
const currentFiberData = Store.reportData.get(reportFiber) ?? {
346355
count: 0,
@@ -367,9 +376,7 @@ export const reportRender = (fiber: Fiber, renders: Array<Render>) => {
367376
type: getType(fiber.type) || fiber.type,
368377
};
369378

370-
existingLegacyData.count =
371-
Number(existingLegacyData.count || 0) + Number(renders.length);
372-
existingLegacyData.time =
379+
existingLegacyData.count = existingLegacyData.time =
373380
Number(existingLegacyData.time || 0) + Number(selfTime || 0);
374381
existingLegacyData.renders = renders;
375382

@@ -541,18 +548,32 @@ export const start = () => {
541548
},
542549
isValidFiber,
543550
onRender(fiber, renders) {
544-
if (Boolean(ReactScanInternals.instrumentation?.isPaused.value) || !ctx) {
545-
// don't draw if it's paused
551+
if (
552+
Boolean(ReactScanInternals.instrumentation?.isPaused.value) ||
553+
!ctx ||
554+
document.visibilityState !== 'visible'
555+
) {
556+
// don't draw if it's paused or tab is not active
546557
return;
547558
}
548559
updateFiberRenderData(fiber, renders);
560+
if (ReactScanInternals.options.value.log) {
561+
// this can be expensive given enough re-renders
562+
log(renders);
563+
}
549564

550565
if (isCompositeFiber(fiber)) {
551-
reportRender(fiber, renders);
566+
// report render has a non trivial cost because it calls Date.now(), so we want to avoid the computation if possible
567+
if (
568+
ReactScanInternals.options.value.showToolbar !== false &&
569+
Store.inspectState.value.kind === 'focused'
570+
) {
571+
reportRender(fiber, renders);
572+
}
552573
}
553574

554575
if (ReactScanInternals.options.value.log) {
555-
log(renders);
576+
renders;
556577
}
557578

558579
ReactScanInternals.options.value.onRender?.(fiber, renders);
@@ -562,10 +583,38 @@ export const start = () => {
562583
const domFiber = getNearestHostFiber(fiber);
563584
if (!domFiber || !domFiber.stateNode) continue;
564585

565-
ReactScanInternals.scheduledOutlines.push({
566-
domNode: domFiber.stateNode,
567-
renders,
568-
});
586+
if (ReactScanInternals.scheduledOutlines.has(fiber)) {
587+
const existingOutline =
588+
ReactScanInternals.scheduledOutlines.get(fiber)!;
589+
aggregateRender(render, existingOutline.aggregatedRender);
590+
} else {
591+
ReactScanInternals.scheduledOutlines.set(fiber, {
592+
domNode: domFiber.stateNode,
593+
aggregatedRender: {
594+
name:
595+
renders.find((render) => render.componentName)?.componentName ??
596+
'Unknown',
597+
aggregatedCount: 1,
598+
changes: aggregateChanges(render.changes),
599+
didCommit: render.didCommit,
600+
forget: render.forget,
601+
fps: render.fps,
602+
phase: new Set([render.phase]),
603+
time: render.time,
604+
// todo: add back a when clear use case in the UI is needed for isRenderUnnecessary, or performance is optimized
605+
// unnecessary: isRenderUnnecessary(fiber),
606+
unnecessary: false,
607+
frame: 0,
608+
609+
computedKey: null,
610+
},
611+
alpha: null,
612+
groupedAggregatedRender: null,
613+
rect: null,
614+
totalFrames: null,
615+
estimatedTextWidth: null,
616+
});
617+
}
569618

570619
// - audio context can take up an insane amount of cpu, todo: figure out why
571620
// - we may want to take this out of hot path

packages/scan/src/core/instrumentation.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
traverseProps,
1515
} from 'bippy';
1616
import { type Signal, signal } from '@preact/signals';
17-
import { ReactScanInternals } from './index';
17+
import { ReactScanInternals, Store } from './index';
1818

1919
let fps = 0;
2020
let lastTime = performance.now();
@@ -75,23 +75,29 @@ export const isElementInViewport = (
7575
return isVisible && rect.width && rect.height;
7676
};
7777

78-
export interface Change {
78+
export interface RenderChange {
7979
type: 'props' | 'context' | 'state';
8080
name: string;
8181
prevValue: unknown;
8282
nextValue: unknown;
8383
unstable: boolean;
8484
}
8585

86+
// this must grow O(1) w.r.t to renders
87+
export interface AggregatedChange {
88+
type: Set<'props' | 'context' | 'state'>;
89+
unstable: boolean;
90+
}
91+
8692
export type Category = 'commit' | 'unstable' | 'unnecessary';
8793

8894
export interface Render {
89-
phase: string;
95+
phase: 'mount' | 'update' | 'unmount';
9096
componentName: string | null;
9197
time: number | null;
92-
count: number;
98+
count: number; // uh is this right?
9399
forget: boolean;
94-
changes: Array<Change> | null;
100+
changes: Array<RenderChange>;
95101
unnecessary: boolean | null;
96102
didCommit: boolean;
97103
fps: number;
@@ -160,12 +166,11 @@ export function fastSerialize(value: unknown, depth = 0): string {
160166
}
161167

162168
export const getPropsChanges = (fiber: Fiber) => {
163-
const changes: Array<Change> = [];
169+
const changes: Array<RenderChange> = [];
164170

165171
const prevProps = fiber.alternate?.memoizedProps || {};
166172
const nextProps = fiber.memoizedProps || {};
167173

168-
169174
const allKeys = new Set([
170175
...Object.keys(prevProps),
171176
...Object.keys(nextProps),
@@ -181,7 +186,7 @@ export const getPropsChanges = (fiber: Fiber) => {
181186
) {
182187
continue;
183188
}
184-
const change: Change = {
189+
const change: RenderChange = {
185190
type: 'props',
186191
name: propName,
187192
prevValue,
@@ -199,13 +204,13 @@ export const getPropsChanges = (fiber: Fiber) => {
199204
};
200205

201206
export const getStateChanges = (fiber: Fiber) => {
202-
const changes: Array<Change> = [];
207+
const changes: Array<RenderChange> = [];
203208

204209
traverseState(fiber, (prevState, nextState) => {
205210
if (Object.is(prevState.memoizedState, nextState.memoizedState)) return;
206-
const change: Change = {
211+
const change: RenderChange = {
207212
type: 'state',
208-
name: '',
213+
name: '', // bad interface should make this a discriminated union
209214
prevValue: prevState.memoizedState,
210215
nextValue: nextState.memoizedState,
211216
unstable: false,
@@ -217,13 +222,13 @@ export const getStateChanges = (fiber: Fiber) => {
217222
};
218223

219224
export const getContextChanges = (fiber: Fiber) => {
220-
const changes: Array<Change> = [];
225+
const changes: Array<RenderChange> = [];
221226

222227
traverseContexts(fiber, (prevContext, nextContext) => {
223228
const prevValue = prevContext.memoizedValue;
224229
const nextValue = nextContext.memoizedValue;
225230

226-
const change: Change = {
231+
const change: RenderChange = {
227232
type: 'context',
228233
name: '',
229234
prevValue,
@@ -329,7 +334,7 @@ export const createInstrumentation = (
329334
}
330335
if (!validInstancesIndicies.length) return null;
331336

332-
const changes: Array<Change> = [];
337+
const changes: Array<RenderChange> = [];
333338

334339
const propsChanges = getPropsChanges(fiber);
335340
const stateChanges = getStateChanges(fiber);
@@ -359,8 +364,7 @@ export const createInstrumentation = (
359364
changes,
360365
time: selfTime,
361366
forget: hasMemoCache(fiber),
362-
// only collect if the render was unnecessary 5% of the time since is isRenderUnnecessary is expensive
363-
unnecessary: Math.random() < 0.05 ? isRenderUnnecessary(fiber) : null,
367+
unnecessary: Store.monitor ? null : isRenderUnnecessary(fiber),
364368
didCommit: didFiberCommit(fiber),
365369
fps,
366370
};

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,6 @@ export const startMonitoring = () => {
132132
updateFiberRenderData(fiber, renders);
133133

134134
if (isCompositeFiber(fiber)) {
135-
reportRender(fiber, renders);
136135
aggregateComponentRenderToInteraction(fiber, renders);
137136
}
138137
ReactScanInternals.options.value.onRender?.(fiber, renders);

0 commit comments

Comments
 (0)