diff --git a/packages/devtools/client/exports/mount.mjs b/packages/devtools/client/exports/mount.mjs index 4badfacf9450..fa8f8424be35 100644 --- a/packages/devtools/client/exports/mount.mjs +++ b/packages/devtools/client/exports/mount.mjs @@ -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; diff --git a/packages/devtools/client/modern.config.ts b/packages/devtools/client/modern.config.ts index 11001ac9b2f2..3e313a4de7b1 100644 --- a/packages/devtools/client/modern.config.ts +++ b/packages/devtools/client/modern.config.ts @@ -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'; @@ -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, diff --git a/packages/devtools/client/package.json b/packages/devtools/client/package.json index 41aad8ab8fee..de38671a5d7e 100644 --- a/packages/devtools/client/package.json +++ b/packages/devtools/client/package.json @@ -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", diff --git a/packages/devtools/client/src/entries/client/routes/react/page.tsx b/packages/devtools/client/src/entries/client/routes/react/page.tsx index 07bd247b8e82..63c41a50a645 100644 --- a/packages/devtools/client/src/entries/client/routes/react/page.tsx +++ b/packages/devtools/client/src/entries/client/routes/react/page.tsx @@ -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 | 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 ( { right: 0, }} > - {View && } + {InnerView && ( + + )} ); }; diff --git a/packages/devtools/client/src/entries/client/rpc/index.ts b/packages/devtools/client/src/entries/client/rpc/index.ts index 0ce5251aa327..fb068650ea48 100644 --- a/packages/devtools/client/src/entries/client/rpc/index.ts +++ b/packages/devtools/client/src/entries/client/rpc/index.ts @@ -1 +1,2 @@ export * from './server'; +export * from './mount'; diff --git a/packages/devtools/client/src/entries/client/rpc/mount.ts b/packages/devtools/client/src/entries/client/rpc/mount.ts new file mode 100644 index 000000000000..f87653b43691 --- /dev/null +++ b/packages/devtools/client/src/entries/client/rpc/mount.ts @@ -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; + 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(); + + const mountPoint = createBirpc( + { + 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; +}; diff --git a/packages/devtools/client/src/entries/mount/index.tsx b/packages/devtools/client/src/entries/mount/index.tsx index f0ac5a864c7b..c1dbb08e0eaa 100644 --- a/packages/devtools/client/src/entries/mount/index.tsx +++ b/packages/devtools/client/src/entries/mount/index.tsx @@ -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 @@ -22,4 +23,5 @@ const options: SetupClientParams = { endpoint: parsed.endpoint, def: JSON.parse(parsed.def), }; +waitClientConnection(); root.render(); diff --git a/packages/devtools/client/src/entries/mount/rpc/client.ts b/packages/devtools/client/src/entries/mount/rpc/client.ts new file mode 100644 index 000000000000..4312257f0d8a --- /dev/null +++ b/packages/devtools/client/src/entries/mount/rpc/client.ts @@ -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( + { + 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>>(); + 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; +}; diff --git a/packages/devtools/client/src/entries/mount/rpc/index.ts b/packages/devtools/client/src/entries/mount/rpc/index.ts new file mode 100644 index 000000000000..4f1cce44fa36 --- /dev/null +++ b/packages/devtools/client/src/entries/mount/rpc/index.ts @@ -0,0 +1 @@ +export * from './client'; diff --git a/packages/devtools/client/src/types/rpc.ts b/packages/devtools/client/src/types/rpc.ts new file mode 100644 index 000000000000..5aa32b08811d --- /dev/null +++ b/packages/devtools/client/src/types/rpc.ts @@ -0,0 +1,15 @@ +import { ReactDevtoolsWallEvent } from '@/utils/react-devtools'; + +export interface ClientFunctions { + sendReactDevtoolsData: (e: ReactDevtoolsWallEvent) => Promise; + onMountPointConnect: () => Promise; +} + +export interface MountPointFunctions { + sendReactDevtoolsData: (e: ReactDevtoolsWallEvent) => Promise; + activateReactDevtools: () => Promise; +} + +export const CLIENT_CONNECT_EVENT = 'modern_js_devtools::client::connect'; + +export type ClientConnectEventType = typeof CLIENT_CONNECT_EVENT; diff --git a/packages/devtools/client/src/utils/react-devtools.ts b/packages/devtools/client/src/utils/react-devtools.ts new file mode 100644 index 000000000000..3dba7628531f --- /dev/null +++ b/packages/devtools/client/src/utils/react-devtools.ts @@ -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); + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f67f932f2da..eef0527d741f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1368,17 +1368,23 @@ importers: specifier: ^10.0.1 version: 10.0.2(postcss@8.4.31) react: - specifier: ~18.2.0 + specifier: ^18.2.0 version: 18.2.0 react-base16-styling: specifier: ^0.9.1 version: 0.9.1 react-devtools-inline: - specifier: 4.10.4 - version: 4.10.4 + specifier: ^4.28.5 + version: 4.28.5 react-dom: - specifier: ~18.2.0 + specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-dom-exp: + specifier: npm:react-dom@0.0.0-experimental-51ffd3564-20231025 + version: /react-dom@0.0.0-experimental-51ffd3564-20231025(react@18.2.0) + react-exp: + specifier: npm:react@0.0.0-experimental-51ffd3564-20231025 + version: /react@0.0.0-experimental-51ffd3564-20231025 react-icons: specifier: ^4.11.0 version: 4.11.0(react@18.2.0) @@ -29491,8 +29497,11 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: true - /react-devtools-inline@4.10.4: - resolution: {integrity: sha512-N2tujWfiLXplM/7Hf1WASTVDRhP0UzmTGCUa0la2PdkW1fNWty4AVn+nCsu2cywM1gfijq9NhVpNWHZdyHjrAw==} + /react-devtools-inline@4.28.5: + resolution: {integrity: sha512-SjXL/nnrLaPWtCb+kLqtSVqO+e1B+V5aC0nXfrMWrEzKgxfJFlaEtpvf9Dc9LaWXlGw1MvJA19L+yNYRzqAq5g==} + dependencies: + source-map-js: 0.6.2 + sourcemap-codec: 1.4.8 dev: true /react-devtools-inline@4.4.0: @@ -29528,6 +29537,16 @@ packages: - supports-color dev: false + /react-dom@0.0.0-experimental-51ffd3564-20231025(react@18.2.0): + resolution: {integrity: sha512-a7RxUzDZLin+HCbNe/LQCn0dIb0ncK+SW9b4bqLW0NK4jlzwVQIXwcErxQYueUGmam4lLOCdeTmdVoAAGPMCCw==} + peerDependencies: + react: 0.0.0-experimental-51ffd3564-20231025 + dependencies: + loose-envify: 1.4.0 + react: 18.2.0 + scheduler: 0.0.0-experimental-51ffd3564-20231025 + dev: true + /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -29862,6 +29881,13 @@ packages: tslib: 2.4.0 dev: true + /react@0.0.0-experimental-51ffd3564-20231025: + resolution: {integrity: sha512-sehV2ZhmM2XFS1gV4ilpRXm1Vm7L2aCXPsMNtQa3mTKUvfdxpJB8KItGpOW/I7Btzi7b7wT4+vIqoOmOQ3lx5A==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + dev: true + /react@17.0.2: resolution: {integrity: sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==} engines: {node: '>=0.10.0'} @@ -30611,6 +30637,12 @@ packages: dependencies: xmlchars: 2.2.0 + /scheduler@0.0.0-experimental-51ffd3564-20231025: + resolution: {integrity: sha512-9FShMMH3ctrZ79/gZc11bXoNpd6Bzzw8Ldgea7UFgA33wZp5gtaTAO7FfFEmcGCnmRkrPDCWvZqD2EWWC6xGcw==} + dependencies: + loose-envify: 1.4.0 + dev: true + /scheduler@0.23.0: resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} dependencies: @@ -30957,6 +30989,11 @@ packages: /source-list-map@2.0.1: resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} + /source-map-js@0.6.2: + resolution: {integrity: sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug==} + engines: {node: '>=0.10.0'} + dev: true + /source-map-js@1.0.2: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'}