From 98cb2547cd0b6e160a220424396d04e408848c8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Born=C3=B6?= Date: Thu, 8 Dec 2022 12:30:27 +0100 Subject: [PATCH] Unhandled errors and rejections opens as minimized in app dir error overlay (#43844) Updated version of the reverted https://github.com/vercel/next.js/pull/43511 Unhandled errors that did not occur during React rendering (those errors are caught in `getDerivedStateFromError` in the Error Overlay) should be opened in the minimized toast state instead of fullscreen. For example if they occur in event handlers or setTimeout. Errors that breaks the app, such as uncaught render errors or build errors, still opens up in fullscreen mode. The added test make sure the errors opens up as minimized, but if there's a breaking error it should "win" and open up in fullscreen. The updated tests either throw errors inside an event handler or a setTimeout, or the error is handled in a custom error boundary - which means the app don't break. Closes NEXT-128 ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md) ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] [e2e](https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs) tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md) ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm build && pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- .../internal/ReactDevOverlay.tsx | 4 +- .../internal/container/Errors.tsx | 17 ++-- .../acceptance-app/ReactRefreshLogBox.test.ts | 90 ++++++++++++++++--- test/development/acceptance-app/helpers.ts | 3 + 4 files changed, 96 insertions(+), 18 deletions(-) diff --git a/packages/next/client/components/react-dev-overlay/internal/ReactDevOverlay.tsx b/packages/next/client/components/react-dev-overlay/internal/ReactDevOverlay.tsx index ee85548be45103f..931a1c93ca74517 100644 --- a/packages/next/client/components/react-dev-overlay/internal/ReactDevOverlay.tsx +++ b/packages/next/client/components/react-dev-overlay/internal/ReactDevOverlay.tsx @@ -76,9 +76,9 @@ class ReactDevOverlay extends React.PureComponent< ) : hasBuildError ? ( ) : hasRuntimeErrors ? ( - + ) : reactError ? ( - + ) : undefined} ) : undefined} diff --git a/packages/next/client/components/react-dev-overlay/internal/container/Errors.tsx b/packages/next/client/components/react-dev-overlay/internal/container/Errors.tsx index 84d83cd46f58c65..22d0c90777f4043 100644 --- a/packages/next/client/components/react-dev-overlay/internal/container/Errors.tsx +++ b/packages/next/client/components/react-dev-overlay/internal/container/Errors.tsx @@ -24,10 +24,15 @@ export type SupportedErrorEvent = { id: number event: UnhandledErrorAction | UnhandledRejectionAction } -export type ErrorsProps = { errors: SupportedErrorEvent[] } +export type ErrorsProps = { + errors: SupportedErrorEvent[] + initialDisplayState: DisplayState +} type ReadyErrorEvent = ReadyRuntimeError +type DisplayState = 'minimized' | 'fullscreen' | 'hidden' + function getErrorSignature(ev: SupportedErrorEvent): string { const { event } = ev switch (event.type) { @@ -73,7 +78,10 @@ const HotlinkedText: React.FC<{ ) } -export const Errors: React.FC = function Errors({ errors }) { +export const Errors: React.FC = function Errors({ + errors, + initialDisplayState, +}) { const [lookups, setLookups] = React.useState( {} as { [eventId: string]: ReadyErrorEvent } ) @@ -137,9 +145,8 @@ export const Errors: React.FC = function Errors({ errors }) { } }, [nextError]) - const [displayState, setDisplayState] = React.useState< - 'minimized' | 'fullscreen' | 'hidden' - >('fullscreen') + const [displayState, setDisplayState] = + React.useState(initialDisplayState) const [activeIdx, setActiveIndex] = React.useState(0) const previous = React.useCallback((e?: MouseEvent | TouchEvent) => { e?.preventDefault() diff --git a/test/development/acceptance-app/ReactRefreshLogBox.test.ts b/test/development/acceptance-app/ReactRefreshLogBox.test.ts index 3a372d42d427698..6e73c94dfa900eb 100644 --- a/test/development/acceptance-app/ReactRefreshLogBox.test.ts +++ b/test/development/acceptance-app/ReactRefreshLogBox.test.ts @@ -49,7 +49,7 @@ describe('ReactRefreshLogBox app', () => { ) await session.evaluate(() => document.querySelector('a').click()) - expect(await session.hasRedbox(true)).toBe(true) + await session.waitForAndOpenRuntimeError() expect(await session.getRedboxSource()).toMatchSnapshot() await cleanup() @@ -481,7 +481,7 @@ describe('ReactRefreshLogBox app', () => { ) await new Promise((resolve) => setTimeout(resolve, 1000)) - expect(await session.hasRedbox(true)).toBe(true) + await session.waitForAndOpenRuntimeError() if (process.platform === 'win32') { expect(await session.getRedboxSource()).toMatchSnapshot() } else { @@ -568,7 +568,7 @@ describe('ReactRefreshLogBox app', () => { `export default function FunctionDefault() { throw new Error('no'); }` ) - expect(await session.hasRedbox(true)).toBe(true) + await session.waitForAndOpenRuntimeError() expect(await session.getRedboxSource()).toMatchSnapshot() expect( await session.evaluate(() => document.querySelector('h2').textContent) @@ -770,9 +770,8 @@ describe('ReactRefreshLogBox app', () => { ` ) - expect(await session.hasRedbox()).toBe(false) await session.evaluate(() => document.querySelector('button').click()) - expect(await session.hasRedbox(true)).toBe(true) + await session.waitForAndOpenRuntimeError() const header = await session.getRedboxDescription() expect(header).toMatchSnapshot() @@ -816,9 +815,8 @@ describe('ReactRefreshLogBox app', () => { ` ) - expect(await session.hasRedbox()).toBe(false) await session.evaluate(() => document.querySelector('button').click()) - expect(await session.hasRedbox(true)).toBe(true) + await session.waitForAndOpenRuntimeError() const header2 = await session.getRedboxDescription() expect(header2).toMatchSnapshot() @@ -862,9 +860,8 @@ describe('ReactRefreshLogBox app', () => { ` ) - expect(await session.hasRedbox()).toBe(false) await session.evaluate(() => document.querySelector('button').click()) - expect(await session.hasRedbox(true)).toBe(true) + await session.waitForAndOpenRuntimeError() const header3 = await session.getRedboxDescription() expect(header3).toMatchSnapshot() @@ -908,9 +905,8 @@ describe('ReactRefreshLogBox app', () => { ` ) - expect(await session.hasRedbox()).toBe(false) await session.evaluate(() => document.querySelector('button').click()) - expect(await session.hasRedbox(true)).toBe(true) + await session.waitForAndOpenRuntimeError() const header4 = await session.getRedboxDescription() expect(header4).toMatchInlineSnapshot( @@ -1085,4 +1081,76 @@ describe('ReactRefreshLogBox app', () => { await cleanup() }) + + test('Unhandled errors and rejections opens up in the minimized state', async () => { + const { session, browser, cleanup } = await sandbox(next) + + const file = ` + export default function Index() { + // + setTimeout(() => { + throw new Error('Unhandled error') + }, 0) + setTimeout(() => { + Promise.reject(new Error('Undhandled rejection')) + }, 0) + return ( + <> + + + + ) + } + ` + + await session.patch('index.js', file) + + // Unhandled error and rejection in setTimeout + expect( + await browser.waitForElementByCss('.nextjs-toast-errors').text() + ).toBe('2 errors') + + // Unhandled error in event handler + await browser.elementById('unhandled-error').click() + await check( + () => browser.elementByCss('.nextjs-toast-errors').text(), + /3 errors/ + ) + + // Unhandled rejection in event handler + await browser.elementById('unhandled-rejection').click() + await check( + () => browser.elementByCss('.nextjs-toast-errors').text(), + /4 errors/ + ) + expect(await session.hasRedbox()).toBe(false) + + // Add Component error + await session.patch( + 'index.js', + file.replace( + '//', + "if (typeof window !== 'undefined') throw new Error('Component error')" + ) + ) + + // Render error should "win" and show up in fullscreen + expect(await session.hasRedbox(true)).toBe(true) + + await cleanup() + }) }) diff --git a/test/development/acceptance-app/helpers.ts b/test/development/acceptance-app/helpers.ts index f0787fb84b76dad..c97d311cb6bc74f 100644 --- a/test/development/acceptance-app/helpers.ts +++ b/test/development/acceptance-app/helpers.ts @@ -112,6 +112,9 @@ export async function sandbox( } return source }, + async waitForAndOpenRuntimeError() { + return browser.waitForElementByCss('[data-nextjs-toast]').click() + }, }, async cleanup() { await browser.close()