Skip to content

Commit

Permalink
Add browser devtool inspect url copy button to dev overlay (#69357)
Browse files Browse the repository at this point in the history
  • Loading branch information
huozhi authored Sep 3, 2024
1 parent e366f3b commit d7c6200
Show file tree
Hide file tree
Showing 20 changed files with 325 additions and 40 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,24 @@ For help, see: https://nodejs.org/en/docs/inspector
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
```

> Be aware that running `NODE_OPTIONS='--inspect' npm run dev` or `NODE_OPTIONS='--inspect' yarn dev` won't work. This would try to start multiple debuggers on the same port: one for the npm/yarn process and one for Next.js. You would then get an error like `Starting inspector on 127.0.0.1:9229 failed: address already in use` in your console.
Once the server starts, open a new tab in Chrome and visit `chrome://inspect`, where you should see your Next.js application inside the **Remote Target** section. Click **inspect** under your application to open a separate DevTools window, then go to the **Sources** tab.

Debugging server-side code here works much like debugging client-side code with Chrome DevTools, except that when you search for files here with `Ctrl+P` or `⌘+P`, your source files will have paths starting with `webpack://{application-name}/./` (where `{application-name}` will be replaced with the name of your application according to your `package.json` file).

### Inspect Server Errors with Chrome DevTools

When you encounter an error, inspecting the source code can help trace the root cause of errors.

Next.js will display a Node.js logo like a green button on the dev overlay. By clicking that button Chrome DevTool url is copied into clipboard, and you can open a new browser tab with that url to inspect the Next.js server process with Chrome DevTool.

<Image
alt="Copy Chrome DevTool url example"
srcLight="/docs/dark/copy-devtool-url-example.png"
srcDark="/docs/dark/copy-devtool-url-example.png"
width="1600"
height="594"
/>

### Debugging on Windows

Windows users may run into an issue when using `NODE_OPTIONS='--inspect'` as that syntax is not supported on Windows platforms. To get around this, install the [`cross-env`](https://www.npmjs.com/package/cross-env) package as a development dependency (`-D` with `npm` and `yarn`) and replace the `dev` script with the following.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export default class ReactDevOverlay extends React.PureComponent<
const hasBuildError = state.buildError != null
const hasRuntimeErrors = Boolean(state.errors.length)
const hasStaticIndicator = state.staticIndicator
const debugInfo = state.debugInfo

return (
<>
Expand Down Expand Up @@ -85,6 +86,7 @@ export default class ReactDevOverlay extends React.PureComponent<
initialDisplayState="fullscreen"
errors={[reactError]}
hasStaticIndicator={hasStaticIndicator}
debugInfo={debugInfo}
/>
) : hasRuntimeErrors ? (
<Errors
Expand All @@ -93,6 +95,7 @@ export default class ReactDevOverlay extends React.PureComponent<
errors={state.errors}
versionInfo={state.versionInfo}
hasStaticIndicator={hasStaticIndicator}
debugInfo={debugInfo}
/>
) : null}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ACTION_BEFORE_REFRESH,
ACTION_BUILD_ERROR,
ACTION_BUILD_OK,
ACTION_DEBUG_INFO,
ACTION_REFRESH,
ACTION_STATIC_INDICATOR,
ACTION_UNHANDLED_ERROR,
Expand Down Expand Up @@ -34,11 +35,13 @@ import type {
import { extractModulesFromTurbopackMessage } from '../../../../server/dev/extract-modules-from-turbopack-message'
import { REACT_REFRESH_FULL_RELOAD_FROM_ERROR } from '../shared'
import type { HydrationErrorState } from '../internal/helpers/hydration-error-info'
import type { DebugInfo } from '../types'

export interface Dispatcher {
onBuildOk(): void
onBuildError(message: string): void
onVersionInfo(versionInfo: VersionInfo): void
onDebugInfo(debugInfo: DebugInfo): void
onBeforeRefresh(): void
onRefresh(): void
onStaticIndicator(status: boolean): void
Expand Down Expand Up @@ -356,6 +359,7 @@ function processMessage(

// Is undefined when it's a 'built' event
if ('versionInfo' in obj) dispatcher.onVersionInfo(obj.versionInfo)
if ('debug' in obj && obj.debug) dispatcher.onDebugInfo(obj.debug)

const hasErrors = Boolean(errors && errors.length)
// Compilation with errors (e.g. syntax error or missing modules).
Expand Down Expand Up @@ -530,6 +534,9 @@ export default function HotReload({
onStaticIndicator(status: boolean) {
dispatch({ type: ACTION_STATIC_INDICATOR, staticIndicator: status })
},
onDebugInfo(debugInfo) {
dispatch({ type: ACTION_DEBUG_INFO, debugInfo })
},
}
}, [dispatch])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ export function CopyButton({
actionLabel,
successLabel,
content,
icon,
disabled,
...props
}: React.HTMLProps<HTMLButtonElement> & {
actionLabel: string
successLabel: string
content: string
icon?: React.ReactNode
}) {
const [copyState, dispatch, isPending] = React.useActionState(
(
Expand Down Expand Up @@ -77,21 +80,22 @@ export function CopyButton({
// Remove from dependencies once https://github.com/facebook/react/pull/29665 is released.
dispatch,
])
const isDisabled = isPending
const isDisabled = isPending || disabled
const label = copyState.state === 'success' ? successLabel : actionLabel
const title = label
const icon =
copyState.state === 'success' ? <CopySuccessIcon /> : <CopyIcon />

// Assign default icon
const renderedIcon =
copyState.state === 'success' ? <CopySuccessIcon /> : icon || <CopyIcon />

return (
<button
{...props}
type="button"
title={title}
title={label}
aria-label={label}
aria-disabled={isDisabled}
data-nextjs-data-runtime-error-copy-stack
className={`nextjs-data-runtime-error-copy-stack nextjs-data-runtime-error-copy-stack--${copyState.state}`}
data-nextjs-data-runtime-error-copy-button
className={`nextjs-data-runtime-error-copy-button nextjs-data-runtime-error-copy-button--${copyState.state}`}
onClick={() => {
if (!isDisabled) {
React.startTransition(() => {
Expand All @@ -100,7 +104,7 @@ export function CopyButton({
}
}}
>
{icon}
{renderedIcon}
{copyState.state === 'error' ? ` ${copyState.error}` : null}
</button>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { CopyButton } from './copy-button'

// Inline this helper to avoid widely used across the codebase,
// as for this feature the Chrome detector doesn't need to be super accurate.
function isChrome() {
if (typeof window === 'undefined') return false
const isChromium = 'chrome' in window && window.chrome
const vendorName = window.navigator.vendor

return (
isChromium !== null &&
isChromium !== undefined &&
vendorName === 'Google Inc.'
)
}

const isChromeBrowser = isChrome()

function NodeJsIcon(props: any) {
return (
<svg
width="44"
height="44"
viewBox="0 0 44 44"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_1546_2)">
<path
d="M22 0L41.0526 11V33L22 44L2.94744 33V11L22 0Z"
fill="#71BD55"
/>
<path
d="M41.0493 11.0001L41.0493 33L22 1.5583e-07L41.0493 11.0001Z"
fill="#A1DF83"
/>
</g>
<defs>
<clipPath id="clip0_1546_2">
<rect width="44" height="44" fill="white" />
</clipPath>
</defs>
</svg>
)
}

function NodeJsDisabledIcon(props: any) {
return (
<svg
width="44"
height="44"
viewBox="0 0 44 44"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M4.44744 11.866L22 1.73205L39.5526 11.866V32.134L22 42.2679L4.44744 32.134V11.866Z"
stroke="currentColor"
fill="transparent"
strokeWidth="3"
strokeLinejoin="round"
/>
<path
d="M22 2L39 32"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
)
}

const label =
'Learn more about enabling Node.js inspector for server code with Chrome DevTools'

export function NodejsInspectorCopyButton({
devtoolsFrontendUrl,
}: {
devtoolsFrontendUrl: string | undefined
}) {
const content = devtoolsFrontendUrl || ''
const disabled = !content || !isChromeBrowser
if (disabled) {
return (
<a
title={label}
aria-label={label}
className="nextjs-data-runtime-error-inspect-link"
href={`https://nextjs.org/docs/app/building-your-application/configuring/debugging#server-side-code`}
target="_blank"
rel="noopener noreferrer"
>
<NodeJsDisabledIcon width={16} height={16} />
</a>
)
}
return (
<CopyButton
data-nextjs-data-runtime-error-copy-devtools-url
actionLabel={'Copy Chrome DevTools URL'}
successLabel="Copied"
content={content}
icon={<NodeJsIcon width={16} height={16} />}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ export const BuildError: React.FC<BuildErrorProps> = function BuildError({
>
<DialogContent>
<DialogHeader className="nextjs-container-errors-header">
<h1 id="nextjs__container_errors_label">{'Build Error'}</h1>
<h1
id="nextjs__container_errors_label"
className="nextjs__container_errors_label"
>
{'Build Error'}
</h1>
<VersionStalenessInfo versionInfo={versionInfo} />
<p
id="nextjs__container_errors_desc"
Expand All @@ -55,7 +60,7 @@ export const BuildError: React.FC<BuildErrorProps> = function BuildError({
}

export const styles = css`
.nextjs-container-errors-header > h1 {
h1.nextjs__container_errors_label {
font-size: var(--size-font-big);
line-height: var(--size-font-bigger);
font-weight: bold;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type UnhandledErrorAction,
type UnhandledRejectionAction,
} from '../../shared'
import type { DebugInfo } from '../../types'
import {
Dialog,
DialogBody,
Expand All @@ -28,6 +29,8 @@ import {
type HydrationErrorState,
getHydrationWarningType,
} from '../helpers/hydration-error-info'
import { NodejsInspectorCopyButton } from '../components/nodejs-inspector'
import { CopyButton } from '../components/copy-button'

export type SupportedErrorEvent = {
id: number
Expand All @@ -39,6 +42,7 @@ export type ErrorsProps = {
initialDisplayState: DisplayState
versionInfo?: VersionInfo
hasStaticIndicator?: boolean
debugInfo?: DebugInfo
}

type ReadyErrorEvent = ReadyRuntimeError
Expand Down Expand Up @@ -71,6 +75,7 @@ export function Errors({
initialDisplayState,
versionInfo,
hasStaticIndicator,
debugInfo,
}: ErrorsProps) {
const [lookups, setLookups] = useState(
{} as { [eventId: string]: ReadyErrorEvent }
Expand Down Expand Up @@ -273,9 +278,28 @@ export function Errors({
</small>
<VersionStalenessInfo versionInfo={versionInfo} />
</LeftRightDialogHeader>
<h1 id="nextjs__container_errors_label">
{isServerError ? 'Server Error' : 'Unhandled Runtime Error'}
</h1>

<div className="nextjs__container_errors__error_title">
<h1
id="nextjs__container_errors_label"
className="nextjs__container_errors_label"
>
{isServerError ? 'Server Error' : 'Unhandled Runtime Error'}
</h1>
<span>
<CopyButton
data-nextjs-data-runtime-error-copy-stack
actionLabel="Copy error stack"
successLabel="Copied"
content={error.stack || ''}
disabled={!error.stack}
/>

<NodejsInspectorCopyButton
devtoolsFrontendUrl={debugInfo?.devtoolsFrontendUrl}
/>
</span>
</div>
<p
id="nextjs__container_errors_desc"
className="nextjs__container_errors_desc"
Expand Down Expand Up @@ -424,4 +448,25 @@ export const styles = css`
top: 0;
right: 0;
}
.nextjs__container_errors_inspect_copy_button {
cursor: pointer;
background: none;
border: none;
color: var(--color-ansi-bright-white);
font-size: 1.5rem;
padding: 0;
margin: 0;
margin-left: var(--size-gap);
transition: opacity 0.25s ease;
}
.nextjs__container_errors__error_title {
display: flex;
align-items: center;
justify-content: space-between;
}
.nextjs-data-runtime-error-inspect-link,
.nextjs-data-runtime-error-inspect-link:hover {
margin: 0 8px;
color: inherit;
}
`
Loading

0 comments on commit d7c6200

Please sign in to comment.