Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[error overlay] display different error info individually #75916

Merged
merged 3 commits into from
Feb 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
getDefaultHydrationErrorMessage,
isHydrationError,
testReactHydrationWarning,
} from '../is-hydration-error'
import {
Expand All @@ -13,24 +14,42 @@ export function attachHydrationErrorState(error: Error) {
)
let parsedHydrationErrorState: typeof hydrationErrorState = {}
const isHydrationWarning = testReactHydrationWarning(error.message)
const isHydrationRuntimeError = isHydrationError(error)
// If the reactHydrationDiffSegments exists
// and the diff (reactHydrationDiffSegments[1]) exists
// e.g. the hydration diff log error.
if (reactHydrationDiffSegments && reactHydrationDiffSegments[1]) {
if (reactHydrationDiffSegments) {
const diff = reactHydrationDiffSegments[1]
parsedHydrationErrorState = {
...(error as any).details,
...hydrationErrorState,
warning: hydrationErrorState.warning || [
getDefaultHydrationErrorMessage(),
],
// If diff is present in error, we don't need to pick up the console logged warning.
// - if hydration error has diff, and is not hydration diff log, then it's a normal hydration error.
// - if hydration error no diff, then leverage the one from the hydration diff log.

warning: (diff && !isHydrationWarning
? null
: hydrationErrorState.warning) || [getDefaultHydrationErrorMessage()],
// When it's hydration diff log, do not show notes section.
// This condition is only for the 1st squashed error.
notes: isHydrationWarning ? '' : reactHydrationDiffSegments[0],
reactOutputComponentDiff: reactHydrationDiffSegments[1],
reactOutputComponentDiff: diff,
}
// Cache the `reactOutputComponentDiff` into hydrationErrorState.
// This is only required for now when we still squashed the hydration diff log into hydration error.
// Once the all error is logged to dev overlay in order, this will go away.
hydrationErrorState.reactOutputComponentDiff =
parsedHydrationErrorState.reactOutputComponentDiff
if (!hydrationErrorState.reactOutputComponentDiff && diff) {
hydrationErrorState.reactOutputComponentDiff = diff
}
// If it's hydration runtime error that doesn't contain the diff, combine the diff from the cached hydration diff.
if (
!diff &&
isHydrationRuntimeError &&
hydrationErrorState.reactOutputComponentDiff
) {
parsedHydrationErrorState.reactOutputComponentDiff =
hydrationErrorState.reactOutputComponentDiff
}
} else {
// Normal runtime error, where it doesn't contain the hydration diff.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export function ErrorOverlayPagination({
disabled={activeIdx === 0}
aria-disabled={activeIdx === 0}
onClick={handlePrevious}
data-nextjs-dialog-error-previous
className="error-overlay-pagination-button"
>
<LeftArrow
Expand All @@ -126,7 +127,7 @@ export function ErrorOverlayPagination({
/>
</button>
<div className="error-overlay-pagination-count">
<span>{activeIdx + 1}/</span>
<span data-nextjs-dialog-error-index={activeIdx}>{activeIdx + 1}/</span>
<span data-nextjs-dialog-header-total-count>
{/* Display 1 out of 1 if there are no errors (e.g. for build errors). */}
{readyErrors.length || 1}
Expand All @@ -139,6 +140,7 @@ export function ErrorOverlayPagination({
disabled={activeIdx >= readyErrors.length - 1}
aria-disabled={activeIdx >= readyErrors.length - 1}
onClick={handleNext}
data-nextjs-dialog-error-next
className="error-overlay-pagination-button"
>
<RightArrow
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ const LeftRightDialogHeader: React.FC<LeftRightDialogHeaderProps> =
<button
ref={buttonLeft}
type="button"
data-nextjs-dialog-error-previous
disabled={previous == null ? true : undefined}
aria-disabled={previous == null ? true : undefined}
onClick={previous ?? undefined}
Expand All @@ -128,6 +129,7 @@ const LeftRightDialogHeader: React.FC<LeftRightDialogHeaderProps> =
<button
ref={buttonRight}
type="button"
data-nextjs-dialog-error-next
disabled={next == null ? true : undefined}
aria-disabled={next == null ? true : undefined}
onClick={next ?? undefined}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,10 @@ export function Errors({
close={isServerError ? undefined : minimize}
>
<small>
<span>{activeIdx + 1}</span> of{' '}
<span data-nextjs-dialog-error-index={activeIdx}>
{activeIdx + 1}
</span>{' '}
of{' '}
<span data-nextjs-dialog-header-total-count>
{readyErrors.length}
</span>
Expand Down
7 changes: 3 additions & 4 deletions test/development/acceptance-app/hydration-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,10 +443,9 @@ describe('Error overlay for hydration errors in App router', () => {
})

// FIXME: Should also have "text nodes cannot be a child of tr"
expect(await session.getRedboxDescription()).toMatchInlineSnapshot(`
"In HTML, text nodes cannot be a child of <tr>.
This will cause a hydration error."
`)
expect(await session.getRedboxDescription()).toMatchInlineSnapshot(
`"Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:"`
)

const pseudoHtml = await session.getRedboxComponentStack()
expect(pseudoHtml).toMatchInlineSnapshot(`
Expand Down
15 changes: 7 additions & 8 deletions test/development/acceptance/hydration-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,10 +430,10 @@ describe('Error overlay for hydration errors in Pages router', () => {
`"Expected server HTML to contain a matching <table> in <div>."`
)
} else {
expect(await session.getRedboxDescription()).toMatchInlineSnapshot(`
"In HTML, text nodes cannot be a child of <tr>.
This will cause a hydration error."
`)
// FIXME: should show "Expected server HTML to contain a matching <table> in <div>." first
expect(await session.getRedboxDescription()).toMatchInlineSnapshot(
`"Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:"`
)
}

const pseudoHtml = await session.getRedboxComponentStack()
Expand Down Expand Up @@ -497,10 +497,9 @@ describe('Error overlay for hydration errors in Pages router', () => {

expect(await getRedboxTotalErrorCount(browser)).toBe(3)
} else {
expect(await session.getRedboxDescription()).toMatchInlineSnapshot(`
"In HTML, text nodes cannot be a child of <table>.
This will cause a hydration error."
`)
expect(await session.getRedboxDescription()).toMatchInlineSnapshot(
`"Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:"`
)

const pseudoHtml = await session.getRedboxComponentStack()
if (isTurbopack) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client'

// This page has two hydrations issues:
// - bad nested tags
// - server client mismatch
export default function Page() {
const clx = typeof window === 'undefined' ? 'server' : 'client'
return (
<p className={clx}>
<p>nest</p>
</p>
)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { nextTestSetup } from 'e2e-utils'
import { hasErrorToast, getToastErrorCount, retry } from 'next-test-utils'
import {
hasErrorToast,
getToastErrorCount,
retry,
openRedbox,
getRedboxDescription,
getRedboxComponentStack,
goToNextErrorView,
getRedboxDescriptionWarning,
} from 'next-test-utils'
import { BrowserInterface } from 'next-webdriver'

describe('hydration-error-count', () => {
const { next } = nextTestSetup({
Expand Down Expand Up @@ -30,4 +40,87 @@ describe('hydration-error-count', () => {
expect(totalErrorCount).toBe(1)
})
})

it('should display correct hydration info in each hydration error view', async () => {
const browser = await next.browser('/two-issues')

await openRedbox(browser)

const firstError = await getHydrationErrorSnapshot(browser)
await goToNextErrorView(browser)
const secondError = await getHydrationErrorSnapshot(browser)

// Hydration runtime error
// - contains a diff
// - has notes
expect(firstError).toMatchInlineSnapshot(`
{
"description": "Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:",
"diff": "...
<OuterLayoutRouter parallelRouterKey="children" template={<RenderFromTemplateContext>}>
<RenderFromTemplateContext>
<ScrollAndFocusHandler segmentPath={[...]}>
<InnerScrollAndFocusHandler segmentPath={[...]} focusAndScrollRef={{apply:false, ...}}>
<ErrorBoundary errorComponent={undefined} errorStyles={undefined} errorScripts={undefined}>
<LoadingBoundary loading={null}>
<HTTPAccessFallbackBoundary notFound={undefined} forbidden={undefined} unauthorized={undefined}>
<RedirectBoundary>
<RedirectErrorBoundary router={{...}}>
<InnerLayoutRouter url="/two-issues" tree={[...]} cacheNode={{lazyData:null, ...}} ...>
<ClientPageRoot Component={function Page} searchParams={{}} params={{}}>
<Page params={Promise} searchParams={Promise}>
<p
+ className="client"
- className="server"
>
...",
"notes": "- A server/client branch \`if (typeof window !== 'undefined')\`.
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.

It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.",
}
`)

// Hydration console.error
// - contains a diff
// - no notes
expect(secondError).toMatchInlineSnapshot(`
{
"description": "In HTML, <p> cannot be a descendant of <p>.
This will cause a hydration error.",
"diff": "...
<OuterLayoutRouter parallelRouterKey="children" template={<RenderFromTemplateContext>}>
<RenderFromTemplateContext>
<ScrollAndFocusHandler segmentPath={[...]}>
<InnerScrollAndFocusHandler segmentPath={[...]} focusAndScrollRef={{apply:false, ...}}>
<ErrorBoundary errorComponent={undefined} errorStyles={undefined} errorScripts={undefined}>
<LoadingBoundary loading={null}>
<HTTPAccessFallbackBoundary notFound={undefined} forbidden={undefined} unauthorized={undefined}>
<RedirectBoundary>
<RedirectErrorBoundary router={{...}}>
<InnerLayoutRouter url="/two-issues" tree={[...]} cacheNode={{lazyData:null, ...}} ...>
<ClientPageRoot Component={function Page} searchParams={{}} params={{}}>
<Page params={Promise} searchParams={Promise}>
> <p className="client">
> <p>
...",
"notes": undefined,
}
`)
})
})

async function getHydrationErrorSnapshot(browser: BrowserInterface) {
const description = await getRedboxDescription(browser)
const notes = await getRedboxDescriptionWarning(browser)
const diff = await getRedboxComponentStack(browser)

return {
description,
notes,
diff,
}
}
21 changes: 21 additions & 0 deletions test/lib/next-test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,27 @@ export async function openRedbox(browser: BrowserInterface): Promise<void> {
await assertHasRedbox(browser)
}

export async function goToNextErrorView(
browser: BrowserInterface
): Promise<void> {
try {
const currentErrorIndex = await browser
.elementByCss('[data-nextjs-dialog-error-index]')
.text()
await browser.elementByCss('[data-nextjs-dialog-error-next]').click()
await retry(async () => {
const nextErrorIndex = await browser
.elementByCss('[data-nextjs-dialog-error-index]')
.text()
expect(nextErrorIndex).not.toBe(currentErrorIndex)
})
} catch (cause) {
const error = new Error('No Redbox to open.', { cause })
Error.captureStackTrace(error, openRedbox)
throw error
}
}

export async function openDevToolsIndicatorPopover(
browser: BrowserInterface
): Promise<void> {
Expand Down
Loading