diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index aeecef5886908..804e648a82155 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -434,18 +434,14 @@ function Router({ return findHeadInCache(cache, tree[1]) }, [cache, tree]) + const notFoundProps = { notFound, notFoundStyles, asNotFound } + const content = ( - - - {head} - {cache.subTreeData} - - - + + {head} + {cache.subTreeData} + + ) return ( @@ -478,9 +474,14 @@ function Router({ }} > {HotReloader ? ( - {content} + // HotReloader implements a separate NotFoundBoundary to maintain the HMR ping interval + + {content} + ) : ( - content + + {content} + )} diff --git a/packages/next/src/client/components/react-dev-overlay/hot-reloader-client.tsx b/packages/next/src/client/components/react-dev-overlay/hot-reloader-client.tsx index 1c33313651a54..78ea9c886a3e6 100644 --- a/packages/next/src/client/components/react-dev-overlay/hot-reloader-client.tsx +++ b/packages/next/src/client/components/react-dev-overlay/hot-reloader-client.tsx @@ -10,6 +10,7 @@ import stripAnsi from 'next/dist/compiled/strip-ansi' import formatWebpackMessages from '../../dev/error-overlay/format-webpack-messages' import { useRouter } from '../navigation' import { + ACTION_NOT_FOUND, ACTION_VERSION_INFO, errorOverlayReducer, } from './internal/error-overlay-reducer' @@ -34,6 +35,8 @@ import { } from './internal/helpers/use-websocket' import { parseComponentStack } from './internal/helpers/parse-component-stack' import type { VersionInfo } from '../../../server/dev/parse-version-info' +import { isNotFoundError } from '../not-found' +import { NotFoundBoundary } from '../not-found-boundary' interface Dispatcher { onBuildOk(): void @@ -41,6 +44,7 @@ interface Dispatcher { onVersionInfo(versionInfo: VersionInfo): void onBeforeRefresh(): void onRefresh(): void + onNotFound(): void } // TODO-APP: add actual type @@ -414,8 +418,22 @@ function processMessage( if (invalid) { // Payload can be invalid even if the page does exist. // So, we check if it can be created. - // @ts-ignore it exists, it's just hidden - router.fastRefresh() + fetch(window.location.href, { + credentials: 'same-origin', + }).then((pageRes) => { + if (pageRes.status === 200) { + // Page exists now, reload + startTransition(() => { + // @ts-ignore it exists, it's just hidden + router.fastRefresh() + dispatcher.onRefresh() + }) + } else { + // We are still on the page, + // dispatch an error so it's caught by the NotFound handler + dispatcher.onNotFound() + } + }) } return } @@ -428,14 +446,21 @@ function processMessage( export default function HotReload({ assetPrefix, children, + notFound, + notFoundStyles, + asNotFound, }: { assetPrefix: string children?: ReactNode + notFound?: React.ReactNode + notFoundStyles?: React.ReactNode + asNotFound?: boolean }) { const [state, dispatch] = useReducer(errorOverlayReducer, { nextId: 1, buildError: null, errors: [], + notFound: false, refreshState: { type: 'idle' }, versionInfo: { installed: '0.0.0', staleness: 'unknown' }, }) @@ -456,6 +481,9 @@ export default function HotReload({ onVersionInfo(versionInfo) { dispatch({ type: ACTION_VERSION_INFO, versionInfo }) }, + onNotFound() { + dispatch({ type: ACTION_NOT_FOUND }) + }, } }, [dispatch]) @@ -477,7 +505,9 @@ export default function HotReload({ frames: parseStack(reason.stack!), }) }, []) - const handleOnReactError = useCallback(() => { + const handleOnReactError = useCallback((error: Error) => { + // not found errors are handled by the parent boundary, not the dev overlay + if (isNotFoundError(error)) throw error RuntimeErrorHandler.hadRuntimeError = true }, []) useErrorHandler(handleOnUnhandledError, handleOnUnhandledRejection) @@ -487,6 +517,7 @@ export default function HotReload({ const sendMessage = useSendMessage(webSocketRef) const router = useRouter() + useEffect(() => { const handler = (event: MessageEvent) => { try { @@ -507,8 +538,15 @@ export default function HotReload({ }, [sendMessage, router, webSocketRef, dispatcher]) return ( - - {children} - + + + {children} + + ) } diff --git a/packages/next/src/client/components/react-dev-overlay/internal/ReactDevOverlay.tsx b/packages/next/src/client/components/react-dev-overlay/internal/ReactDevOverlay.tsx index 715e3fd1b9571..5b88f1b03c00f 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/ReactDevOverlay.tsx +++ b/packages/next/src/client/components/react-dev-overlay/internal/ReactDevOverlay.tsx @@ -13,6 +13,7 @@ import { parseStack } from './helpers/parseStack' import { Base } from './styles/Base' import { ComponentStyles } from './styles/ComponentStyles' import { CssReset } from './styles/CssReset' +import { notFound } from '../../not-found' interface ReactDevOverlayState { reactError: SupportedErrorEvent | null @@ -58,6 +59,10 @@ class ReactDevOverlay extends React.PureComponent< reactError || rootLayoutMissingTagsError + if (state.notFound) { + notFound() + } + return ( <> {reactError ? ( diff --git a/packages/next/src/client/components/react-dev-overlay/internal/error-overlay-reducer.ts b/packages/next/src/client/components/react-dev-overlay/internal/error-overlay-reducer.ts index 3eed9936b60ac..b6f9c69d0c0c6 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/error-overlay-reducer.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/error-overlay-reducer.ts @@ -10,6 +10,7 @@ export const ACTION_REFRESH = 'fast-refresh' export const ACTION_UNHANDLED_ERROR = 'unhandled-error' export const ACTION_UNHANDLED_REJECTION = 'unhandled-rejection' export const ACTION_VERSION_INFO = 'version-info' +export const ACTION_NOT_FOUND = 'not-found' interface BuildOkAction { type: typeof ACTION_BUILD_OK @@ -24,6 +25,11 @@ interface BeforeFastRefreshAction { interface FastRefreshAction { type: typeof ACTION_REFRESH } + +interface NotFoundAction { + type: typeof ACTION_NOT_FOUND +} + export interface UnhandledErrorAction { type: typeof ACTION_UNHANDLED_ERROR reason: Error @@ -59,6 +65,7 @@ export interface OverlayState { } refreshState: FastRefreshState versionInfo: VersionInfo + notFound: boolean } function pushErrorFilterDuplicates( @@ -81,6 +88,7 @@ export const errorOverlayReducer: React.Reducer< | BuildErrorAction | BeforeFastRefreshAction | FastRefreshAction + | NotFoundAction | UnhandledErrorAction | UnhandledRejectionAction | VersionInfoAction @@ -88,7 +96,7 @@ export const errorOverlayReducer: React.Reducer< > = (state, action) => { switch (action.type) { case ACTION_BUILD_OK: { - return { ...state, buildError: null } + return { ...state, buildError: null, notFound: false } } case ACTION_BUILD_ERROR: { return { ...state, buildError: action.message } @@ -96,10 +104,14 @@ export const errorOverlayReducer: React.Reducer< case ACTION_BEFORE_REFRESH: { return { ...state, refreshState: { type: 'pending', errors: [] } } } + case ACTION_NOT_FOUND: { + return { ...state, notFound: true } + } case ACTION_REFRESH: { return { ...state, buildError: null, + notFound: false, errors: // Errors can come in during updates. In this case, UNHANDLED_ERROR // and UNHANDLED_REJECTION events might be dispatched between the diff --git a/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-websocket.ts b/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-websocket.ts index 0b1dc95687870..211916215618d 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-websocket.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/helpers/use-websocket.ts @@ -50,7 +50,6 @@ export function useWebsocketPing( useEffect(() => { // Taken from on-demand-entries-client.js - // TODO-APP: check 404 case const interval = setInterval(() => { sendMessage( JSON.stringify({ diff --git a/test/e2e/app-dir/not-found/app/not-found.js b/test/e2e/app-dir/not-found/app/not-found.js index 6adda9a790961..6f61e265196c2 100644 --- a/test/e2e/app-dir/not-found/app/not-found.js +++ b/test/e2e/app-dir/not-found/app/not-found.js @@ -1,3 +1,9 @@ export default function Page() { - return

This Is The Not Found Page

+ return ( + <> +

This Is The Not Found Page

+ +
{Date.now()}
+ + ) } diff --git a/test/e2e/app-dir/not-found/not-found.test.ts b/test/e2e/app-dir/not-found/not-found.test.ts index 07a43f8a1ac29..49df6a937924e 100644 --- a/test/e2e/app-dir/not-found/not-found.test.ts +++ b/test/e2e/app-dir/not-found/not-found.test.ts @@ -1,4 +1,5 @@ import { createNextDescribe } from 'e2e-utils' +import { check } from 'next-test-utils' createNextDescribe( 'app dir - not-found', @@ -18,6 +19,34 @@ createNextDescribe( expect(html).toContain("I'm still a valid page") }) + if (isNextDev) { + it('should not reload the page', async () => { + const browser = await next.browser('/random-content') + const timestamp = await browser.elementByCss('#timestamp').text() + + await new Promise((resolve) => { + setTimeout(resolve, 3000) + }) + + await check(async () => { + const newTimestamp = await browser.elementByCss('#timestamp').text() + return newTimestamp !== timestamp ? 'failure' : 'success' + }, 'success') + }) + + it('should render the 404 page when the file is removed, and restore the page when re-added', async () => { + const browser = await next.browser('/') + await check(() => browser.elementByCss('h1').text(), 'My page') + await next.renameFile('./app/page.js', './app/foo.js') + await check( + () => browser.elementByCss('h1').text(), + 'This Is The Not Found Page' + ) + await next.renameFile('./app/foo.js', './app/page.js') + await check(() => browser.elementByCss('h1').text(), 'My page') + }) + } + if (!isNextDev) { it('should create the 404 mapping and copy the file to pages', async () => { const html = await next.readFile('.next/server/pages/404.html')