Skip to content

Commit a1b1e1f

Browse files
authored
chore(scan): optimize perf of core scan (#138)
- 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
1 parent d29e638 commit a1b1e1f

File tree

11 files changed

+627
-359
lines changed

11 files changed

+627
-359
lines changed

.eslintrc.js

Lines changed: 3 additions & 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/no-unused-vars":"warn",
2122
'@typescript-eslint/explicit-function-return-type': 'off',
2223
'import/no-default-export': 'off',
2324
'no-bitwise': 'off',
@@ -47,12 +48,14 @@ module.exports = {
4748
'no-implicit-coercion': 'off',
4849
'@typescript-eslint/no-redundant-type-constituents': 'off',
4950
'object-shorthand': 'off',
51+
"@typescript-eslint/no-unused-vars":"warn",
5052
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
5153
'no-useless-return': 'off',
5254
'func-names': 'off',
5355
'@typescript-eslint/prefer-for-of': 'off',
5456
'@typescript-eslint/restrict-template-expressions': 'off',
5557
'@typescript-eslint/array-type': ['error', { default: 'generic' }],
58+
"no-console":'warn'
5659
},
5760
settings: {
5861
'import/resolver': {

packages/scan/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,8 @@
249249
"mri": "^1.2.0",
250250
"playwright": "^1.49.0",
251251
"preact": "^10.25.1",
252-
"tsx": "^4.0.0"
252+
"tsx": "^4.0.0",
253+
"vite-tsconfig-paths": "^5.1.4"
253254
},
254255
"devDependencies": {
255256
"@esbuild-plugins/tsconfig-paths": "^0.1.2",
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { fastSerialize } from 'src/core/instrumentation';
3+
4+
describe('fastSerialize', () => {
5+
it('serializes null', () => {
6+
expect(fastSerialize(null)).toBe('null');
7+
});
8+
9+
it('serializes undefined', () => {
10+
expect(fastSerialize(undefined)).toBe('undefined');
11+
});
12+
13+
it('serializes strings', () => {
14+
expect(fastSerialize('hello')).toBe('hello');
15+
expect(fastSerialize('')).toBe('');
16+
});
17+
18+
it('serializes numbers', () => {
19+
expect(fastSerialize(42)).toBe('42');
20+
expect(fastSerialize(0)).toBe('0');
21+
expect(fastSerialize(NaN)).toBe('NaN');
22+
});
23+
24+
it('serializes booleans', () => {
25+
expect(fastSerialize(true)).toBe('true');
26+
expect(fastSerialize(false)).toBe('false');
27+
});
28+
29+
it('serializes functions', () => {
30+
// eslint-disable-next-line @typescript-eslint/no-empty-function
31+
function testFunc() {}
32+
expect(fastSerialize(testFunc)).toBe('function');
33+
});
34+
35+
it('serializes arrays', () => {
36+
expect(fastSerialize([])).toBe('[]');
37+
expect(fastSerialize([1, 2, 3])).toBe('[3]');
38+
});
39+
40+
it('serializes plain objects', () => {
41+
expect(fastSerialize({})).toBe('{}');
42+
expect(fastSerialize({ a: 1, b: 2 })).toBe('{2}');
43+
});
44+
45+
it('serializes deeply nested objects with depth limit', () => {
46+
const nested = { a: { b: { c: 1 } } };
47+
expect(fastSerialize(nested, 0)).toBe('{1}');
48+
expect(fastSerialize(nested, -1)).toBe('…');
49+
});
50+
51+
it('serializes objects with custom constructors', () => {
52+
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
53+
class CustomClass {}
54+
const instance = new CustomClass();
55+
expect(fastSerialize(instance)).toBe('CustomClass{…}');
56+
});
57+
58+
it('serializes unknown objects gracefully', () => {
59+
const date = new Date();
60+
const serialized = fastSerialize(date);
61+
expect(serialized.includes('Date')).toBe(true);
62+
});
63+
});

packages/scan/src/core/index.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { type Signal, signal } from '@preact/signals';
44
import {
55
getDisplayName,
66
getRDTHook,
7+
getNearestHostFiber,
78
getTimings,
89
getType,
910
isCompositeFiber,
@@ -14,7 +15,6 @@ import {
1415
import {
1516
type ActiveOutline,
1617
flushOutlines,
17-
getOutline,
1818
type PendingOutline,
1919
} from '@web-utils/outline';
2020
import { log, logIntro } from '@web-utils/log';
@@ -314,6 +314,15 @@ export const isValidFiber = (fiber: Fiber) => {
314314
return true;
315315
};
316316

317+
let flushInterval: ReturnType<typeof setInterval>;
318+
const startFlushOutlineInterval = (ctx: CanvasRenderingContext2D) => {
319+
clearInterval(flushInterval);
320+
setInterval(() => {
321+
requestAnimationFrame(() => {
322+
flushOutlines(ctx);
323+
});
324+
}, 30);
325+
};
317326
export const start = () => {
318327
if (typeof window === 'undefined') return;
319328

@@ -382,6 +391,7 @@ export const start = () => {
382391

383392
ctx = initReactScanOverlay();
384393
if (!ctx) return;
394+
startFlushOutlineInterval(ctx);
385395

386396
createInspectElementStateMachine(shadow);
387397

@@ -445,11 +455,16 @@ export const start = () => {
445455

446456
for (let i = 0, len = renders.length; i < len; i++) {
447457
const render = renders[i];
448-
const outline = getOutline(fiber, render);
449-
if (!outline) continue;
450-
ReactScanInternals.scheduledOutlines.push(outline);
458+
const domFiber = getNearestHostFiber(fiber);
459+
if (!domFiber || !domFiber.stateNode) continue;
460+
461+
ReactScanInternals.scheduledOutlines.push({
462+
domNode: domFiber.stateNode,
463+
renders,
464+
});
451465

452-
// audio context can take up an insane amount of cpu, todo: figure out why
466+
// - audio context can take up an insane amount of cpu, todo: figure out why
467+
// - we may want to take this out of hot path
453468
if (ReactScanInternals.options.value.playSound && audioContext) {
454469
const renderTimeThreshold = 10;
455470
const amplitude = Math.min(
@@ -460,7 +475,6 @@ export const start = () => {
460475
playGeigerClickSound(audioContext, amplitude);
461476
}
462477
}
463-
flushOutlines(ctx, new Map());
464478
},
465479
onCommitFinish() {
466480
ReactScanInternals.options.value.onCommitFinish?.();

packages/scan/src/core/instrumentation.ts

Lines changed: 58 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -92,68 +92,85 @@ export interface Render {
9292
count: number;
9393
forget: boolean;
9494
changes: Array<Change> | null;
95-
unnecessary: boolean;
95+
unnecessary: boolean | null;
9696
didCommit: boolean;
9797
fps: number;
9898
}
9999

100100
const unstableTypes = ['function', 'object'];
101101

102-
export const fastSerialize = (value: unknown, depth = 0) => {
102+
const cache = new WeakMap<object, string>();
103+
104+
export function fastSerialize(value: unknown, depth = 0): string {
103105
if (depth < 0) return '…';
106+
104107
switch (typeof value) {
105108
case 'function':
106109
return value.toString();
107110
case 'string':
108111
return value;
112+
case 'number':
113+
case 'boolean':
114+
case 'undefined':
115+
return String(value);
109116
case 'object':
110-
if (value === null) {
111-
return 'null';
112-
}
113-
if (Array.isArray(value)) {
114-
return value.length > 0 ? `[${value.length}]` : '[]';
115-
}
116-
if (isValidElement(value)) {
117-
// attempt to extract some name from the component
118-
return `<${getDisplayName(value.type) ?? ''} ${
119-
Object.keys(value.props || {}).length
120-
}>`;
121-
}
122-
if (
123-
typeof value === 'object' &&
124-
value !== null &&
125-
value.constructor === Object
126-
) {
127-
for (const key in value) {
128-
if (Object.prototype.hasOwnProperty.call(value, key)) {
129-
return `{${Object.keys(value).length}}`;
130-
}
131-
}
132-
return '{}';
133-
}
134-
// eslint-disable-next-line no-case-declarations
135-
const tagString = Object.prototype.toString.call(value).slice(8, -1);
136-
if (tagString === 'Object') {
137-
const proto = Object.getPrototypeOf(value);
138-
const constructor = proto?.constructor;
139-
if (typeof constructor === 'function') {
140-
return `${constructor.displayName || constructor.name || ''}{…}`;
141-
}
142-
}
143-
return `${tagString}{…}`;
117+
break;
144118
default:
145119
return String(value);
146120
}
147-
};
121+
122+
if (value === null) return 'null';
123+
124+
if (cache.has(value)) {
125+
return cache.get(value)!;
126+
}
127+
128+
if (Array.isArray(value)) {
129+
const str = value.length ? `[${value.length}]` : '[]';
130+
cache.set(value, str);
131+
return str;
132+
}
133+
134+
if (isValidElement(value)) {
135+
const type = getDisplayName(value.type) ?? '';
136+
const propCount = value.props ? Object.keys(value.props).length : 0;
137+
const str = `<${type} ${propCount}>`;
138+
cache.set(value, str);
139+
return str;
140+
}
141+
142+
if (Object.getPrototypeOf(value) === Object.prototype) {
143+
const keys = Object.keys(value);
144+
const str = keys.length ? `{${keys.length}}` : '{}';
145+
cache.set(value, str);
146+
return str;
147+
}
148+
149+
const ctor = (value as any).constructor;
150+
if (ctor && typeof ctor === 'function' && ctor.name) {
151+
const str = `${ctor.name}{…}`;
152+
cache.set(value, str);
153+
return str;
154+
}
155+
156+
const tagString = Object.prototype.toString.call(value).slice(8, -1);
157+
const str = `${tagString}{…}`;
158+
cache.set(value, str);
159+
return str;
160+
}
148161

149162
export const getPropsChanges = (fiber: Fiber) => {
150163
const changes: Array<Change> = [];
151164

152165
const prevProps = fiber.alternate?.memoizedProps || {};
153166
const nextProps = fiber.memoizedProps || {};
154167

155-
// eslint-disable-next-line prefer-object-spread
156-
for (const propName in Object.assign({}, prevProps, nextProps)) {
168+
169+
const allKeys = new Set([
170+
...Object.keys(prevProps),
171+
...Object.keys(nextProps),
172+
]);
173+
for (const propName in allKeys) {
157174
const prevValue = prevProps?.[propName];
158175
const nextValue = nextProps?.[propName];
159176

@@ -199,7 +216,6 @@ export const getStateChanges = (fiber: Fiber) => {
199216
return changes;
200217
};
201218

202-
203219
export const getContextChanges = (fiber: Fiber) => {
204220
const changes: Array<Change> = [];
205221

@@ -343,7 +359,8 @@ export const createInstrumentation = (
343359
changes,
344360
time: selfTime,
345361
forget: hasMemoCache(fiber),
346-
unnecessary: isRenderUnnecessary(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,
347364
didCommit: didFiberCommit(fiber),
348365
fps,
349366
};

packages/scan/src/core/web/inspect-element/utils.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import {
1010
isCompositeFiber,
1111
} from 'bippy';
1212
import { ReactScanInternals, Store } from '../../index';
13-
import { getRect } from '../utils/outline';
1413

1514
interface OverrideMethods {
16-
overrideProps: ((fiber: Fiber, path: Array<string>, value: any) => void) | null;
17-
overrideHookState: ((fiber: Fiber, id: string, path: Array<any>, value: any) => void) | null;
15+
overrideProps:
16+
| ((fiber: Fiber, path: Array<string>, value: any) => void)
17+
| null;
18+
overrideHookState:
19+
| ((fiber: Fiber, id: string, path: Array<any>, value: any) => void)
20+
| null;
1821
}
1922

2023
export const getFiberFromElement = (element: Element): Fiber | null => {
@@ -203,7 +206,7 @@ export const getCompositeComponentFromElement = (element: Element) => {
203206
: (associatedFiber.alternate ?? associatedFiber);
204207
const stateNode = getFirstStateNode(currentAssociatedFiber);
205208
if (!stateNode) return {};
206-
const targetRect = getRect(stateNode);
209+
const targetRect = stateNode.getBoundingClientRect(); // causes reflow, be careful
207210
if (!targetRect) return {};
208211
const anotherRes = getParentCompositeFiber(currentAssociatedFiber);
209212
if (!anotherRes) {

packages/scan/src/core/web/overlay.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { recalcOutlines } from './utils/outline';
1+
import { recalcOutlines } from '@web-utils/outline';
22

33
export const initReactScanOverlay = () => {
44
const container = document.getElementById('react-scan-root');

0 commit comments

Comments
 (0)