Persist Request Insights display settings (#96277)

## Summary

- Replaces the per-request verbose checkbox with a global Request
Insights settings menu.
- Persists `requestInsights.verbose` through the existing DevTools
configuration path.
- Restores the setting across reloads.
- Does not include the unrelated global `pagehide` flush behavior.

## Verification

```bash
pnpm build-all
pnpm --filter=next types
pnpm --filter=next build
pnpm test-dev-turbo test/development/app-dir/request-insights/request-insights.test.ts
pnpm test-dev-webpack test/development/app-dir/request-insights/request-insights.test.ts
```

<!-- NEXT_JS_LLM -->
This commit is contained in:
David Alexandru Ilie
2026-07-28 14:15:21 -04:00
committed by GitHub
parent efcbb27450
commit c294cb7539
5 changed files with 314 additions and 34 deletions
@@ -18,6 +18,95 @@
min-width: 0;
}
.request-insights-list-toolbar {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 36px;
padding: 4px 10px;
border-bottom: 1px solid var(--color-gray-400);
background: var(--color-background-100);
font-size: var(--size-12);
}
.request-insights-settings {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
}
.request-insights-settings-trigger {
display: inline-flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--color-gray-900);
cursor: pointer;
}
.request-insights-settings-trigger:hover,
.request-insights-settings-trigger[aria-expanded='true'] {
background: var(--color-gray-300);
color: var(--color-gray-1000);
}
.request-insights-settings-positioner {
z-index: var(--top-z-index);
}
.request-insights-settings-menu {
min-width: 160px;
border: 1px solid var(--color-gray-400);
border-radius: 8px;
background: var(--color-background-100);
box-shadow: 0px 4px 8px -4px
color-mix(in srgb, var(--color-gray-900) 4%, transparent);
padding: 4px;
user-select: none;
}
.request-insights-settings-item {
display: flex;
align-items: center;
gap: 6px;
border-radius: 6px;
padding: 6px 8px;
cursor: pointer;
font-size: var(--size-12);
color: var(--color-gray-1000);
}
.request-insights-settings-item[data-highlighted] {
background: var(--color-gray-200);
}
.request-insights-settings-checkbox {
display: inline-flex;
width: 14px;
height: 14px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 1px solid var(--color-gray-500);
border-radius: 4px;
background: var(--color-background-100);
color: var(--color-background-100);
}
.request-insights-settings-checkbox[data-checked] {
border-color: var(--color-gray-1000);
background: var(--color-gray-1000);
}
.request-insights-row {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
@@ -158,27 +247,6 @@
flex-shrink: 0;
}
.request-insights-verbose-toggle {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 5px;
height: var(--size-20);
margin: 0;
color: var(--color-gray-900);
font-size: var(--size-12);
line-height: var(--size-16);
user-select: none;
}
.request-insights-verbose-toggle input {
display: block;
width: var(--size-14);
height: var(--size-14);
flex: 0 0 var(--size-14);
margin: 0;
}
.request-insights-total {
flex-shrink: 0;
color: var(--color-gray-1000);
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'
import { Menu } from '@base-ui-components/react/menu'
import {
getRequestInsightKey,
getRequestInsightKind,
@@ -6,7 +7,10 @@ import {
type RequestInsightFetch,
} from '../../../shared/request-insights'
import { useDevOverlayContext } from '../../../dev-overlay.browser'
import { ACTION_DEVTOOLS_CONFIG } from '../../shared'
import { saveDevToolsConfig } from '../../utils/save-devtools-config'
import { CopyButton } from '../copy-button'
import GearIcon from '../../icons/gear-icon'
import { formatDuration } from './format-duration'
import { getActiveRequestKey, isPageLoadRequest } from './request-list'
import {
@@ -20,11 +24,20 @@ import './request-insights-panel.css'
const TRACE_TICK_COUNT = 5
export function RequestInsightsPanel() {
const { state } = useDevOverlayContext()
const { dispatch, state, shadowRoot } = useDevOverlayContext()
const requests = useMemo(
() => [...state.requestInsights].reverse(),
[state.requestInsights]
)
const { verbose } = state.requestInsightsConfig
const setVerbose = (checked: boolean) => {
const requestInsights = { verbose: checked }
dispatch({
type: ACTION_DEVTOOLS_CONFIG,
devToolsConfig: { requestInsights },
})
saveDevToolsConfig({ requestInsights })
}
const [selectedRequestKey, setSelectedRequestKey] = useState<string | null>(
() => getActiveRequestKey(requests, null)
)
@@ -46,6 +59,44 @@ export function RequestInsightsPanel() {
return (
<div className="request-insights-panel">
<div className="request-insights-list">
<div className="request-insights-list-toolbar">
<strong>Requests</strong>
<div className="request-insights-settings">
<Menu.Root delay={0} modal={false}>
<Menu.Trigger
aria-label="Request list settings"
className="request-insights-settings-trigger"
>
<GearIcon />
</Menu.Trigger>
<Menu.Portal container={shadowRoot}>
<Menu.Positioner
align="end"
className="request-insights-settings-positioner"
side="bottom"
sideOffset={4}
>
<Menu.Popup className="request-insights-settings-menu">
<Menu.CheckboxItem
checked={verbose}
className="request-insights-settings-item"
closeOnClick={false}
onCheckedChange={setVerbose}
>
<span
className="request-insights-settings-checkbox"
data-checked={verbose || undefined}
>
{verbose ? <CheckIcon /> : null}
</span>
Verbose traces
</Menu.CheckboxItem>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</div>
</div>
{requests.map((request) => {
const requestKey = getRequestInsightKey(request)
return (
@@ -60,7 +111,9 @@ export function RequestInsightsPanel() {
})}
</div>
{selectedRequest && <RequestDetails request={selectedRequest} />}
{selectedRequest && (
<RequestDetails request={selectedRequest} verbose={verbose} />
)}
</div>
)
}
@@ -114,8 +167,27 @@ function RequestRow({
)
}
function RequestDetails({ request }: { request: RequestInsight }) {
const [verbose, setVerbose] = useState(false)
function CheckIcon() {
return (
<svg
aria-hidden="true"
fill="currentColor"
height="12"
viewBox="0 0 16 16"
width="12"
>
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />
</svg>
)
}
function RequestDetails({
request,
verbose,
}: {
request: RequestInsight
verbose: boolean
}) {
const traceItems = useMemo(
() => getTraceItems(request, verbose),
[request, verbose]
@@ -139,14 +211,6 @@ function RequestDetails({ request }: { request: RequestInsight }) {
content={JSON.stringify(request, null, 2)}
successLabel="Copied request JSON"
/>
<label className="request-insights-verbose-toggle">
<input
checked={verbose}
onChange={(event) => setVerbose(event.currentTarget.checked)}
type="checkbox"
/>
<span>Verbose</span>
</label>
</div>
</div>
<div className="request-insights-total">
@@ -29,6 +29,9 @@ export type DevToolsConfig = {
devToolsPanelSize?: Record<string, { width: number; height: number }>
scale?: number
hideShortcut?: string | null
requestInsights?: {
verbose?: boolean
}
}
export type Corners = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
@@ -83,6 +86,9 @@ export interface OverlayState {
readonly hideShortcut: string | null
readonly instantNavs: boolean
readonly requestInsights: readonly RequestInsight[]
readonly requestInsightsConfig: Readonly<{
verbose: boolean
}>
}
type DevtoolsPanelName = string
export type OverlayDispatch = React.Dispatch<DispatcherEvent>
@@ -386,6 +392,7 @@ export const INITIAL_OVERLAY_STATE: Omit<
hideShortcut: null,
instantNavs: hasInstantNavsCookie,
requestInsights: [],
requestInsightsConfig: { verbose: false },
}
function getInitialState(
@@ -582,6 +589,7 @@ export function useErrorOverlayReducer(
devToolsPanelSize,
scale,
hideShortcut,
requestInsights: requestInsightsConfig,
} = action.devToolsConfig
return {
@@ -597,6 +605,13 @@ export function useErrorOverlayReducer(
hideShortcut:
// hideShortcut can be null.
hideShortcut !== undefined ? hideShortcut : state.hideShortcut,
requestInsightsConfig: requestInsightsConfig
? {
verbose:
requestInsightsConfig.verbose ??
state.requestInsightsConfig.verbose,
}
: state.requestInsightsConfig,
}
}
case ACTION_INSTANT_NAVS_TOGGLE: {
@@ -18,4 +18,9 @@ export const devToolsConfigSchema: z.ZodType<DevToolsConfig> = z.object({
.optional(),
scale: z.number().optional(),
hideShortcut: z.string().nullable().optional(),
requestInsights: z
.object({
verbose: z.boolean().optional(),
})
.optional(),
})
@@ -1,7 +1,11 @@
import { nextTestSetup } from 'e2e-utils'
import { createServer } from 'http'
import type { AddressInfo } from 'net'
import { retry, waitForNoRedbox } from 'next-test-utils'
import {
retry,
toggleDevToolsIndicatorPopover,
waitForNoRedbox,
} from 'next-test-utils'
type RequestInsight = {
requestId: string
@@ -223,6 +227,130 @@ describe('request insights', () => {
})
})
it('persists verbose trace settings', async () => {
const browser = await next.browser('/instant-insights')
async function openRequestInsightsPanel() {
await toggleDevToolsIndicatorPopover(browser)
await browser.elementByCss('[data-request-insights]').click()
await browser.waitForElementByCss('.request-insights-list-toolbar')
// The panel selector menu stays mounted for its exit animation and its
// click-outside handler would close the freshly opened panel. Wait for
// it to fully unmount before interacting with the panel.
await retry(async () => {
const selectorMenuGone = await browser.eval(() => {
const root = document.querySelector('nextjs-portal')?.shadowRoot
return !root?.querySelector('#nextjs-dev-tools-menu')
})
expect(selectorMenuGone).toBe(true)
})
}
function getSettingsMenuState(): Promise<{
open: boolean
checked: string | null
}> {
return browser.eval(() => {
const root = document.querySelector('nextjs-portal')?.shadowRoot
const item = root?.querySelector('.request-insights-settings-item')
return {
open: !!root?.querySelector('.request-insights-settings-menu'),
checked:
item
?.querySelector('.request-insights-settings-checkbox')
?.getAttribute('data-checked') ?? null,
}
})
}
function getSpanRowCount(): Promise<number> {
return browser.eval(() => {
const root = document.querySelector('nextjs-portal')?.shadowRoot
return root?.querySelectorAll('.request-insights-span-row').length ?? 0
})
}
await openRequestInsightsPanel()
await retry(async () => {
const selectedRegularRequest = await browser.eval(() => {
const root = document.querySelector('nextjs-portal')?.shadowRoot
const row = Array.from(
root?.querySelectorAll<HTMLButtonElement>('.request-insights-row') ??
[]
).find(
(candidate) => !candidate.textContent?.includes('Instant Insights')
)
row?.click()
return row !== undefined
})
expect(selectedRegularRequest).toBe(true)
})
await browser.elementByCss('.request-insights-settings-trigger').click()
await retry(async () => {
expect(await getSettingsMenuState()).toEqual({
open: true,
checked: null,
})
})
let defaultSpanRowCount = 0
await retry(async () => {
defaultSpanRowCount = await getSpanRowCount()
expect(defaultSpanRowCount).toBeGreaterThan(0)
})
await browser
.elementByCss(
'.request-insights-settings-item:has-text("Verbose traces")'
)
.click()
await retry(async () => {
expect(await getSettingsMenuState()).toEqual({
open: true,
checked: 'true',
})
expect(await getSpanRowCount()).toBeGreaterThan(defaultSpanRowCount)
})
await retry(async () => {
const config = JSON.parse(
await next.readFile('build/dev/cache/next-devtools-config.json')
)
expect(config.requestInsights?.verbose).toBe(true)
})
await browser.elementByCss('.request-insights-details').click()
await retry(async () => {
expect((await getSettingsMenuState()).open).toBe(false)
})
await browser.refresh()
await openRequestInsightsPanel()
await browser.elementByCss('.request-insights-settings-trigger').click()
await retry(async () => {
expect(await getSettingsMenuState()).toEqual({
open: true,
checked: 'true',
})
})
// Restore the default so this test does not affect later assertions.
await browser
.elementByCss(
'.request-insights-settings-item:has-text("Verbose traces")'
)
.click()
await retry(async () => {
const config = JSON.parse(
await next.readFile('build/dev/cache/next-devtools-config.json')
)
expect(config.requestInsights?.verbose).toBe(false)
})
})
it('uses the development endpoint and reports truncated output', async () => {
const { result, requestedPaths } = await runWithResponse(
{