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')