[react-devtools-facade] 5/ support an already-installed DevTools hook…
[react-devtools-facade] 5/ support an already-installed DevTools hook (#36682) Makes `installFacade` usable on pages that already have a DevTools backend - most importantly the **React DevTools browser extension** - by attaching to the existing `__REACT_DEVTOOLS_GLOBAL_HOOK__` instead of refusing to install. This is important, because otherwise if the page had Facade installed, the user won't be able to use React DevTools browser extension.
Summary
Makes installFacade usable on pages that already have a DevTools backend - most importantly the React DevTools browser extension - by attaching to the existing __REACT_DEVTOOLS_GLOBAL_HOOK__ instead of refusing to install. This is important, because otherwise if the page had Facade installed, the user won't be able to use React DevTools browser extension.
Shared Internal Helpers
Two functions are extracted to share logic between the installed hook's methods and the attach path:
initializeRendererInternals
Initializes per-renderer internal constants for a renderer registered with the hook. Shared by the installed hook's inject() and the attach path.
function initializeRendererInternals(
rendererInternals: Map,
id: number,
renderer: any,
): void {
const version = renderer.reconcilerVersion || renderer.version;
if (version == null) {
console.error(
'react-devtools-facade: Renderer %s has no version, internals not initialized.',
id,
);
return;
}
const { getDisplayNameForFiber, ReactTypeOfWork, ReactPriorityLevels } =
getInternalReactConstants(version);
rendererInternals.set(id, {
getDisplayNameForFiber,
ReactTypeOfWork,
ReactPriorityLevels,
currentDispatcherRef: renderer.currentDispatcherRef,
});
}
recordCommitFiberRoot
Records a commit: keeps fiberRoots in sync (adds new roots, drops unmounted ones) and drives a profiling session when one is active. Shared by the installed hook's onCommitFiberRoot and the attach path's wrapper.
function recordCommitFiberRoot(
fiberRoots: Map<number, Set<FiberRoot>>,
profilingState: ProfilingState,
rendererID: number,
root: any,
schedulerPriority?: number,
): void {
let mountedRoots = fiberRoots.get(rendererID);
if (mountedRoots == null) {
mountedRoots = new Set();
fiberRoots.set(rendererID, mountedRoots);
}
const current = root.current;
const isKnownRoot = mountedRoots.has(root);
const isUnmounting =
current.memoizedState == null || current.memoizedState.element == null;
if (!isKnownRoot && !isUnmounting) {
mountedRoots.add(root);
} else if (isKnownRoot && isUnmounting) {
mountedRoots.delete(root);
}
if (profilingState.isActive && profilingState.onCommit != null) {
profilingState.onCommit(rendererID, root, schedulerPriority);
}
}
Attaching to an Existing Hook
attachToExistingHook handles the case where a DevTools hook is already installed on the page - for example the React DevTools browser extension. Rather than replacing it (React would ignore a second hook), it reads the renderers and fiber roots the existing hook is already tracking, then wraps inject / onCommitFiberRoot / onPostCommitFiberRoot so future renderers, commits, and passive passes also feed the facade's state. The existing hook's own bookkeeping is preserved - the facade always calls through to it first.
function attachToExistingHook(
hook: any,
fiberRoots: Map<number, Set<FiberRoot>>,
rendererInternals: Map<number, RendererInternals>,
profilingState: ProfilingState,
): void {
// Back-fill renderers and roots registered before we attached (React may have
// initialized first).
if (hook.renderers instanceof Map) {
hook.renderers.forEach((renderer: any, id: number) => {
if (!rendererInternals.has(id)) {
initializeRendererInternals(rendererInternals, id, renderer);
}
if (typeof hook.getFiberRoots === 'function') {
let roots = fiberRoots.get(id);
if (roots == null) {
roots = new Set();
fiberRoots.set(id, roots);
}
// Alias to a const so the non-null refinement survives into the closure.
const mountedRoots = roots;
hook.getFiberRoots(id).forEach((root: FiberRoot) => {
mountedRoots.add(root);
});
}
});
}
const originalInject = hook.inject;
hook.inject = function inject(renderer: any, ...rest: Array<any>): number {
const id = originalInject.call(hook, renderer, ...rest);
if (typeof id === 'number') {
initializeRendererInternals(rendererInternals, id, renderer);
}
return id;
};
const originalOnCommitFiberRoot = hook.onCommitFiberRoot;
hook.onCommitFiberRoot = function onCommitFiberRoot(
rendererID: number,
root: any,
schedulerPriority?: number,
...rest: Array<any>
) {
if (typeof originalOnCommitFiberRoot === 'function') {
originalOnCommitFiberRoot.call(
hook,
rendererID,
root,
schedulerPriority,
...rest,
);
}
recordCommitFiberRoot(
fiberRoots,
profilingState,
rendererID,
root,
schedulerPriority,
);
};
const originalOnPostCommitFiberRoot = hook.onPostCommitFiberRoot;
hook.onPostCommitFiberRoot = function onPostCommitFiberRoot(
rendererID: number,
root: any,
...rest: Array<any>
) {
if (typeof originalOnPostCommitFiberRoot === 'function') {
originalOnPostCommitFiberRoot.call(hook, rendererID, root, ...rest);
}
if (profilingState.isActive && profilingState.onPostCommit != null) {
profilingState.onPostCommit(root);
}
};
}
Updated installFacade
The installFacade function now has two paths:
- If
__REACT_DEVTOOLS_GLOBAL_HOOK__is not yet present, it installs the facade's own minimal hook (the global React looks for at init). - If a hook is already installed - e.g. the user has the React DevTools browser extension - the facade attaches to that hook instead of installing a second one.
Either way the returned Facade exposes the same {hook, fiberRoots, rendererInternals, profilingState} that building blocks such as createTools(facade) read from. Install before React initializes so the first commit is captured; when attaching, roots committed before attach are back-filled from the existing hook.
export function installFacade(target?: any = globalThis): Facade {
const fiberRoots: Map<number, Set<FiberRoot>> = new Map();
const rendererInternals: Map<number, RendererInternals> = new Map();
const profilingState: ProfilingState = {
isActive: false,
currentTraceName: null,
onCommit: null,
onPostCommit: null,
};
// A hook is already installed (e.g. the React DevTools extension). Attach to
// it rather than replacing it.
const existingHook = target.__REACT_DEVTOOLS_GLOBAL_HOOK__;
if (existingHook != null) {
attachToExistingHook(
existingHook,
fiberRoots,
rendererInternals,
profilingState,
);
return { hook: existingHook, fiberRoots, rendererInternals, profilingState };
}
let registeredRenderersCount = 0;
// $FlowFixMe[incompatible-type] the facade provides a minimal subset of DevToolsHook
const hook: DevToolsHook = {
renderers: new Map(),
getFiberRoots: () => fiberRoots,
inject(renderer: any): number {
const id = registeredRenderersCount++;
hook.renderers.set(id, renderer);
initializeRendererInternals(rendererInternals, id, renderer);
return id;
},
on() {},
off() {},
sub() {},
unsubscribe() {},
onCommitFiberRoot(
rendererID: number,
root: any,
schedulerPriority?: number,
) {
recordCommitFiberRoot(
fiberRoots,
profilingState,
rendererID,
root,
schedulerPriority,
);
},
onCommitFiberUnmount() {},
onPostCommitFiberRoot(rendererID: number, root: any) {
if (profilingState.isActive && profilingState.onPostCommit != null) {
profilingState.onPostCommit(root);
}
},
};
target.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
return { hook, fiberRoots, rendererInternals, profilingState };
}
Comments
No comments yet. Start the discussion.