diff --git a/.changeset/quick-files-refuse.md b/.changeset/quick-files-refuse.md new file mode 100644 index 000000000000..7920e305a54a --- /dev/null +++ b/.changeset/quick-files-refuse.md @@ -0,0 +1,5 @@ +--- +'@modern-js/devtools-client': patch +--- + +feat(devtools): pull up react devtools element inspector from capsule diff --git a/packages/devtools/client/src/components/Devtools/Button.module.scss b/packages/devtools/client/src/components/Devtools/Button.module.scss new file mode 100644 index 000000000000..d7f9f977103f --- /dev/null +++ b/packages/devtools/client/src/components/Devtools/Button.module.scss @@ -0,0 +1,23 @@ +.container { + width: var(--space-5); + height: var(--space-5); + padding: var(--space-1); + color: var(--gray-12); + stroke: var(--gray-12); + display: flex; + justify-content: center; + align-items: center; + transition: opacity 200ms; + + &[data-loading='true'] { + cursor: wait; + pointer-events: none; + } + + &[data-type='default'] { + opacity: 0.4; + &:hover { + opacity: 1; + } + } +} diff --git a/packages/devtools/client/src/components/Devtools/Button.tsx b/packages/devtools/client/src/components/Devtools/Button.tsx new file mode 100644 index 000000000000..e288e8c53dd5 --- /dev/null +++ b/packages/devtools/client/src/components/Devtools/Button.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useAsyncFn } from 'react-use'; +import { Promisable } from 'type-fest'; +import { Box } from '@radix-ui/themes'; +import styles from './Button.module.scss'; + +export interface DevtoolsCapsuleButtonProps extends React.PropsWithChildren { + type?: 'primary' | 'default'; + onClick: () => Promisable; +} + +export const DevtoolsCapsuleButton: React.FC< + DevtoolsCapsuleButtonProps +> = props => { + const [clickState, handleClick] = useAsyncFn( + () => Promise.resolve(props.onClick()), + [], + ); + + return ( + + {props.children} + + ); +}; diff --git a/packages/devtools/client/src/components/Devtools/Action.module.scss b/packages/devtools/client/src/components/Devtools/Capsule.module.scss similarity index 69% rename from packages/devtools/client/src/components/Devtools/Action.module.scss rename to packages/devtools/client/src/components/Devtools/Capsule.module.scss index 09987a469a70..8868823f8138 100644 --- a/packages/devtools/client/src/components/Devtools/Action.module.scss +++ b/packages/devtools/client/src/components/Devtools/Capsule.module.scss @@ -8,6 +8,7 @@ justify-content: center; align-items: center; pointer-events: none; + & > :global(*) { pointer-events: auto; } @@ -26,22 +27,23 @@ border-radius: 999px; cursor: pointer; touch-action: none; -} - -.fab :global(*) { + padding: 0 var(--space-1); + transition: box-shadow 400ms; user-select: none; - pointer-events: none; + + &:hover { + box-shadow: var(--shadow-5), 0 0 20px var(--gray-a7); + } + + img { + pointer-events: none; + } } .logo { - width: 1.5rem; - height: 1.5rem; + width: 1.25rem; + height: 1.25rem; object-fit: contain; + background-size: contain; } -.heading { - width: 3.25rem; - height: 1rem; - color: var(--gray-a10); - margin: 0 var(--space-2); -} diff --git a/packages/devtools/client/src/components/Devtools/Action.tsx b/packages/devtools/client/src/components/Devtools/Capsule.tsx similarity index 57% rename from packages/devtools/client/src/components/Devtools/Action.tsx rename to packages/devtools/client/src/components/Devtools/Capsule.tsx index f66b26074b06..adf082df9047 100644 --- a/packages/devtools/client/src/components/Devtools/Action.tsx +++ b/packages/devtools/client/src/components/Devtools/Capsule.tsx @@ -2,16 +2,20 @@ import { SetupClientParams } from '@modern-js/devtools-kit'; import { Flex, Theme } from '@radix-ui/themes'; import React, { useState } from 'react'; import { useEvent, useToggle } from 'react-use'; +import { HiMiniCursorArrowRipple } from 'react-icons/hi2'; import { withQuery } from 'ufo'; import Visible from '../Visible'; -import styles from './Action.module.scss'; +import styles from './Capsule.module.scss'; import { FrameBox } from './FrameBox'; -import { ReactComponent as DevToolsIcon } from './heading.svg'; +import { DevtoolsCapsuleButton } from './Button'; import { useStickyDraggable } from '@/utils/draggable'; +import { $client } from '@/entries/mount/state'; +import { pTimeout } from '@/utils/promise'; -export const DevtoolsActionButton: React.FC = props => { +export const DevtoolsCapsule: React.FC = props => { const logoSrc = props.def.assets.logo; const [showDevtools, toggleDevtools] = useToggle(false); + const [loadDevtools, setLoadDevtools] = useState(false); const src = withQuery(props.endpoint, { src: props.dataSource }); @@ -33,9 +37,17 @@ export const DevtoolsActionButton: React.FC = props => { e.shiftKey && e.altKey && e.code === 'KeyD' && toggleDevtools(); }); + const handleClickInspect = async () => { + toggleDevtools(false); + setLoadDevtools(true); + const client = await pTimeout($client, 10_000).catch(() => null); + if (!client) return; + client.remote.pullUpReactInspector(); + }; + return ( - +
= props => { />
- - + +
+ + + + + + +
); diff --git a/packages/devtools/client/src/components/Devtools/FrameBox.tsx b/packages/devtools/client/src/components/Devtools/FrameBox.tsx index e153099bb29c..ce08b98c8938 100644 --- a/packages/devtools/client/src/components/Devtools/FrameBox.tsx +++ b/packages/devtools/client/src/components/Devtools/FrameBox.tsx @@ -1,11 +1,11 @@ -import React, { useState } from 'react'; import { Box } from '@radix-ui/themes'; import type { BoxProps } from '@radix-ui/themes/dist/cjs/components/box'; -import { useAsync } from 'react-use'; +import React from 'react'; import { HiMiniXMark } from 'react-icons/hi2'; +import { useSnapshot } from 'valtio'; import { Loading } from '../Loading'; import styles from './FrameBox.module.scss'; -import { $client } from '@/entries/mount/state'; +import { $inner } from '@/entries/mount/state'; export interface FrameBoxProps extends BoxProps, @@ -19,20 +19,13 @@ export const FrameBox: React.FC = ({ onClose, ...props }) => { - const [showFrame, setShowFrame] = useState(false); - useAsync(async () => { - const client = await $client; - client.hooks.hook('onFinishRender', async () => setShowFrame(true)); - }, []); - + const { loaded } = useSnapshot($inner); + const display = loaded ? 'none' : undefined; return ( -
+
diff --git a/packages/devtools/client/src/components/Devtools/Puller.tsx b/packages/devtools/client/src/components/Devtools/Puller.tsx new file mode 100644 index 000000000000..99fcabdb7d7c --- /dev/null +++ b/packages/devtools/client/src/components/Devtools/Puller.tsx @@ -0,0 +1,36 @@ +import React, { useEffect } from 'react'; +import { useNavigate } from '@modern-js/runtime/router'; +import { useThrowable } from '@/utils'; +import { $mountPoint } from '@/entries/client/routes/state'; +import { bridge } from '@/entries/client/routes/react/state'; + +let _intendPullUp = false; + +$mountPoint.then(({ hooks }) => { + hooks.hookOnce('pullUpReactInspector', async () => { + _intendPullUp = true; + }); +}); + +export const DevtoolsPuller: React.FC = () => { + const navigate = useNavigate(); + const mountPoint = useThrowable($mountPoint); + const handlePullUp = async () => { + navigate('/react'); + const { store } = await import('@/entries/client/routes/react/state'); + if (store.backendVersion) { + bridge.send('startInspectingNative'); + } else { + const handleOperations = () => { + bridge.removeListener('operations', handleOperations); + bridge.send('startInspectingNative'); + }; + bridge.addListener('operations', handleOperations); + } + }; + useEffect(() => { + _intendPullUp && handlePullUp(); + mountPoint.hooks.hook('pullUpReactInspector', handlePullUp); + }, []); + return null; +}; diff --git a/packages/devtools/client/src/components/Visible.tsx b/packages/devtools/client/src/components/Visible.tsx index 4a0f5eaf860d..1ed9adc4e4d9 100644 --- a/packages/devtools/client/src/components/Visible.tsx +++ b/packages/devtools/client/src/components/Visible.tsx @@ -4,6 +4,7 @@ export interface VisibleProps { children: React.ReactNode; when?: boolean; keepAlive?: boolean; + load?: boolean; } const Visible: React.FC = props => { @@ -14,7 +15,7 @@ const Visible: React.FC = props => { if (when) { opened.current = true; } - const load = keepAlive ? opened.current : when; + const load = props.load || (keepAlive ? opened.current : when); const visible = keepAlive ? when : true; return load ? ( diff --git a/packages/devtools/client/src/entries/client/routes/layout.tsx b/packages/devtools/client/src/entries/client/routes/layout.tsx index 92226faf2e26..a991298c5e7c 100644 --- a/packages/devtools/client/src/entries/client/routes/layout.tsx +++ b/packages/devtools/client/src/entries/client/routes/layout.tsx @@ -16,6 +16,7 @@ import { $tabs } from './state'; import { Theme } from '@/components/Theme'; import { InternalTab } from '@/entries/client/types'; import { Breadcrumbs } from '@/components/Breadcrumbs'; +import { DevtoolsPuller } from '@/components/Devtools/Puller'; const NavigateButton: React.FC<{ tab: InternalTab }> = ({ tab }) => { let to = ''; @@ -106,6 +107,7 @@ export default function Layout() { + ); } diff --git a/packages/devtools/client/src/entries/client/routes/react/[tab]/page.tsx b/packages/devtools/client/src/entries/client/routes/react/[tab]/page.tsx index 26e929efd3a9..caa5f9010915 100644 --- a/packages/devtools/client/src/entries/client/routes/react/[tab]/page.tsx +++ b/packages/devtools/client/src/entries/client/routes/react/[tab]/page.tsx @@ -1,14 +1,10 @@ import { useParams } from '@modern-js/runtime/router'; import { Box, useThemeContext } from '@radix-ui/themes'; -import React, { useMemo } from 'react'; -import { - createBridge, - createStore, - initialize, -} from 'react-devtools-inline/frontend'; +import React, { useEffect, useMemo } from 'react'; +import { initialize } from 'react-devtools-inline/frontend'; import { $mountPoint } from '../../state'; +import { bridge, store } from '../state'; import { useThrowable } from '@/utils'; -import { WallAgent } from '@/utils/react-devtools'; const Page: React.FC = () => { const params = useParams(); @@ -16,16 +12,15 @@ const Page: React.FC = () => { const browserTheme = ctx.appearance === 'light' ? 'light' : 'dark'; const mountPoint = useThrowable($mountPoint); - const InnerView = useMemo(() => { - const wallAgent = new WallAgent(); - wallAgent.bindRemote(mountPoint.remote, 'sendReactDevtoolsData'); - const bridge = createBridge(window.parent, wallAgent); - const store = createStore(bridge); - const ret = initialize(window.parent, { bridge, store }); + useEffect(() => { mountPoint.remote.activateReactDevtools(); - return ret; }, []); + const InnerView = useMemo( + () => initialize(window.parent, { bridge, store }), + [], + ); + return ( { + wallAgent.bindRemote(mountPoint.remote, 'sendReactDevtoolsData'); +}); + +export const bridge = createBridge(window.parent, wallAgent); + +export const store = createStore(bridge); diff --git a/packages/devtools/client/src/entries/client/routes/state.tsx b/packages/devtools/client/src/entries/client/routes/state.tsx index f40a5e41d5b2..e4901a940be9 100644 --- a/packages/devtools/client/src/entries/client/routes/state.tsx +++ b/packages/devtools/client/src/entries/client/routes/state.tsx @@ -33,11 +33,13 @@ export const $mountPointChannel = MessagePortChannel.link( 'channel:connect:client', ); -$mountPointChannel.then(() => console.log('$mountPointChannel')); - export const $mountPoint = $mountPointChannel.then(async channel => { const hooks = createHooks(); - const definitions: ToMountPointFunctions = {}; + const definitions: ToMountPointFunctions = { + async pullUpReactInspector() { + await hooks.callHook('pullUpReactInspector'); + }, + }; const remote = createBirpc( definitions, { ...channel.handlers, timeout: 500 }, diff --git a/packages/devtools/client/src/entries/mount/index.tsx b/packages/devtools/client/src/entries/mount/index.tsx index 32fb0a687c99..ac34cbf7c1f7 100644 --- a/packages/devtools/client/src/entries/mount/index.tsx +++ b/packages/devtools/client/src/entries/mount/index.tsx @@ -3,7 +3,7 @@ import './state'; import { createRoot } from 'react-dom/client'; import { SetupClientParams } from '@modern-js/devtools-kit'; import styles from './index.module.scss'; -import { DevtoolsActionButton } from '@/components/Devtools/Action'; +import { DevtoolsCapsule } from '@/components/Devtools/Capsule'; declare global { interface Window { @@ -34,5 +34,5 @@ document.addEventListener('DOMContentLoaded', () => { const options = window.__MODERN_JS_DEVTOOLS_OPTIONS__; const root = createRoot(container); - root.render(); + root.render(); }); diff --git a/packages/devtools/client/src/entries/mount/state.ts b/packages/devtools/client/src/entries/mount/state.ts index 6fd6218f724f..3ea8c7b5a53e 100644 --- a/packages/devtools/client/src/entries/mount/state.ts +++ b/packages/devtools/client/src/entries/mount/state.ts @@ -1,6 +1,8 @@ import { MessagePortChannel } from '@modern-js/devtools-kit'; import { createBirpc } from 'birpc'; import { createHooks } from 'hookable'; +import createDeferred from 'p-defer'; +import { proxy } from 'valtio'; import { activate, createBridge } from 'react-devtools-inline/backend'; import { ClientFunctions, @@ -34,6 +36,16 @@ export const $client = $clientChannel.then(channel => { return { remote, hooks }; }); -$client.then(({ remote }) => { +export const $inner = proxy({ + loaded: false, +}); + +export const innerLoaded = createDeferred(); + +$client.then(({ remote, hooks }) => { wallAgent.bindRemote(remote, 'sendReactDevtoolsData'); + hooks.hook('onFinishRender', async () => { + $inner.loaded = true; + innerLoaded.resolve(); + }); }); diff --git a/packages/devtools/client/src/types/rpc.ts b/packages/devtools/client/src/types/rpc.ts index ce28cb9896c9..e088576a7412 100644 --- a/packages/devtools/client/src/types/rpc.ts +++ b/packages/devtools/client/src/types/rpc.ts @@ -1,5 +1,6 @@ -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ClientFunctions {} +export interface ClientFunctions { + pullUpReactInspector: () => Promise; +} export interface MountPointFunctions { activateReactDevtools: () => Promise; diff --git a/packages/devtools/client/src/utils/draggable.ts b/packages/devtools/client/src/utils/draggable.ts index 5691c3f5bc1c..7f1a5f2c5721 100644 --- a/packages/devtools/client/src/utils/draggable.ts +++ b/packages/devtools/client/src/utils/draggable.ts @@ -75,6 +75,9 @@ export const useStickyDraggable = (options?: StickyDraggableOptions) => { setState(undefined); el.style[primary.key] = margin; }); + el.addEventListener('transitionend', () => (el.style.transition = ''), { + once: true, + }); }; const handleMouseMove = (raw: MouseEvent | TouchEvent) => { diff --git a/packages/devtools/client/src/utils/promise.ts b/packages/devtools/client/src/utils/promise.ts new file mode 100644 index 000000000000..9d601b0cc8f8 --- /dev/null +++ b/packages/devtools/client/src/utils/promise.ts @@ -0,0 +1,12 @@ +export const pTimeout = (promise: Promise, timeout: number) => + new Promise((resolve, reject) => { + let active = true; + const timer = setTimeout(() => { + active = false; + }, timeout); + promise.then(res => { + active && resolve(res); + clearTimeout(timer); + }); + promise.catch(reject); + });