mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
### What?
Adds a tab bar to the dev overlay that separates normal errors
("Errors") from instant navigation errors ("Insights"). The indicator
pill also reflects the split.
### Why?
When `unstable_instant` validation produces navigation-phase errors
alongside regular prerender errors, they were mixed into a single list.
Developers had no way to tell which errors were structural
instant-validation issues versus regular runtime/prerender errors.
### Demo
- [Demo 1: prerender
blocking-route](https://error-messages-overhaul-ibsl.labs.vercel.dev/scenario/01-cookies-body)
— `Blocking Route` badge (red), Errors tab.
- [Demo 2: navigation
blocking-route](https://error-messages-overhaul-ibsl.labs.vercel.dev/scenario/42-subnav-fetch)
— `Instant` badge (amber), Insights tab.
### How?
- `Errors` splits `runtimeErrors` into `normalErrors` / `instantErrors`
(using the existing `inNavigation` flag from
`getBlockingRouteErrorDetails`) and defaults to whichever bucket has
errors.
- `ErrorTabBar` renders between the nav and dialog inside
`ErrorOverlayLayout` (new `tabBar` prop). Empty tabs are disabled.
- `ErrorOverlay` passes a `key` derived from the error composition so
tab state resets when the shape changes (e.g. normal errors resolve).
- `RenderErrorContext` gains `instantErrorCount`; the indicator pill
shows "N Issues", "N Insights", or "N Issues · N Insights" accordingly.
- Prerender errors show `Blocking Route` badge (red), navigation errors
show `Instant` badge (amber).
---------
Co-authored-by: Yavor Punchev <[email protected]>
213 lines
7.1 KiB
TypeScript
213 lines
7.1 KiB
TypeScript
import { nextTestSetup, type Playwright } from 'e2e-utils'
|
|
import { retry } from 'next-test-utils'
|
|
|
|
describe('instant insights tab overlay', () => {
|
|
const { next } = nextTestSetup({
|
|
files: __dirname,
|
|
})
|
|
|
|
// The dev overlay UI lives inside <nextjs-portal>'s shadow DOM, so we
|
|
// can't use Playwright's regular selectors.
|
|
function evalInPortal<T>(browser: Playwright, fn: (root: any) => T) {
|
|
return browser.eval(`(() => {
|
|
const portal = document.querySelector('nextjs-portal');
|
|
const root = portal && portal.shadowRoot;
|
|
if (!root) return null;
|
|
return (${fn.toString()})(root);
|
|
})()`) as Promise<T>
|
|
}
|
|
|
|
function getIndicatorPillState(browser: Playwright) {
|
|
return evalInPortal(browser, (root) => {
|
|
const badge = root.querySelector('[data-next-badge]')
|
|
if (!badge) return null
|
|
return {
|
|
hasError: badge.getAttribute('data-error') === 'true',
|
|
insightsOnly: badge.getAttribute('data-insights-only') === 'true',
|
|
text: badge.textContent?.trim() ?? '',
|
|
}
|
|
})
|
|
}
|
|
|
|
function hasErrorOverlayTabBar(browser: Playwright) {
|
|
return evalInPortal(browser, (root) => {
|
|
return !!root.querySelector('[data-nextjs-error-overlay-tab-bar]')
|
|
})
|
|
}
|
|
|
|
function getErrorOverlayTabCounts(browser: Playwright) {
|
|
return evalInPortal(browser, (root) => {
|
|
const bar = root.querySelector('[data-nextjs-error-overlay-tab-bar]')
|
|
if (!bar) return null
|
|
const tabs = bar.querySelectorAll('.error-overlay-tab')
|
|
const parseTotal = (tab: Element | undefined) => {
|
|
const totalEl = tab?.querySelector(
|
|
'.error-overlay-pagination-count > span:last-child'
|
|
)
|
|
return parseInt(totalEl?.textContent || '0', 10)
|
|
}
|
|
return {
|
|
errors: parseTotal(tabs[0]),
|
|
instant: parseTotal(tabs[1]),
|
|
}
|
|
})
|
|
}
|
|
|
|
function getActiveErrorOverlayTab(browser: Playwright) {
|
|
return evalInPortal(browser, (root) => {
|
|
const bar = root.querySelector('[data-nextjs-error-overlay-tab-bar]')
|
|
if (!bar) return null
|
|
const tabs = [...bar.querySelectorAll('.error-overlay-tab')]
|
|
const activeIndex = tabs.findIndex(
|
|
(tab) => tab.getAttribute('data-active') === 'true'
|
|
)
|
|
if (activeIndex === -1) return null
|
|
return activeIndex === 0 ? 'errors' : 'instant'
|
|
})
|
|
}
|
|
|
|
function getErrorOverlayTabs(browser: Playwright) {
|
|
return evalInPortal(browser, (root) => {
|
|
const bar = root.querySelector('[data-nextjs-error-overlay-tab-bar]')
|
|
if (!bar) return null
|
|
const variants = ['errors', 'instant'] as const
|
|
return [...bar.querySelectorAll('.error-overlay-tab')].map(
|
|
(tab: any, index: number) => ({
|
|
variant: variants[index],
|
|
disabled: tab.disabled === true,
|
|
active: tab.getAttribute('data-active') === 'true',
|
|
})
|
|
)
|
|
})
|
|
}
|
|
|
|
function clickIssuesTab(browser: Playwright) {
|
|
return browser.eval(`(() => {
|
|
const portal = document.querySelector('nextjs-portal');
|
|
const root = portal && portal.shadowRoot;
|
|
if (!root) return;
|
|
const tabs = root.querySelectorAll(
|
|
'[data-nextjs-error-overlay-tab-bar] .error-overlay-tab'
|
|
);
|
|
tabs[0] && tabs[0].click();
|
|
})()`)
|
|
}
|
|
|
|
function clickInsightsTab(browser: Playwright) {
|
|
return browser.eval(`(() => {
|
|
const portal = document.querySelector('nextjs-portal');
|
|
const root = portal && portal.shadowRoot;
|
|
if (!root) return;
|
|
const tabs = root.querySelectorAll(
|
|
'[data-nextjs-error-overlay-tab-bar] .error-overlay-tab'
|
|
);
|
|
tabs[1] && tabs[1].click();
|
|
})()`)
|
|
}
|
|
|
|
it('should show a red pill and no tab bar when only an Issue is present', async () => {
|
|
const browser = await next.browser('/issue-only')
|
|
|
|
await retry(async () => {
|
|
const state = await getIndicatorPillState(browser)
|
|
expect(state).not.toBeNull()
|
|
expect(state!.hasError).toBe(true)
|
|
expect(state!.insightsOnly).toBe(false)
|
|
expect(state!.text).toMatch(/1\s*Issue/i)
|
|
})
|
|
|
|
expect(await hasErrorOverlayTabBar(browser)).toBe(false)
|
|
})
|
|
|
|
it('should show an amber pill when only an Insight is present', async () => {
|
|
const browser = await next.browser('/')
|
|
await browser.elementByCss('a[href="/insight-only"]').click()
|
|
|
|
await retry(async () => {
|
|
const state = await getIndicatorPillState(browser)
|
|
expect(state).not.toBeNull()
|
|
expect(state!.hasError).toBe(false)
|
|
expect(state!.insightsOnly).toBe(true)
|
|
expect(state!.text).toMatch(/1\s*Insight/i)
|
|
})
|
|
|
|
expect(await hasErrorOverlayTabBar(browser)).toBe(true)
|
|
expect(await getActiveErrorOverlayTab(browser)).toBe('instant')
|
|
|
|
expect(await getErrorOverlayTabs(browser)).toEqual([
|
|
{ variant: 'errors', disabled: true, active: false },
|
|
{ variant: 'instant', disabled: false, active: true },
|
|
])
|
|
expect(await getErrorOverlayTabCounts(browser)).toEqual({
|
|
errors: 0,
|
|
instant: 1,
|
|
})
|
|
})
|
|
|
|
it('should not switch tabs when clicking a disabled tab', async () => {
|
|
const browser = await next.browser('/')
|
|
await browser.elementByCss('a[href="/insight-only"]').click()
|
|
|
|
await retry(async () => {
|
|
expect(await getActiveErrorOverlayTab(browser)).toBe('instant')
|
|
})
|
|
|
|
await clickIssuesTab(browser)
|
|
expect(await getActiveErrorOverlayTab(browser)).toBe('instant')
|
|
})
|
|
|
|
it('should show both tabs when an Issue and an Insight coexist', async () => {
|
|
const browser = await next.browser('/')
|
|
await browser.elementByCss('a[href="/issue-only"]').click()
|
|
|
|
await retry(async () => {
|
|
const state = await getIndicatorPillState(browser)
|
|
expect(state?.text).toMatch(/1\s*Issue/i)
|
|
})
|
|
|
|
await browser.eval('history.back()')
|
|
await browser.waitForElementByCss('a[href="/insight-only"]')
|
|
await browser.elementByCss('a[href="/insight-only"]').click()
|
|
|
|
await retry(async () => {
|
|
const counts = await getErrorOverlayTabCounts(browser)
|
|
expect(counts).toEqual({ errors: 1, instant: 1 })
|
|
})
|
|
|
|
const pill = await getIndicatorPillState(browser)
|
|
expect(pill?.hasError).toBe(true)
|
|
expect(pill?.insightsOnly).toBe(false)
|
|
expect(pill?.text).toMatch(/1\s*Issue\s*·\s*1\s*Insight/i)
|
|
|
|
expect(await getErrorOverlayTabs(browser)).toEqual([
|
|
{ variant: 'errors', disabled: false, active: true },
|
|
{ variant: 'instant', disabled: false, active: false },
|
|
])
|
|
|
|
await clickInsightsTab(browser)
|
|
await retry(async () => {
|
|
expect(await getActiveErrorOverlayTab(browser)).toBe('instant')
|
|
})
|
|
})
|
|
|
|
it('should clear the Insight when navigating away from the offending route', async () => {
|
|
const browser = await next.browser('/')
|
|
await browser.elementByCss('a[href="/insight-only"]').click()
|
|
|
|
await retry(async () => {
|
|
const state = await getIndicatorPillState(browser)
|
|
expect(state?.text).toMatch(/1\s*Insight/i)
|
|
})
|
|
|
|
await browser.eval('history.back()')
|
|
await browser.waitForElementByCss('a[href="/insight-only"]')
|
|
|
|
await retry(async () => {
|
|
const state = await getIndicatorPillState(browser)
|
|
expect(state).not.toBeNull()
|
|
expect(state!.hasError).toBe(false)
|
|
expect(state!.insightsOnly).toBe(false)
|
|
})
|
|
})
|
|
})
|