Skip to content

Commit

Permalink
refactor[react-devtools]: initialize renderer interface early (#30946)
Browse files Browse the repository at this point in the history
The current state is that `rendererInterface`, which contains all the
backend logic, like generating component stack or attaching errors to
fibers, or traversing the Fiber tree, ..., is only mounted after the
Frontend is created.

For browser extension, this means that we don't patch console or track
errors and warnings before Chrome DevTools is opened.

With these changes, `rendererInterface` is created right after
`renderer` is injected from React via global hook object (e. g.
`__REACT_DEVTOOLS_GLOBAL_HOOK__.inject(...)`.

Because of the current implementation, in case of multiple Reacts on the
page, all of them will patch the console independently. This will be
fixed in one of the next PRs, where I am moving console patching to the
global Hook.

This change of course makes `hook.js` script bigger, but I think it is a
reasonable trade-off for better DevX. We later can add more heuristics
to optimize the performance (if necessary) of `rendererInterface` for
cases when Frontend was connected late and Backend is attempting to
flush out too many recorded operations.

This essentially reverts #26563.
  • Loading branch information
hoxyq authored Sep 12, 2024
1 parent d6cb4e7 commit bb6b86e
Show file tree
Hide file tree
Showing 10 changed files with 110 additions and 134 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,6 @@ const contentScriptsToInject = [
runAt: 'document_start',
world: chrome.scripting.ExecutionWorld.MAIN,
},
{
id: '@react-devtools/renderer',
js: ['build/renderer.js'],
matches: ['<all_urls>'],
persistAcrossSessions: true,
runAt: 'document_start',
world: chrome.scripting.ExecutionWorld.MAIN,
},
];

async function dynamicallyInjectContentScripts() {
Expand Down
33 changes: 0 additions & 33 deletions packages/react-devtools-extensions/src/contentScripts/renderer.js

This file was deleted.

1 change: 0 additions & 1 deletion packages/react-devtools-extensions/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ module.exports = {
panel: './src/panel.js',
proxy: './src/contentScripts/proxy.js',
prepareInjection: './src/contentScripts/prepareInjection.js',
renderer: './src/contentScripts/renderer.js',
installHook: './src/contentScripts/installHook.js',
},
output: {
Expand Down
61 changes: 61 additions & 0 deletions packages/react-devtools-shared/src/attachRenderer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

import type {
ReactRenderer,
RendererInterface,
DevToolsHook,
RendererID,
} from 'react-devtools-shared/src/backend/types';

import {attach as attachFlight} from 'react-devtools-shared/src/backend/flight/renderer';
import {attach as attachFiber} from 'react-devtools-shared/src/backend/fiber/renderer';
import {attach as attachLegacy} from 'react-devtools-shared/src/backend/legacy/renderer';
import {hasAssignedBackend} from 'react-devtools-shared/src/backend/utils';

// this is the backend that is compatible with all older React versions
function isMatchingRender(version: string): boolean {
return !hasAssignedBackend(version);
}

export default function attachRenderer(
hook: DevToolsHook,
id: RendererID,
renderer: ReactRenderer,
global: Object,
): RendererInterface | void {
// only attach if the renderer is compatible with the current version of the backend
if (!isMatchingRender(renderer.reconcilerVersion || renderer.version)) {
return;
}
let rendererInterface = hook.rendererInterfaces.get(id);

// Inject any not-yet-injected renderers (if we didn't reload-and-profile)
if (rendererInterface == null) {
if (typeof renderer.getCurrentComponentInfo === 'function') {
// react-flight/client
rendererInterface = attachFlight(hook, id, renderer, global);
} else if (
// v16-19
typeof renderer.findFiberByHostInstance === 'function' ||
// v16.8+
renderer.currentDispatcherRef != null
) {
// react-reconciler v16+
rendererInterface = attachFiber(hook, id, renderer, global);
} else if (renderer.ComponentTree) {
// react-dom v15
rendererInterface = attachLegacy(hook, id, renderer, global);
} else {
// Older react-dom or other unsupported renderer version
}
}

return rendererInterface;
}
15 changes: 12 additions & 3 deletions packages/react-devtools-shared/src/backend/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export default class Agent extends EventEmitter<{
traceUpdates: [Set<HostInstance>],
drawTraceUpdates: [Array<HostInstance>],
disableTraceUpdates: [],
getIfHasUnsupportedRendererVersion: [],
}> {
_bridge: BackendBridge;
_isProfiling: boolean = false;
Expand Down Expand Up @@ -221,6 +222,10 @@ export default class Agent extends EventEmitter<{
);
bridge.addListener('updateComponentFilters', this.updateComponentFilters);
bridge.addListener('getEnvironmentNames', this.getEnvironmentNames);
bridge.addListener(
'getIfHasUnsupportedRendererVersion',
this.getIfHasUnsupportedRendererVersion,
);

// Temporarily support older standalone front-ends sending commands to newer embedded backends.
// We do this because React Native embeds the React DevTools backend,
Expand Down Expand Up @@ -709,7 +714,7 @@ export default class Agent extends EventEmitter<{
}
}

setRendererInterface(
registerRendererInterface(
rendererID: RendererID,
rendererInterface: RendererInterface,
) {
Expand Down Expand Up @@ -940,8 +945,12 @@ export default class Agent extends EventEmitter<{
}
};

onUnsupportedRenderer(rendererID: number) {
this._bridge.send('unsupportedRendererVersion', rendererID);
getIfHasUnsupportedRendererVersion: () => void = () => {
this.emit('getIfHasUnsupportedRendererVersion');
};

onUnsupportedRenderer() {
this._bridge.send('unsupportedRendererVersion');
}

_persistSelectionTimerScheduled: boolean = false;
Expand Down
101 changes: 22 additions & 79 deletions packages/react-devtools-shared/src/backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,7 @@

import Agent from './agent';

import {attach as attachFiber} from './fiber/renderer';
import {attach as attachFlight} from './flight/renderer';
import {attach as attachLegacy} from './legacy/renderer';

import {hasAssignedBackend} from './utils';

import type {DevToolsHook, ReactRenderer, RendererInterface} from './types';

// this is the backend that is compatible with all older React versions
function isMatchingRender(version: string): boolean {
return !hasAssignedBackend(version);
}
import type {DevToolsHook, RendererID, RendererInterface} from './types';

export type InitBackend = typeof initBackend;

Expand All @@ -34,29 +23,32 @@ export function initBackend(
return () => {};
}

function registerRendererInterface(
id: RendererID,
rendererInterface: RendererInterface,
) {
agent.registerRendererInterface(id, rendererInterface);

// Now that the Store and the renderer interface are connected,
// it's time to flush the pending operation codes to the frontend.
rendererInterface.flushInitialOperations();
}

const subs = [
hook.sub(
'renderer-attached',
({
id,
renderer,
rendererInterface,
}: {
id: number,
renderer: ReactRenderer,
rendererInterface: RendererInterface,
...
}) => {
agent.setRendererInterface(id, rendererInterface);

// Now that the Store and the renderer interface are connected,
// it's time to flush the pending operation codes to the frontend.
rendererInterface.flushInitialOperations();
registerRendererInterface(id, rendererInterface);
},
),

hook.sub('unsupported-renderer-version', (id: number) => {
agent.onUnsupportedRenderer(id);
hook.sub('unsupported-renderer-version', () => {
agent.onUnsupportedRenderer();
}),

hook.sub('fastRefreshScheduled', agent.onFastRefreshScheduled),
Expand All @@ -66,68 +58,19 @@ export function initBackend(
// TODO Add additional subscriptions required for profiling mode
];

const attachRenderer = (id: number, renderer: ReactRenderer) => {
// only attach if the renderer is compatible with the current version of the backend
if (!isMatchingRender(renderer.reconcilerVersion || renderer.version)) {
return;
}
let rendererInterface = hook.rendererInterfaces.get(id);

// Inject any not-yet-injected renderers (if we didn't reload-and-profile)
if (rendererInterface == null) {
if (typeof renderer.getCurrentComponentInfo === 'function') {
// react-flight/client
rendererInterface = attachFlight(hook, id, renderer, global);
} else if (
// v16-19
typeof renderer.findFiberByHostInstance === 'function' ||
// v16.8+
renderer.currentDispatcherRef != null
) {
// react-reconciler v16+
rendererInterface = attachFiber(hook, id, renderer, global);
} else if (renderer.ComponentTree) {
// react-dom v15
rendererInterface = attachLegacy(hook, id, renderer, global);
} else {
// Older react-dom or other unsupported renderer version
}

if (rendererInterface != null) {
hook.rendererInterfaces.set(id, rendererInterface);
}
agent.addListener('getIfHasUnsupportedRendererVersion', () => {
if (hook.hasUnsupportedRendererAttached) {
agent.onUnsupportedRenderer();
}

// Notify the DevTools frontend about new renderers.
// This includes any that were attached early (via __REACT_DEVTOOLS_ATTACH__).
if (rendererInterface != null) {
hook.emit('renderer-attached', {
id,
renderer,
rendererInterface,
});
} else {
hook.emit('unsupported-renderer-version', id);
}
};

// Connect renderers that have already injected themselves.
hook.renderers.forEach((renderer, id) => {
attachRenderer(id, renderer);
});

// Connect any new renderers that injected themselves.
subs.push(
hook.sub(
'renderer',
({id, renderer}: {id: number, renderer: ReactRenderer, ...}) => {
attachRenderer(id, renderer);
},
),
);
hook.rendererInterfaces.forEach((rendererInterface, id) => {
registerRendererInterface(id, rendererInterface);
});

hook.emit('react-devtools', agent);
hook.reactDevtoolsAgent = agent;

const onAgentShutdown = () => {
subs.forEach(fn => fn());
hook.rendererInterfaces.forEach(rendererInterface => {
Expand Down
1 change: 1 addition & 0 deletions packages/react-devtools-shared/src/backend/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ export type DevToolsHook = {
listeners: {[key: string]: Array<Handler>, ...},
rendererInterfaces: Map<RendererID, RendererInterface>,
renderers: Map<RendererID, ReactRenderer>,
hasUnsupportedRendererAttached: boolean,
backends: Map<string, DevToolsBackend>,

emit: (event: string, data: any) => void,
Expand Down
3 changes: 2 additions & 1 deletion packages/react-devtools-shared/src/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export type BackendEvents = {
stopInspectingHost: [boolean],
syncSelectionFromBuiltinElementsPanel: [],
syncSelectionToBuiltinElementsPanel: [],
unsupportedRendererVersion: [RendererID],
unsupportedRendererVersion: [],

// React Native style editor plug-in.
isNativeStyleEditorSupported: [
Expand All @@ -218,6 +218,7 @@ type FrontendEvents = {
deletePath: [DeletePath],
getBackendVersion: [],
getBridgeProtocol: [],
getIfHasUnsupportedRendererVersion: [],
getOwnersList: [ElementAndRendererID],
getProfilingData: [{rendererID: RendererID}],
getProfilingStatus: [],
Expand Down
1 change: 1 addition & 0 deletions packages/react-devtools-shared/src/devtools/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -1500,6 +1500,7 @@ export default class Store extends EventEmitter<{
}

this._bridge.send('getBackendVersion');
this._bridge.send('getIfHasUnsupportedRendererVersion');
};

// The Store should never throw an Error without also emitting an event.
Expand Down
20 changes: 11 additions & 9 deletions packages/react-devtools-shared/src/hook.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
FIREFOX_CONSOLE_DIMMING_COLOR,
ANSI_STYLE_DIMMING_TEMPLATE,
} from 'react-devtools-shared/src/constants';
import attachRenderer from './attachRenderer';

declare var window: any;

Expand Down Expand Up @@ -358,7 +359,6 @@ export function installHook(target: any): DevToolsHook | null {
}

let uidCounter = 0;

function inject(renderer: ReactRenderer): number {
const id = ++uidCounter;
renderers.set(id, renderer);
Expand All @@ -367,20 +367,21 @@ export function installHook(target: any): DevToolsHook | null {
? 'deadcode'
: detectReactBuildType(renderer);

// If we have just reloaded to profile, we need to inject the renderer interface before the app loads.
// Otherwise the renderer won't yet exist and we can skip this step.
const attach = target.__REACT_DEVTOOLS_ATTACH__;
if (typeof attach === 'function') {
const rendererInterface = attach(hook, id, renderer, target);
hook.rendererInterfaces.set(id, rendererInterface);
}

hook.emit('renderer', {
id,
renderer,
reactBuildType,
});

const rendererInterface = attachRenderer(hook, id, renderer, target);
if (rendererInterface != null) {
hook.rendererInterfaces.set(id, rendererInterface);
hook.emit('renderer-attached', {id, rendererInterface});
} else {
hook.hasUnsupportedRendererAttached = true;
hook.emit('unsupported-renderer-version');
}

return id;
}

Expand Down Expand Up @@ -534,6 +535,7 @@ export function installHook(target: any): DevToolsHook | null {

// Fast Refresh for web relies on this.
renderers,
hasUnsupportedRendererAttached: false,

emit,
getFiberRoots,
Expand Down

0 comments on commit bb6b86e

Please sign in to comment.