From babe50364e2c1d71890da1617546aa50d25610dd Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 18 May 2023 22:06:57 +0200 Subject: [PATCH] Reset not-found and error boundary when navigating (#49855) ## What? Currently the error boundary state does not reset when a navigation happens because the `key` does not change. Because of that navigating using next/link when the error boundary is in the open state ends up not rendering the contents of the new navigation, instead it keeps the error boundary contents. ## How? This PR uses a pattern that is recommended in the React docs (setState during render) to reset the error boundary if the `usePathname` value has changed, it is used as a proxy for a new navigation. Fixes #47862 Fixes NEXT-963 Fixes #49736 Fixes #49732 Fixes #49599 --- .../src/client/components/error-boundary.tsx | 42 +++++++++++++++-- .../client/components/not-found-boundary.tsx | 45 +++++++++++++++++-- .../app/error.tsx | 13 ++++++ .../app/layout.tsx | 7 +++ .../app/not-found.tsx | 12 +++++ .../app/page.tsx | 3 ++ .../app/result/page.tsx | 3 ++ .../app/trigger-404/page.tsx | 7 +++ .../app/trigger-error/page.tsx | 5 +++ ...ror-boundary-and-not-found-linking.test.ts | 39 ++++++++++++++++ .../next.config.js | 10 +++++ .../navigation/app/not-found/not-found.js | 12 +++-- .../navigation/app/not-found/result/page.js | 3 ++ .../app/not-found/servercomponent/page.js | 1 - .../app/not-found/with-link/page.js | 8 ++++ 15 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 test/e2e/app-dir/error-boundary-and-not-found-linking/app/error.tsx create mode 100644 test/e2e/app-dir/error-boundary-and-not-found-linking/app/layout.tsx create mode 100644 test/e2e/app-dir/error-boundary-and-not-found-linking/app/not-found.tsx create mode 100644 test/e2e/app-dir/error-boundary-and-not-found-linking/app/page.tsx create mode 100644 test/e2e/app-dir/error-boundary-and-not-found-linking/app/result/page.tsx create mode 100644 test/e2e/app-dir/error-boundary-and-not-found-linking/app/trigger-404/page.tsx create mode 100644 test/e2e/app-dir/error-boundary-and-not-found-linking/app/trigger-error/page.tsx create mode 100644 test/e2e/app-dir/error-boundary-and-not-found-linking/error-boundary-and-not-found-linking.test.ts create mode 100644 test/e2e/app-dir/error-boundary-and-not-found-linking/next.config.js create mode 100644 test/e2e/app-dir/navigation/app/not-found/result/page.js create mode 100644 test/e2e/app-dir/navigation/app/not-found/with-link/page.js diff --git a/packages/next/src/client/components/error-boundary.tsx b/packages/next/src/client/components/error-boundary.tsx index 02b6e0fb24e7..bc83c846ff74 100644 --- a/packages/next/src/client/components/error-boundary.tsx +++ b/packages/next/src/client/components/error-boundary.tsx @@ -1,6 +1,7 @@ 'use client' import React from 'react' +import { usePathname } from './navigation' const styles = { error: { @@ -36,19 +37,50 @@ export interface ErrorBoundaryProps { errorStyles?: React.ReactNode | undefined } +interface ErrorBoundaryHandlerProps extends ErrorBoundaryProps { + pathname: string +} + +interface ErrorBoundaryHandlerState { + error: Error | null + previousPathname: string +} + export class ErrorBoundaryHandler extends React.Component< - ErrorBoundaryProps, - { error: Error | null } + ErrorBoundaryHandlerProps, + ErrorBoundaryHandlerState > { - constructor(props: ErrorBoundaryProps) { + constructor(props: ErrorBoundaryHandlerProps) { super(props) - this.state = { error: null } + this.state = { error: null, previousPathname: this.props.pathname } } static getDerivedStateFromError(error: Error) { return { error } } + static getDerivedStateFromProps( + props: ErrorBoundaryHandlerProps, + state: ErrorBoundaryHandlerState + ): ErrorBoundaryHandlerState | null { + /** + * Handles reset of the error boundary when a navigation happens. + * Ensures the error boundary does not stay enabled when navigating to a new page. + * Approach of setState in render is safe as it checks the previous pathname and then overrides + * it as outlined in https://react.dev/reference/react/useState#storing-information-from-previous-renders + */ + if (props.pathname !== state.previousPathname && state.error) { + return { + error: null, + previousPathname: props.pathname, + } + } + return { + error: state.error, + previousPathname: props.pathname, + } + } + reset = () => { this.setState({ error: null }) } @@ -105,9 +137,11 @@ export function ErrorBoundary({ errorStyles, children, }: ErrorBoundaryProps & { children: React.ReactNode }): JSX.Element { + const pathname = usePathname() if (errorComponent) { return ( diff --git a/packages/next/src/client/components/not-found-boundary.tsx b/packages/next/src/client/components/not-found-boundary.tsx index 81126b2e3f9c..cf594deecba4 100644 --- a/packages/next/src/client/components/not-found-boundary.tsx +++ b/packages/next/src/client/components/not-found-boundary.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { usePathname } from './navigation' interface NotFoundBoundaryProps { notFound?: React.ReactNode @@ -7,13 +8,25 @@ interface NotFoundBoundaryProps { children: React.ReactNode } +interface NotFoundErrorBoundaryProps extends NotFoundBoundaryProps { + pathname: string +} + +interface NotFoundErrorBoundaryState { + notFoundTriggered: boolean + previousPathname: string +} + class NotFoundErrorBoundary extends React.Component< - NotFoundBoundaryProps, - { notFoundTriggered: boolean } + NotFoundErrorBoundaryProps, + NotFoundErrorBoundaryState > { - constructor(props: NotFoundBoundaryProps) { + constructor(props: NotFoundErrorBoundaryProps) { super(props) - this.state = { notFoundTriggered: !!props.asNotFound } + this.state = { + notFoundTriggered: !!props.asNotFound, + previousPathname: props.pathname, + } } static getDerivedStateFromError(error: any) { @@ -24,6 +37,28 @@ class NotFoundErrorBoundary extends React.Component< throw error } + static getDerivedStateFromProps( + props: NotFoundErrorBoundaryProps, + state: NotFoundErrorBoundaryState + ): NotFoundErrorBoundaryState | null { + /** + * Handles reset of the error boundary when a navigation happens. + * Ensures the error boundary does not stay enabled when navigating to a new page. + * Approach of setState in render is safe as it checks the previous pathname and then overrides + * it as outlined in https://react.dev/reference/react/useState#storing-information-from-previous-renders + */ + if (props.pathname !== state.previousPathname && state.notFoundTriggered) { + return { + notFoundTriggered: false, + previousPathname: props.pathname, + } + } + return { + notFoundTriggered: state.notFoundTriggered, + previousPathname: props.pathname, + } + } + render() { if (this.state.notFoundTriggered) { return ( @@ -45,8 +80,10 @@ export function NotFoundBoundary({ asNotFound, children, }: NotFoundBoundaryProps) { + const pathname = usePathname() return notFound ? ( +

Error Happened!

+ + To Result + + + ) +} diff --git a/test/e2e/app-dir/error-boundary-and-not-found-linking/app/layout.tsx b/test/e2e/app-dir/error-boundary-and-not-found-linking/app/layout.tsx new file mode 100644 index 000000000000..e7077399c03c --- /dev/null +++ b/test/e2e/app-dir/error-boundary-and-not-found-linking/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/error-boundary-and-not-found-linking/app/not-found.tsx b/test/e2e/app-dir/error-boundary-and-not-found-linking/app/not-found.tsx new file mode 100644 index 000000000000..50a433f5e443 --- /dev/null +++ b/test/e2e/app-dir/error-boundary-and-not-found-linking/app/not-found.tsx @@ -0,0 +1,12 @@ +import Link from 'next/link' + +export default function NotFound() { + return ( + <> +

Not Found!

+ + To Result + + + ) +} diff --git a/test/e2e/app-dir/error-boundary-and-not-found-linking/app/page.tsx b/test/e2e/app-dir/error-boundary-and-not-found-linking/app/page.tsx new file mode 100644 index 000000000000..c0c3e311375c --- /dev/null +++ b/test/e2e/app-dir/error-boundary-and-not-found-linking/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Home

+} diff --git a/test/e2e/app-dir/error-boundary-and-not-found-linking/app/result/page.tsx b/test/e2e/app-dir/error-boundary-and-not-found-linking/app/result/page.tsx new file mode 100644 index 000000000000..f99fc856f67a --- /dev/null +++ b/test/e2e/app-dir/error-boundary-and-not-found-linking/app/result/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Result Page!

+} diff --git a/test/e2e/app-dir/error-boundary-and-not-found-linking/app/trigger-404/page.tsx b/test/e2e/app-dir/error-boundary-and-not-found-linking/app/trigger-404/page.tsx new file mode 100644 index 000000000000..bdcc212a66a0 --- /dev/null +++ b/test/e2e/app-dir/error-boundary-and-not-found-linking/app/trigger-404/page.tsx @@ -0,0 +1,7 @@ +import { notFound } from 'next/navigation' + +export const dynamic = 'force-dynamic' + +export default function Page() { + notFound() +} diff --git a/test/e2e/app-dir/error-boundary-and-not-found-linking/app/trigger-error/page.tsx b/test/e2e/app-dir/error-boundary-and-not-found-linking/app/trigger-error/page.tsx new file mode 100644 index 000000000000..5fca91341f62 --- /dev/null +++ b/test/e2e/app-dir/error-boundary-and-not-found-linking/app/trigger-error/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic' + +export default function Page() { + throw new Error('This is an error') +} diff --git a/test/e2e/app-dir/error-boundary-and-not-found-linking/error-boundary-and-not-found-linking.test.ts b/test/e2e/app-dir/error-boundary-and-not-found-linking/error-boundary-and-not-found-linking.test.ts new file mode 100644 index 000000000000..3e602154f814 --- /dev/null +++ b/test/e2e/app-dir/error-boundary-and-not-found-linking/error-boundary-and-not-found-linking.test.ts @@ -0,0 +1,39 @@ +import { createNextDescribe } from 'e2e-utils' + +createNextDescribe( + 'not-found-linking', + { + files: __dirname, + }, + ({ next }) => { + it('should allow navigation on not-found', async () => { + const browser = await next.browser('/trigger-404') + expect(await browser.elementByCss('#not-found-component').text()).toBe( + 'Not Found!' + ) + + expect( + await browser + .elementByCss('#to-result') + .click() + .waitForElementByCss('#result-page') + .text() + ).toBe('Result Page!') + }) + + it('should allow navigation on error', async () => { + const browser = await next.browser('/trigger-error') + expect(await browser.elementByCss('#error-component').text()).toBe( + 'Error Happened!' + ) + + expect( + await browser + .elementByCss('#to-result') + .click() + .waitForElementByCss('#result-page') + .text() + ).toBe('Result Page!') + }) + } +) diff --git a/test/e2e/app-dir/error-boundary-and-not-found-linking/next.config.js b/test/e2e/app-dir/error-boundary-and-not-found-linking/next.config.js new file mode 100644 index 000000000000..3d24f6813557 --- /dev/null +++ b/test/e2e/app-dir/error-boundary-and-not-found-linking/next.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + typescript: { + ignoreBuildErrors: true, + }, +} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/navigation/app/not-found/not-found.js b/test/e2e/app-dir/navigation/app/not-found/not-found.js index eab732a10872..4aeeb9ba5d63 100644 --- a/test/e2e/app-dir/navigation/app/not-found/not-found.js +++ b/test/e2e/app-dir/navigation/app/not-found/not-found.js @@ -1,9 +1,15 @@ +import Link from 'next/link' import styles from './style.module.css' export default function NotFound() { return ( -

- Not Found! -

+ <> +

+ Not Found! +

+ + To Result + + ) } diff --git a/test/e2e/app-dir/navigation/app/not-found/result/page.js b/test/e2e/app-dir/navigation/app/not-found/result/page.js new file mode 100644 index 000000000000..f99fc856f67a --- /dev/null +++ b/test/e2e/app-dir/navigation/app/not-found/result/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return

Result Page!

+} diff --git a/test/e2e/app-dir/navigation/app/not-found/servercomponent/page.js b/test/e2e/app-dir/navigation/app/not-found/servercomponent/page.js index 180403e6e014..d3cf49459932 100644 --- a/test/e2e/app-dir/navigation/app/not-found/servercomponent/page.js +++ b/test/e2e/app-dir/navigation/app/not-found/servercomponent/page.js @@ -1,4 +1,3 @@ -// TODO-APP: enable when flight error serialization is implemented import { notFound } from 'next/navigation' export default function Page() { diff --git a/test/e2e/app-dir/navigation/app/not-found/with-link/page.js b/test/e2e/app-dir/navigation/app/not-found/with-link/page.js new file mode 100644 index 000000000000..fd2d09221238 --- /dev/null +++ b/test/e2e/app-dir/navigation/app/not-found/with-link/page.js @@ -0,0 +1,8 @@ +import { notFound } from 'next/navigation' + +export const dynamic = 'force-dynamic' + +export default function Page() { + notFound() + return <> +}