Skip to content

Commit ecb4041

Browse files
committed
refactor(fiber.ts, overlay.ts, types.ts, utils.ts): Enhance change detection in components
1 parent ecc1693 commit ecb4041

File tree

4 files changed

+140
-101
lines changed

4 files changed

+140
-101
lines changed

src/fiber.ts

Lines changed: 110 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Fiber, FiberRoot } from 'react-reconciler';
2-
import { NO_OP } from './utils';
2+
import * as React from 'react';
3+
import { didChange, NO_OP } from './utils';
34
import type { Renderer } from './types';
45

56
const PerformedWorkFlag = 0b01;
@@ -10,6 +11,11 @@ const ForwardRefTag = 11;
1011
const MemoComponentTag = 14;
1112
const SimpleMemoComponentTag = 15;
1213

14+
const ReactSharedInternals =
15+
(React as any)
16+
?.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE ||
17+
(React as any)?.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
18+
1319
export const didFiberRender = (fiber: Fiber | null): boolean => {
1420
if (!fiber) return true; // mount (probably)
1521
const prevProps = fiber.alternate?.memoizedProps || {};
@@ -129,83 +135,110 @@ export const registerDevtoolsHook = ({
129135
onCommitFiberRoot(rendererID, root);
130136
};
131137

132-
// const renderersArray = Array.from(devtoolsHook.renderers.values());
133-
// for (let i = 0, len = renderersArray.length; i < len; i++) {
134-
// const renderer = renderersArray[i];
135-
// controlDispatcherRef(renderer.currentDispatcherRef);
136-
// }
138+
if (ReactSharedInternals) {
139+
controlDispatcherRef(ReactSharedInternals);
140+
}
137141

138142
return devtoolsHook;
139143
};
140144

141-
// TODO: check useMemo / useCallback / useMemoCache (React Compiler)
142-
143-
// const REACT_MAJOR_VERSION = Number(React.version.split('.')[0]);
144-
// const dispatcherRefs = new Set();
145-
146-
// export const controlDispatcherRef = (currentDispatcherRef: any) => {
147-
// const ref = currentDispatcherRef;
148-
// if (ref && !dispatcherRefs.has(ref)) {
149-
// // Renamed to ".H" in React 19
150-
// const propName = REACT_MAJOR_VERSION > 18 ? 'H' : 'current';
151-
// let currentDispatcher = ref[propName];
152-
// const seenDispatchers = new Set();
153-
154-
// Object.defineProperty(ref, propName, {
155-
// get: () => currentDispatcher,
156-
// set(current: any) {
157-
// currentDispatcher = current;
158-
159-
// if (
160-
// !current ||
161-
// seenDispatchers.has(current) ||
162-
// current.useRef === current.useImperativeHandle ||
163-
// /warnInvalidContextAccess\(\)/.test(current.readContext.toString())
164-
// ) {
165-
// return;
166-
// }
167-
// seenDispatchers.add(current);
168-
// const isInComponent = peekIsInComponent(current);
169-
// if (!isInComponent) return;
170-
// const prevUseCallback = current.useCallback;
171-
// const useCallback = (fn: (...args: any[]) => any, deps: any[]) => {
172-
// return prevUseCallback(fn, deps);
173-
// };
174-
// current.useCallback = useCallback;
175-
176-
// const prevUseMemo = current.useMemo;
177-
// const useMemo = (fn: (...args: any[]) => any, deps: any[]) => {
178-
// return prevUseMemo(fn, deps);
179-
// };
180-
// current.useMemo = useMemo;
181-
// },
182-
// });
183-
// dispatcherRefs.add(ref);
184-
// }
185-
// };
186-
187-
// const invalidHookErrFunctions = new WeakMap<() => void, boolean>();
188-
189-
// /**
190-
// * Check if you can currently run hooks in a component. This avoids allocting
191-
// * a new hook on the stack by "peeking." Note that this doesn't correctly handle some cases
192-
// * For example, if you are iterating through Array.map, it won't check if you allocate more/less hooks between renders
193-
// *
194-
// * This function checks the current dispatcher, which is swapped with an invalid / valid state by React. If
195-
// * the current dispatcher is invalid (includes the string ("Error")), it will return false.
196-
// */
197-
// export const peekIsInComponent = (
198-
// dispatcher: Record<string, () => void>,
199-
// ): boolean => {
200-
// const hook = dispatcher.useRef;
201-
202-
// if (typeof hook !== 'function' || invalidHookErrFunctions.has(hook)) {
203-
// return false;
204-
// }
205-
// const str = hook.toString();
206-
// if (str.includes('Error')) {
207-
// invalidHookErrFunctions.set(hook, true);
208-
// return false;
209-
// }
210-
// return true;
211-
// };
145+
const REACT_MAJOR_VERSION = Number(React.version.split('.')[0]);
146+
const dispatcherRefs = new Set();
147+
148+
export const controlDispatcherRef = (currentDispatcherRef: any) => {
149+
const ref = currentDispatcherRef;
150+
if (ref && !dispatcherRefs.has(ref)) {
151+
// Renamed to ".H" in React 19
152+
const propName = REACT_MAJOR_VERSION > 18 ? 'H' : 'current';
153+
let currentDispatcher = ref[propName];
154+
const seenDispatchers = new Set();
155+
156+
const callbackCache = new Map<string, any[]>();
157+
const memoCache = new Map<string, any[]>();
158+
159+
Object.defineProperty(ref, propName, {
160+
get: () => currentDispatcher,
161+
set(current: any) {
162+
currentDispatcher = current;
163+
164+
if (
165+
!current ||
166+
seenDispatchers.has(current) ||
167+
current.useRef === current.useImperativeHandle ||
168+
/warnInvalidContextAccess\(\)/.test(current.readContext.toString())
169+
) {
170+
return;
171+
}
172+
seenDispatchers.add(current);
173+
const isInComponent = peekIsInComponent(current);
174+
if (!isInComponent) return;
175+
const prevUseCallback = current.useCallback;
176+
const useCallback = (fn: (...args: any[]) => any, deps: any[]) => {
177+
try {
178+
const key = fn.toString();
179+
const prevDeps = callbackCache.get(key);
180+
if (prevDeps && prevDeps.length === deps.length) {
181+
for (let i = 0; i < prevDeps.length; i++) {
182+
const changed = didChange(prevDeps[i], deps[i]);
183+
if (!changed) break;
184+
// do something
185+
}
186+
}
187+
callbackCache.set(key, deps);
188+
} catch (_err) {
189+
/**/
190+
}
191+
return prevUseCallback(fn, deps);
192+
};
193+
current.useCallback = useCallback;
194+
195+
const prevUseMemo = current.useMemo;
196+
const useMemo = (fn: (...args: any[]) => any, deps: any[]) => {
197+
try {
198+
const key = fn.toString();
199+
const prevDeps = callbackCache.get(key);
200+
if (prevDeps && prevDeps.length === deps.length) {
201+
for (let i = 0; i < prevDeps.length; i++) {
202+
const changed = didChange(prevDeps[i], deps[i]);
203+
if (!changed) break;
204+
// do something
205+
}
206+
}
207+
memoCache.set(key, deps);
208+
} catch (_err) {
209+
/**/
210+
}
211+
return prevUseMemo(fn, deps);
212+
};
213+
current.useMemo = useMemo;
214+
},
215+
});
216+
dispatcherRefs.add(ref);
217+
}
218+
};
219+
220+
const invalidHookErrFunctions = new WeakMap<() => void, boolean>();
221+
222+
/**
223+
* Check if you can currently run hooks in a component. This avoids allocting
224+
* a new hook on the stack by "peeking." Note that this doesn't correctly handle some cases
225+
* For example, if you are iterating through Array.map, it won't check if you allocate more/less hooks between renders
226+
*
227+
* This function checks the current dispatcher, which is swapped with an invalid / valid state by React. If
228+
* the current dispatcher is invalid (includes the string ("Error")), it will return false.
229+
*/
230+
export const peekIsInComponent = (
231+
dispatcher: Record<string, () => void>,
232+
): boolean => {
233+
const hook = dispatcher.useRef;
234+
235+
if (typeof hook !== 'function' || invalidHookErrFunctions.has(hook)) {
236+
return false;
237+
}
238+
const str = hook.toString();
239+
if (str.includes('Error')) {
240+
invalidHookErrFunctions.set(hook, true);
241+
return false;
242+
}
243+
return true;
244+
};

src/overlay.ts

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,8 @@ import {
77
getType,
88
traverseFiber,
99
} from './fiber';
10-
import type {
11-
OutlineLabel,
12-
Outline,
13-
ChangedProp,
14-
OutlinePaintTask,
15-
} from './types';
16-
import { onIdle, fastSerialize } from './utils';
10+
import type { OutlineLabel, Outline, Change, OutlinePaintTask } from './types';
11+
import { onIdle, didChange } from './utils';
1712
import { getCurrentOptions } from './auto';
1813
import { MONO_FONT, PURPLE_RGB } from './constants';
1914

@@ -58,8 +53,7 @@ export const getOutline = (fiber: Fiber | null): Outline | null => {
5853
const type = getType(fiber.type);
5954
if (!type) return null;
6055

61-
const changedProps: ChangedProp[] = [];
62-
const unstableTypes = ['function', 'object'];
56+
const changedProps: Change[] = [];
6357
let unstable = false;
6458

6559
const prevProps = fiber.alternate?.memoizedProps;
@@ -69,32 +63,25 @@ export const getOutline = (fiber: Fiber | null): Outline | null => {
6963
const prevValue = prevProps?.[propName];
7064
const nextValue = nextProps?.[propName];
7165

66+
const changed = didChange(prevValue, nextValue);
67+
7268
if (
73-
Object.is(prevValue, nextValue) ||
69+
!changed ||
7470
React.isValidElement(prevValue) ||
7571
React.isValidElement(nextValue) ||
7672
propName === 'children'
7773
) {
7874
continue;
7975
}
80-
const changedProp: ChangedProp = {
76+
const changedProp: Change = {
8177
name: propName,
8278
prevValue,
8379
nextValue,
84-
unstable: false,
80+
unstable: changed === 'unstable',
8581
};
8682
changedProps.push(changedProp);
8783

88-
const prevValueString = fastSerialize(prevValue);
89-
const nextValueString = fastSerialize(nextValue);
90-
91-
if (
92-
!unstableTypes.includes(typeof prevValue) ||
93-
!unstableTypes.includes(typeof nextValue) ||
94-
prevValueString !== nextValueString
95-
) {
96-
continue;
97-
}
84+
if (changed !== 'unstable') continue;
9885

9986
unstable = true;
10087
changedProp.unstable = true;
@@ -413,7 +400,7 @@ export const createFullscreenCanvas = () => {
413400

414401
export const createStatus = () => {
415402
const status = createElement(
416-
`<div id="react-scan-status" title="Number of unnecessary renders and time elapsed" style="position:fixed;bottom:3px;right:3px;background:rgba(0,0,0,0.5);padding:4px 8px;border-radius:4px;color:white;z-index:2147483647;font-family:${MONO_FONT}" aria-hidden="true">hide scanner</div>`,
403+
`<div id="react-scan-status" title="Number of unnecessary renders and time elapsed" style="position:fixed;bottom:3px;right:3px;background:rgba(0,0,0,0.5);padding:4px 8px;border-radius:4px;color:white;z-index:2147483647;font-family:${MONO_FONT}" aria-hidden="true">stop ⏹</div>`,
417404
) as HTMLDivElement;
418405

419406
let isHidden = localStorage.getItem('react-scan-hidden') === 'true';

src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export interface OutlineLabel {
4646
text: string | null;
4747
}
4848

49-
export interface ChangedProp {
49+
export interface Change {
5050
name: string;
5151
prevValue: any;
5252
nextValue: any;

src/utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,22 @@ export const isProd = () => {
8080
export const NO_OP = () => {
8181
/**/
8282
};
83+
84+
const unstableTypes = ['function', 'object'];
85+
86+
export const didChange = (prevValue: any, nextValue: any) => {
87+
if (Object.is(prevValue, nextValue)) return false;
88+
89+
const prevValueString = fastSerialize(prevValue);
90+
const nextValueString = fastSerialize(nextValue);
91+
92+
if (
93+
!unstableTypes.includes(typeof prevValue) ||
94+
!unstableTypes.includes(typeof nextValue) ||
95+
prevValueString !== nextValueString
96+
) {
97+
return true;
98+
}
99+
100+
return 'unstable';
101+
};

0 commit comments

Comments
 (0)