Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 117 additions & 77 deletions src/fiber.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Fiber, FiberRoot } from 'react-reconciler';
import { NO_OP } from './utils';
import * as React from 'react';
import { didChange, NO_OP } from './utils';
import type { Renderer } from './types';

const PerformedWorkFlag = 0b01;
Expand All @@ -10,6 +11,11 @@ const ForwardRefTag = 11;
const MemoComponentTag = 14;
const SimpleMemoComponentTag = 15;

const ReactSharedInternals =
(React as any)
?.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE ||
(React as any)?.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;

export const didFiberRender = (fiber: Fiber | null): boolean => {
if (!fiber) return true; // mount (probably)
const prevProps = fiber.alternate?.memoizedProps || {};
Expand Down Expand Up @@ -129,83 +135,117 @@ export const registerDevtoolsHook = ({
onCommitFiberRoot(rendererID, root);
};

// const renderersArray = Array.from(devtoolsHook.renderers.values());
// for (let i = 0, len = renderersArray.length; i < len; i++) {
// const renderer = renderersArray[i];
// controlDispatcherRef(renderer.currentDispatcherRef);
// }
if (ReactSharedInternals) {
controlDispatcherRef(ReactSharedInternals);
}

return devtoolsHook;
};

// TODO: check useMemo / useCallback / useMemoCache (React Compiler)

// const REACT_MAJOR_VERSION = Number(React.version.split('.')[0]);
// const dispatcherRefs = new Set();

// export const controlDispatcherRef = (currentDispatcherRef: any) => {
// const ref = currentDispatcherRef;
// if (ref && !dispatcherRefs.has(ref)) {
// // Renamed to ".H" in React 19
// const propName = REACT_MAJOR_VERSION > 18 ? 'H' : 'current';
// let currentDispatcher = ref[propName];
// const seenDispatchers = new Set();

// Object.defineProperty(ref, propName, {
// get: () => currentDispatcher,
// set(current: any) {
// currentDispatcher = current;

// if (
// !current ||
// seenDispatchers.has(current) ||
// current.useRef === current.useImperativeHandle ||
// /warnInvalidContextAccess\(\)/.test(current.readContext.toString())
// ) {
// return;
// }
// seenDispatchers.add(current);
// const isInComponent = peekIsInComponent(current);
// if (!isInComponent) return;
// const prevUseCallback = current.useCallback;
// const useCallback = (fn: (...args: any[]) => any, deps: any[]) => {
// return prevUseCallback(fn, deps);
// };
// current.useCallback = useCallback;

// const prevUseMemo = current.useMemo;
// const useMemo = (fn: (...args: any[]) => any, deps: any[]) => {
// return prevUseMemo(fn, deps);
// };
// current.useMemo = useMemo;
// },
// });
// dispatcherRefs.add(ref);
// }
// };

// const invalidHookErrFunctions = new WeakMap<() => void, boolean>();

// /**
// * Check if you can currently run hooks in a component. This avoids allocting
// * a new hook on the stack by "peeking." Note that this doesn't correctly handle some cases
// * For example, if you are iterating through Array.map, it won't check if you allocate more/less hooks between renders
// *
// * This function checks the current dispatcher, which is swapped with an invalid / valid state by React. If
// * the current dispatcher is invalid (includes the string ("Error")), it will return false.
// */
// export const peekIsInComponent = (
// dispatcher: Record<string, () => void>,
// ): boolean => {
// const hook = dispatcher.useRef;

// if (typeof hook !== 'function' || invalidHookErrFunctions.has(hook)) {
// return false;
// }
// const str = hook.toString();
// if (str.includes('Error')) {
// invalidHookErrFunctions.set(hook, true);
// return false;
// }
// return true;
// };
const REACT_MAJOR_VERSION = Number(React.version.split('.')[0]);
const dispatcherRefs = new Set();

export const getFiberDEV = (): Fiber | null => {
return (
ReactSharedInternals?.A?.getOwner() ??
ReactSharedInternals?.ReactCurrentOwner?.current
);
};

export const controlDispatcherRef = (currentDispatcherRef: any) => {
const ref = currentDispatcherRef;
if (ref && !dispatcherRefs.has(ref)) {
// Renamed to ".H" in React 19
const propName = REACT_MAJOR_VERSION > 18 ? 'H' : 'current';
let currentDispatcher = ref[propName];
const seenDispatchers = new Set();

const callbackCache = new Map<string, any[]>();
const memoCache = new Map<string, any[]>();

Object.defineProperty(ref, propName, {
get: () => currentDispatcher,
set(current: any) {
currentDispatcher = current;

if (
!current ||
seenDispatchers.has(current) ||
current.useRef === current.useImperativeHandle ||
/warnInvalidContextAccess\(\)/.test(current.readContext.toString())
) {
return;
}
seenDispatchers.add(current);
const isInComponent = peekIsInComponent(current);
if (!isInComponent) return;
const prevUseCallback = current.useCallback;
const useCallback = (fn: (...args: any[]) => any, deps: any[]) => {
try {
const key = fn.toString();
const prevDeps = callbackCache.get(key);
if (prevDeps && prevDeps.length === deps.length) {
for (let i = 0; i < prevDeps.length; i++) {
const changed = didChange(prevDeps[i], deps[i]);
if (!changed) break;
// do something
}
}
callbackCache.set(key, deps);
} catch (_err) {
/**/
}
return prevUseCallback(fn, deps);
};
current.useCallback = useCallback;

const prevUseMemo = current.useMemo;
const useMemo = (fn: (...args: any[]) => any, deps: any[]) => {
try {
const key = fn.toString();
const prevDeps = callbackCache.get(key);
if (prevDeps && prevDeps.length === deps.length) {
for (let i = 0; i < prevDeps.length; i++) {
const changed = didChange(prevDeps[i], deps[i]);
if (!changed) break;
// do something
}
}
memoCache.set(key, deps);
} catch (_err) {
/**/
}
return prevUseMemo(fn, deps);
};
current.useMemo = useMemo;
},
});
dispatcherRefs.add(ref);
}
};

const invalidHookErrFunctions = new WeakMap<() => void, boolean>();

/**
* Check if you can currently run hooks in a component. This avoids allocting
* a new hook on the stack by "peeking." Note that this doesn't correctly handle some cases
* For example, if you are iterating through Array.map, it won't check if you allocate more/less hooks between renders
*
* This function checks the current dispatcher, which is swapped with an invalid / valid state by React. If
* the current dispatcher is invalid (includes the string ("Error")), it will return false.
*/
export const peekIsInComponent = (
dispatcher: Record<string, () => void>,
): boolean => {
const hook = dispatcher.useRef;

if (typeof hook !== 'function' || invalidHookErrFunctions.has(hook)) {
return false;
}
const str = hook.toString();
if (str.includes('Error')) {
invalidHookErrFunctions.set(hook, true);
return false;
}
return true;
};
33 changes: 10 additions & 23 deletions src/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,8 @@ import {
getType,
traverseFiber,
} from './fiber';
import type {
OutlineLabel,
Outline,
ChangedProp,
OutlinePaintTask,
} from './types';
import { onIdle, fastSerialize } from './utils';
import type { OutlineLabel, Outline, Change, OutlinePaintTask } from './types';
import { onIdle, didChange } from './utils';
import { getCurrentOptions } from './auto';
import { MONO_FONT, PURPLE_RGB } from './constants';

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

const changedProps: ChangedProp[] = [];
const unstableTypes = ['function', 'object'];
const changedProps: Change[] = [];
let unstable = false;

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

const changed = didChange(prevValue, nextValue);

if (
Object.is(prevValue, nextValue) ||
!changed ||
React.isValidElement(prevValue) ||
React.isValidElement(nextValue) ||
propName === 'children'
) {
continue;
}
const changedProp: ChangedProp = {
const changedProp: Change = {
name: propName,
prevValue,
nextValue,
unstable: false,
unstable: changed === 'unstable',
};
changedProps.push(changedProp);

const prevValueString = fastSerialize(prevValue);
const nextValueString = fastSerialize(nextValue);

if (
!unstableTypes.includes(typeof prevValue) ||
!unstableTypes.includes(typeof nextValue) ||
prevValueString !== nextValueString
) {
continue;
}
if (changed !== 'unstable') continue;

unstable = true;
changedProp.unstable = true;
Expand Down Expand Up @@ -413,7 +400,7 @@ export const createFullscreenCanvas = () => {

export const createStatus = () => {
const status = createElement(
`<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>`,
`<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>`,
) as HTMLDivElement;

let isHidden = localStorage.getItem('react-scan-hidden') === 'true';
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export interface OutlineLabel {
text: string | null;
}

export interface ChangedProp {
export interface Change {
name: string;
prevValue: any;
nextValue: any;
Expand Down
19 changes: 19 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,22 @@ export const isProd = () => {
export const NO_OP = () => {
/**/
};

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

export const didChange = (prevValue: any, nextValue: any) => {
if (Object.is(prevValue, nextValue)) return false;

const prevValueString = fastSerialize(prevValue);
const nextValueString = fastSerialize(nextValue);

if (
!unstableTypes.includes(typeof prevValue) ||
!unstableTypes.includes(typeof nextValue) ||
prevValueString !== nextValueString
) {
return true;
}

return 'unstable';
};