Skip to content

Commit

Permalink
feat(devtools): custom bridge for react-devtools (#5037)
Browse files Browse the repository at this point in the history
  • Loading branch information
Asuka109 authored Dec 5, 2023
1 parent 4776460 commit 3fe4077
Show file tree
Hide file tree
Showing 12 changed files with 244 additions and 46 deletions.
18 changes: 8 additions & 10 deletions packages/devtools/client/exports/mount.mjs
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
import { initialize, activate } from 'react-devtools-inline/backend';
import { initialize } from 'react-devtools-inline/backend';
import routesManifest from '../dist/routes-manifest.json';

if (!window.opener) {
try {
// Delete existing devtools hooks registered by react devtools extension.
try {
delete window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
} catch {}
// Call this before importing React (or any other packages that might import React).
initialize(window);
const handleMessage = e => {
if (
e.data &&
typeof e.data === 'object' &&
e.data.type === 'modern_js_devtools::react_devtools::activate'
) {
activate(window);
window.removeEventListener('message', handleMessage);
// Deny react devtools extension to activate.
const originSubHook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.sub;
window.__REACT_DEVTOOLS_GLOBAL_HOOK__.sub = (e, handler) => {
if (e === 'devtools-backend-installed') {
return undefined;
}
return originSubHook(e, handler);
};
window.addEventListener('message', handleMessage);
} catch (err) {
const e = new Error('Failed to inject React DevTools backend.');
e.cause = err;
Expand Down
6 changes: 6 additions & 0 deletions packages/devtools/client/modern.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from 'path';
import { appTools, defineConfig } from '@modern-js/app-tools';
import { nanoid } from '@modern-js/utils';
import { ROUTE_BASENAME } from '@modern-js/devtools-kit';
Expand Down Expand Up @@ -33,6 +34,11 @@ export default defineConfig<'rspack'>({
'process.env.PKG_VERSION': packageMeta.version,
'process.env.DEVTOOLS_MARK': nanoid(),
},
alias: {
// Trick to fix: Modern.js won't recognize experimental react as react@18.
react: path.resolve('./node_modules/react-exp'),
'react-dom': path.resolve('./node_modules/react-dom-exp'),
},
},
output: {
assetPrefix: ROUTE_BASENAME,
Expand Down
8 changes: 5 additions & 3 deletions packages/devtools/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@
"nanoid": "3.3.4",
"p-defer": "^3.0.0",
"postcss-custom-media": "^10.0.1",
"react": "~18.2.0",
"react": "^18.2.0",
"react-exp": "npm:react@0.0.0-experimental-51ffd3564-20231025",
"react-base16-styling": "^0.9.1",
"react-devtools-inline": "4.10.4",
"react-dom": "~18.2.0",
"react-devtools-inline": "^4.28.5",
"react-dom": "^18.2.0",
"react-dom-exp": "npm:react-dom@0.0.0-experimental-51ffd3564-20231025",
"react-icons": "^4.11.0",
"react-is": "^18.2.0",
"react-json-tree": "^0.18.0",
Expand Down
47 changes: 20 additions & 27 deletions packages/devtools/client/src/entries/client/routes/react/page.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,30 @@
import { useNavigate } from '@modern-js/runtime/router';
import React, { ComponentType, useEffect } from 'react';
import { useGetSet } from 'react-use';
import { Box, useThemeContext } from '@radix-ui/themes';
import { DevtoolsProps, initialize } from 'react-devtools-inline/frontend';
import React from 'react';
import {
createBridge,
createStore,
initialize,
} from 'react-devtools-inline/frontend';
import { useAsync } from 'react-use';
import { setupMountPointConnection } from '../../rpc';

const connTask = setupMountPointConnection();

const Page: React.FC = () => {
const [getView, setView] = useGetSet<ComponentType<DevtoolsProps> | null>(
null,
);
const View = getView();
const navigate = useNavigate();
const ctx = useThemeContext();
const browserTheme = ctx.appearance === 'light' ? 'light' : 'dark';

useEffect(() => {
if (window.parent.window) {
navigate('./');
}
const { value: InnerView } = useAsync(async () => {
const { mountPoint, wall } = await connTask;
const bridge = createBridge(window.parent, wall);
const store = createStore(bridge);
const ret = initialize(window.parent, { bridge, store });
mountPoint.activateReactDevtools();
return ret;
}, []);

const handleRef = (ref: HTMLDivElement | null) => {
if (!ref || getView()) return;
const DevTools = initialize(window.parent);
// TODO: will implement custom bridge via birpc.
// @ts-expect-error: builtin bridge can only inspect react application in iframe.
window.parent.parent = window;
window.parent.postMessage({
type: 'modern_js_devtools::react_devtools::activate',
});
setView(React.memo(DevTools));
};

return (
<Box
ref={handleRef}
style={{
position: 'fixed',
left: 'var(--navigator-width)',
Expand All @@ -42,7 +33,9 @@ const Page: React.FC = () => {
right: 0,
}}
>
{View && <View browserTheme={browserTheme} hideSettings={false} />}
{InnerView && (
<InnerView browserTheme={browserTheme} hideSettings={false} />
)}
</Box>
);
};
Expand Down
1 change: 1 addition & 0 deletions packages/devtools/client/src/entries/client/rpc/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './server';
export * from './mount';
51 changes: 51 additions & 0 deletions packages/devtools/client/src/entries/client/rpc/mount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { BirpcReturn, createBirpc } from 'birpc';
import createDeferPromise from 'p-defer';
import { ReactDevtoolsWallAgent } from '@/utils/react-devtools';
import {
ClientFunctions,
MountPointFunctions,
CLIENT_CONNECT_EVENT,
} from '@/types/rpc';

export interface MountPointConnection {
mountPoint: BirpcReturn<MountPointFunctions, ClientFunctions>;
wall: ReactDevtoolsWallAgent;
}

export const setupMountPointConnection = async () => {
const wallAgent = new ReactDevtoolsWallAgent();
const mountPointWindow = window.parent;
if (!mountPointWindow || mountPointWindow === window) {
throw new Error("Can't resolve the parent window.");
}
const channel = new MessageChannel();
const port = channel.port1;
const mountPointPort = channel.port2;

const connectTask = createDeferPromise<MountPointConnection>();

const mountPoint = createBirpc<MountPointFunctions, ClientFunctions>(
{
async sendReactDevtoolsData(e) {
wallAgent.emit(e);
},
async onMountPointConnect() {
connectTask.resolve({ mountPoint, wall: wallAgent });
},
},
{
post: data => port.postMessage(data),
on: cb => (port.onmessage = cb),
deserialize: e => e.data,
timeout: 500,
},
);

wallAgent.sender = e => mountPoint.sendReactDevtoolsData(e);

mountPointWindow.postMessage({ type: CLIENT_CONNECT_EVENT }, '*', [
mountPointPort,
]);

return connectTask.promise;
};
2 changes: 2 additions & 0 deletions packages/devtools/client/src/entries/mount/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createRoot } from 'react-dom/client';
import { parseQuery } from 'ufo';
import _ from 'lodash';
import { SetupClientParams } from '@modern-js/devtools-kit';
import { waitClientConnection } from './rpc';
import { DevtoolsActionButton } from '@/components/Devtools/Action';

// @ts-expect-error
Expand All @@ -22,4 +23,5 @@ const options: SetupClientParams = {
endpoint: parsed.endpoint,
def: JSON.parse(parsed.def),
};
waitClientConnection();
root.render(<DevtoolsActionButton {...options} />);
58 changes: 58 additions & 0 deletions packages/devtools/client/src/entries/mount/rpc/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import createDeferPromise from 'p-defer';
import { createBirpc } from 'birpc';
import { activate, createBridge } from 'react-devtools-inline/backend';
import {
CLIENT_CONNECT_EVENT,
ClientFunctions,
MountPointFunctions,
} from '@/types/rpc';
import { ReactDevtoolsWallAgent } from '@/utils/react-devtools';

export interface SetupOptions {
port: MessagePort;
}

export const setupClientConnection = async (options: SetupOptions) => {
const { port } = options;
const wallAgent = new ReactDevtoolsWallAgent();
const client = createBirpc<ClientFunctions, MountPointFunctions>(
{
async sendReactDevtoolsData(e) {
wallAgent.emit(e);
},
async activateReactDevtools() {
const bridge = createBridge(window, wallAgent);
activate(window, { bridge });
},
},
{
post: data => port.postMessage(data),
on: cb => (port.onmessage = cb),
deserialize: e => e.data,
timeout: 500,
},
);

wallAgent.sender = e => client.sendReactDevtoolsData(e);

return { client, wall: wallAgent };
};

export const waitClientConnection = async () => {
const connectTask =
createDeferPromise<Awaited<ReturnType<typeof setupClientConnection>>>();
const handleMessage = async (e: MessageEvent) => {
if (!e.data) return;
if (typeof e.data !== 'object') return;
if (e.data.type !== CLIENT_CONNECT_EVENT) return;
const port = e.ports[0];
if (!port) throw new Error('Missing message channel port from devtools.');
window.removeEventListener('message', handleMessage);
const conn = await setupClientConnection({ port });
await conn.client.onMountPointConnect();
connectTask.resolve(conn);
};
window.addEventListener('message', handleMessage);

return connectTask.promise;
};
1 change: 1 addition & 0 deletions packages/devtools/client/src/entries/mount/rpc/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './client';
15 changes: 15 additions & 0 deletions packages/devtools/client/src/types/rpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ReactDevtoolsWallEvent } from '@/utils/react-devtools';

export interface ClientFunctions {
sendReactDevtoolsData: (e: ReactDevtoolsWallEvent) => Promise<void>;
onMountPointConnect: () => Promise<void>;
}

export interface MountPointFunctions {
sendReactDevtoolsData: (e: ReactDevtoolsWallEvent) => Promise<void>;
activateReactDevtools: () => Promise<void>;
}

export const CLIENT_CONNECT_EVENT = 'modern_js_devtools::client::connect';

export type ClientConnectEventType = typeof CLIENT_CONNECT_EVENT;
34 changes: 34 additions & 0 deletions packages/devtools/client/src/utils/react-devtools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Wall } from 'react-devtools-inline';

export interface ReactDevtoolsWallEvent {
event: string;
payload: any;
transferable?: any[] | undefined;
}

export type ReactDevtoolsWallListener = (event: ReactDevtoolsWallEvent) => void;

export class ReactDevtoolsWallAgent implements Wall {
listeners: ReactDevtoolsWallListener[] = [];

sender?: (event: ReactDevtoolsWallEvent) => void;

send(event: string, payload: any, transferable?: any[] | undefined): void {
this.sender?.({
event,
payload,
transferable,
});
}

listen(fn: ReactDevtoolsWallListener): ReactDevtoolsWallListener {
this.listeners.includes(fn) || this.listeners.push(fn);
return fn;
}

emit(event: ReactDevtoolsWallEvent) {
for (const listener of this.listeners) {
listener(event);
}
}
}
Loading

0 comments on commit 3fe4077

Please sign in to comment.