Skip to content

Commit 60a0019

Browse files
committed
feat(react-scan): Update options, add context updates, improve performance
1 parent 73dfecf commit 60a0019

File tree

10 files changed

+146
-64
lines changed

10 files changed

+146
-64
lines changed

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,19 +95,19 @@ We expect all contributors to abide by the terms of our [Code of Conduct](https:
9595
- [x] Scan API (`withScan`, `scan`)
9696
- [ ] Don't show label if no reconciliation occurred ("client renders" in DevTools)
9797
- [ ] Investigate `__REACT_DEVTOOLS_TARGET_WINDOW__`
98-
- [ ] Chrome extension
98+
- [x] Chrome extension (h/t [@biw](https://github.com/biw))
9999
- [ ] "PageSpeed insights" for React
100-
- [ ] Cleanup config options
100+
- [x] Cleanup config options
101101
- [ ] Offscreen canvas on worker thread
102102
- [ ] React Native support
103103
- [ ] "global" counter using `sessionStorage`, aggregate count stats instead of immediate replacement
104104
- [ ] Name / explain the actual problem
105-
- [ ] More explicit options override API (start log at certain area, stop log, etc.)
106-
- [ ] Expose primitives / internals for advanced use cases
107-
- [ ] Add more problem detections other than props
105+
- [x] More explicit options override API (start log at certain area, stop log, etc.)
106+
- [x] Expose primitives / internals for advanced use cases
107+
- [x] Add context updates
108108
- [ ] Simple FPS counter
109109
- [ ] Drag and select areas of the screen to scan
110-
- [ ] Mode to only show on main thread blocking
110+
- [x] Mode to show on main thread blocking
111111
- [ ] Add a funny mascot, like the ["Stop I'm Changing" dude](https://www.youtube.com/shorts/FwOZdX7bDKI?app=desktop)
112112

113113
## Acknowledgments

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-scan",
3-
"version": "0.0.7",
3+
"version": "0.0.8",
44
"description": "Scan your React app for renders",
55
"keywords": [
66
"react",

src/core/index.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ import {
77
getOutline,
88
type PendingOutline,
99
} from './web/outline';
10-
import { createCanvas } from './web/index';
10+
import { createOverlay } from './web/index';
1111
import { logIntro } from './web/log';
1212
import { createToolbar } from './web/toolbar';
1313
import { playGeigerClickSound } from './web/geiger';
14+
import { createPerfObserver } from './web/perf-observer';
1415

1516
interface Options {
1617
/**
@@ -54,6 +55,14 @@ interface Options {
5455
*/
5556
showToolbar?: boolean;
5657

58+
/**
59+
* Long task threshold in milliseconds, only show
60+
* when main thread is blocked for longer than this
61+
*
62+
* @default 50
63+
*/
64+
longTaskThreshold?: number;
65+
5766
onCommitStart?: () => void;
5867
onRender?: (fiber: Fiber, render: Render) => void;
5968
onCommitFinish?: () => void;
@@ -89,9 +98,10 @@ export const ReactScanInternals: Internals = {
8998
enabled: true,
9099
includeChildren: true,
91100
runInProduction: false,
92-
log: false,
93101
playSound: false,
102+
log: false,
94103
showToolbar: true,
104+
longTaskThreshold: 50,
95105
},
96106
scheduledOutlines: [],
97107
activeOutlines: [],
@@ -112,8 +122,9 @@ export const start = () => {
112122
if (inited) return;
113123
inited = true;
114124
const { options } = ReactScanInternals;
115-
const ctx = createCanvas();
125+
const ctx = createOverlay();
116126
const toolbar = options.showToolbar ? createToolbar() : null;
127+
const perfObserver = createPerfObserver();
117128
const audioContext =
118129
typeof window !== 'undefined'
119130
? new (window.AudioContext ||
@@ -149,7 +160,7 @@ export const start = () => {
149160
}
150161

151162
requestAnimationFrame(() => {
152-
flushOutlines(ctx, new Map(), toolbar);
163+
flushOutlines(ctx, new Map(), toolbar, perfObserver);
153164
});
154165
},
155166
onCommitFinish() {

src/core/instrumentation/fiber.ts

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,23 +50,80 @@ export const registerDevtoolsHook = ({
5050
return devtoolsHook;
5151
};
5252

53+
export const traverseContexts = (
54+
fiber: Fiber,
55+
selector: (
56+
prevValue: { context: React.Context<unknown>; memoizedValue: unknown },
57+
nextValue: { context: React.Context<unknown>; memoizedValue: unknown },
58+
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
59+
) => boolean | void,
60+
) => {
61+
const nextDependencies = fiber.dependencies;
62+
const prevDependencies = fiber.alternate?.dependencies;
63+
64+
if (!nextDependencies || !prevDependencies) return false;
65+
if (
66+
typeof nextDependencies !== 'object' ||
67+
!('firstContext' in nextDependencies) ||
68+
typeof prevDependencies !== 'object' ||
69+
!('firstContext' in prevDependencies)
70+
) {
71+
return false;
72+
}
73+
let nextContext = nextDependencies.firstContext;
74+
let prevContext = prevDependencies.firstContext;
75+
while (
76+
nextContext &&
77+
typeof nextContext === 'object' &&
78+
'memoizedValue' in nextContext &&
79+
prevContext &&
80+
typeof prevContext === 'object' &&
81+
'memoizedValue' in prevContext
82+
) {
83+
if (selector(nextContext as any, prevContext as any) === true) return true;
84+
85+
nextContext = nextContext.next;
86+
prevContext = prevContext.next;
87+
}
88+
return true;
89+
};
90+
5391
export const isHostComponent = (fiber: Fiber) =>
5492
fiber.tag === HostComponentTag ||
5593
// @ts-expect-error: it exists
5694
fiber.tag === HostHoistableTag ||
5795
// @ts-expect-error: it exists
5896
fiber.tag === HostSingletonTag;
5997

60-
const seenProps = new WeakMap<any, boolean>();
98+
const seenProps = new WeakSet<any>();
99+
const seenContextValues = new WeakMap<
100+
Fiber,
101+
WeakMap<React.Context<unknown>, unknown>
102+
>();
61103

62104
export const didFiberRender = (fiber: Fiber): boolean => {
63105
let nextProps = fiber.memoizedProps;
64-
if (!nextProps) return true;
65-
if (seenProps.has(nextProps)) return false;
106+
const hasSeenProps = seenProps.has(nextProps);
107+
66108
if (nextProps && typeof nextProps === 'object') {
67-
seenProps.set(nextProps, true);
109+
seenProps.add(nextProps);
68110
}
69111

112+
let isContextChanged = false;
113+
traverseContexts(fiber, (_prevContext, nextContext) => {
114+
const contextMap = seenContextValues.get(fiber) ?? new WeakMap();
115+
const seenContextValue = contextMap.get(nextContext.context);
116+
if (
117+
!contextMap.has(nextContext.context) ||
118+
!Object.is(seenContextValue, nextContext.memoizedValue)
119+
) {
120+
isContextChanged = true;
121+
}
122+
contextMap.set(nextContext.context, nextContext.memoizedValue);
123+
seenContextValues.set(fiber, contextMap);
124+
});
125+
if (!isContextChanged && hasSeenProps) return false;
126+
70127
nextProps ??= {};
71128

72129
const prevProps = fiber.alternate?.memoizedProps || {};

src/core/instrumentation/index.ts

Lines changed: 20 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getSelfTime,
99
hasMemoCache,
1010
registerDevtoolsHook,
11+
traverseContexts,
1112
traverseFiber,
1213
} from './fiber';
1314

@@ -48,10 +49,8 @@ export interface Render {
4849

4950
const unstableTypes = ['function', 'object'];
5051

51-
export const getPropsRender = (fiber: Fiber): Render | null => {
52-
const type = getType(fiber.type);
53-
if (!type) return null;
54-
52+
// eslint-disable-next-line @typescript-eslint/ban-types
53+
export const getPropsRender = (fiber: Fiber, type: Function): Render | null => {
5554
const changes: Change[] = [];
5655

5756
const prevProps = fiber.alternate?.memoizedProps;
@@ -102,39 +101,19 @@ export const getPropsRender = (fiber: Fiber): Render | null => {
102101
};
103102
};
104103

105-
export const getContextRender = (fiber: Fiber): Render | null => {
106-
const type = getType(fiber.type);
107-
if (!type) return null;
108-
109-
const nextDependencies = fiber.dependencies;
110-
const prevDependencies = fiber.alternate?.dependencies;
111-
104+
export const getContextRender = (
105+
fiber: Fiber,
106+
// eslint-disable-next-line @typescript-eslint/ban-types
107+
type: Function,
108+
): Render | null => {
112109
const changes: Change[] = [];
113110

114-
if (!nextDependencies || !prevDependencies) return null;
115-
if (
116-
typeof nextDependencies !== 'object' ||
117-
!('firstContext' in nextDependencies) ||
118-
typeof prevDependencies !== 'object' ||
119-
!('firstContext' in prevDependencies)
120-
) {
121-
return null;
122-
}
123-
let nextContext = nextDependencies.firstContext;
124-
let prevContext = prevDependencies.firstContext;
125-
while (
126-
nextContext &&
127-
typeof nextContext === 'object' &&
128-
'memoizedValue' in nextContext &&
129-
prevContext &&
130-
typeof prevContext === 'object' &&
131-
'memoizedValue' in prevContext
132-
) {
133-
const nextValue = nextContext.memoizedValue;
111+
const result = traverseContexts(fiber, (prevContext, nextContext) => {
134112
const prevValue = prevContext.memoizedValue;
113+
const nextValue = nextContext.memoizedValue;
135114

136115
const change: Change = {
137-
name: '$$context',
116+
name: '',
138117
prevValue,
139118
nextValue,
140119
unstable: false,
@@ -151,10 +130,9 @@ export const getContextRender = (fiber: Fiber): Render | null => {
151130
) {
152131
change.unstable = true;
153132
}
133+
});
154134

155-
nextContext = nextContext.next;
156-
prevContext = prevContext.next;
157-
}
135+
if (!result) return null;
158136

159137
return {
160138
type: 'context',
@@ -181,9 +159,11 @@ export const instrument = ({
181159
onCommitStart();
182160

183161
const handleFiber = (fiber: Fiber, trigger: boolean) => {
184-
if (!fiber || !didFiberRender(fiber)) return null;
185-
const propsRender = getPropsRender(fiber);
186-
const contextRender = getContextRender(fiber);
162+
const type = getType(fiber.type);
163+
if (!type) return null;
164+
if (!didFiberRender(fiber)) return null;
165+
const propsRender = getPropsRender(fiber, type);
166+
const contextRender = getContextRender(fiber, type);
187167
if (!propsRender && !contextRender) return null;
188168

189169
const allowList = ReactScanInternals.componentAllowList;
@@ -233,7 +213,8 @@ export const instrument = ({
233213
try {
234214
handleCommitFiberRoot(rendererID, root);
235215
} catch (err) {
236-
/**/
216+
// eslint-disable-next-line no-console
217+
console.error('[React Scan] Error instrumenting: ', err);
237218
}
238219
};
239220

src/core/web/index.ts

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

4-
export const createCanvas = () => {
4+
export const createOverlay = () => {
55
const canvas = createElement(
66
`<canvas id="react-scan-overlay" style="position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483646" aria-hidden="true"/>`,
77
) as HTMLCanvasElement;

src/core/web/outline.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ReactScanInternals } from '../index';
55
import { getLabelText } from '../utils';
66
import { isOutlineUnstable, throttle } from './utils';
77
import { log } from './log';
8+
import { recalcOutlineColor } from './perf-observer';
89

910
export interface PendingOutline {
1011
rect: DOMRect;
@@ -29,7 +30,7 @@ export interface PaintedOutline {
2930

3031
export const MONO_FONT =
3132
'Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace';
32-
export const PURPLE_RGB = '115,97,230';
33+
export const colorRef = { current: '115,97,230' };
3334

3435
export const getOutlineKey = (outline: PendingOutline): string => {
3536
return `${outline.rect.top}-${outline.rect.left}-${outline.rect.width}-${outline.rect.height}`;
@@ -45,6 +46,8 @@ export const getRect = (domNode: HTMLElement): DOMRect | null => {
4546
return null;
4647
}
4748

49+
// if (!document.documentElement.contains(domNode)) return null;
50+
4851
const rect = domNode.getBoundingClientRect();
4952
const isVisible =
5053
rect.top >= 0 ||
@@ -135,6 +138,7 @@ export const flushOutlines = (
135138
ctx: CanvasRenderingContext2D,
136139
previousOutlines: Map<string, PendingOutline> = new Map(),
137140
toolbar: HTMLElement | null = null,
141+
perfObserver: PerformanceObserver | null = null,
138142
) => {
139143
if (!ReactScanInternals.scheduledOutlines.length) {
140144
return;
@@ -144,6 +148,10 @@ export const flushOutlines = (
144148
ReactScanInternals.scheduledOutlines = [];
145149

146150
requestAnimationFrame(() => {
151+
if (perfObserver) {
152+
recalcOutlineColor(perfObserver.takeRecords());
153+
}
154+
recalcOutlines();
147155
void (async () => {
148156
const secondOutlines = ReactScanInternals.scheduledOutlines;
149157
ReactScanInternals.scheduledOutlines = [];
@@ -182,7 +190,7 @@ export const flushOutlines = (
182190
}),
183191
);
184192
if (ReactScanInternals.scheduledOutlines.length) {
185-
flushOutlines(ctx, newPreviousOutlines, toolbar);
193+
flushOutlines(ctx, newPreviousOutlines, toolbar, perfObserver);
186194
}
187195
})();
188196
});
@@ -272,9 +280,9 @@ export const fadeOutOutline = (ctx: CanvasRenderingContext2D) => {
272280

273281
ctx.save();
274282

275-
ctx.strokeStyle = `rgba(${PURPLE_RGB}, ${maxStrokeAlpha})`;
283+
ctx.strokeStyle = `rgba(${colorRef.current}, ${maxStrokeAlpha})`;
276284
ctx.lineWidth = 1;
277-
ctx.fillStyle = `rgba(${PURPLE_RGB}, ${maxFillAlpha})`;
285+
ctx.fillStyle = `rgba(${colorRef.current}, ${maxFillAlpha})`;
278286

279287
ctx.stroke(combinedPath);
280288
ctx.fill(combinedPath);
@@ -295,7 +303,7 @@ export const fadeOutOutline = (ctx: CanvasRenderingContext2D) => {
295303
const labelX: number = rect.x;
296304
const labelY: number = rect.y - textHeight - 4;
297305

298-
ctx.fillStyle = `rgba(${PURPLE_RGB},${alpha})`;
306+
ctx.fillStyle = `rgba(${colorRef.current},${alpha})`;
299307
ctx.fillRect(labelX, labelY, textWidth + 4, textHeight + 4);
300308

301309
ctx.fillStyle = `rgba(255,255,255,${alpha})`;

src/core/web/perf-observer.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ReactScanInternals } from '../../index';
2+
import { NO_OP } from '../utils';
3+
import { colorRef } from './outline';
4+
5+
export const createPerfObserver = () => {
6+
const observer = new PerformanceObserver(NO_OP);
7+
8+
observer.observe({ entryTypes: ['longtask'] });
9+
10+
return observer;
11+
};
12+
13+
export const recalcOutlineColor = (entries: PerformanceEntryList) => {
14+
const { longTaskThreshold } = ReactScanInternals.options;
15+
for (let i = 0, len = entries.length; i < len; i++) {
16+
const entry = entries[i];
17+
// 64ms = 4 frames
18+
// If longTaskThreshold is set, we show all "short" tasks, otherwise we hide them
19+
if (entry.duration < (longTaskThreshold ?? 50)) continue;
20+
colorRef.current = '185,49,115';
21+
return;
22+
}
23+
colorRef.current = '115,97,230';
24+
};

0 commit comments

Comments
 (0)