diff --git a/packages/nuxt/src/pages/runtime/validate.ts b/packages/nuxt/src/pages/runtime/validate.ts index d4dfb029ab..7a2dcb564e 100644 --- a/packages/nuxt/src/pages/runtime/validate.ts +++ b/packages/nuxt/src/pages/runtime/validate.ts @@ -1,11 +1,38 @@ -import { defineNuxtRouteMiddleware } from '#app/composables/router' +import { createError, showError } from '#app/composables/error' +import { callWithNuxt, useNuxtApp } from '#app/nuxt' +import { defineNuxtRouteMiddleware, useRouter } from '#app/composables/router' export default defineNuxtRouteMiddleware(async (to) => { if (!to.meta?.validate) { return } + const nuxtApp = useNuxtApp() + const router = useRouter() + const result = await Promise.resolve(to.meta.validate(to)) if (result === true) { return } - return result + if (process.server) { + return result + } + const error = createError({ + statusCode: 404, + statusMessage: `Page Not Found: ${to.fullPath}` + }) + const unsub = router.beforeResolve((final) => { + unsub() + if (final === to) { + const unsub = router.afterEach(async () => { + unsub() + await callWithNuxt(nuxtApp, showError, [error]) + // We pretend to have navigated to the invalid route so + // that the user can return to the previous page with + // the back button. + window.history.pushState({}, '', to.fullPath) + }) + // We stop the navigation immediately before it resolves + // if there is no other route matching it. + return false + } + }) }) diff --git a/test/basic.test.ts b/test/basic.test.ts index 0c90ff43c3..740ad15e3a 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -98,6 +98,16 @@ describe('pages', () => { it('validates routes', async () => { const { status } = await fetch('/forbidden') expect(status).toEqual(404) + + const page = await createPage('/navigate-to-forbidden') + await page.waitForLoadState('networkidle') + await page.getByText('should throw a 404 error').click() + expect(await page.getByRole('heading').textContent()).toMatchInlineSnapshot('"Page Not Found: /forbidden"') + + page.goto(url('/navigate-to-forbidden')) + await page.waitForLoadState('networkidle') + await page.getByText('should be caught by catchall').click() + expect(await page.getByRole('heading').textContent()).toMatchInlineSnapshot('"[...slug].vue"') }) it('render 404', async () => { @@ -107,7 +117,7 @@ describe('pages', () => { // expect(html).toMatchInlineSnapshot() expect(html).toContain('[...slug].vue') - expect(html).toContain('404 at not-found') + expect(html).toContain('catchall at not-found') // Middleware still runs after validation: https://github.com/nuxt/nuxt/issues/15650 expect(html).toContain('Middleware ran: true') @@ -941,7 +951,7 @@ describe.runIf(isDev() && !isWebpack)('vite plugins', () => { expect(await $fetch('/__nuxt-test')).toBe('vite-plugin with __nuxt prefix') }) it('does not allow direct access to nuxt source folder', async () => { - expect(await $fetch('/app.config')).toContain('404') + expect(await $fetch('/app.config')).toContain('catchall at') }) }) diff --git a/test/fixtures/basic/error.vue b/test/fixtures/basic/error.vue new file mode 100644 index 0000000000..7344c5e3b6 --- /dev/null +++ b/test/fixtures/basic/error.vue @@ -0,0 +1,15 @@ + + + diff --git a/test/fixtures/basic/pages/[...slug].vue b/test/fixtures/basic/pages/[...slug].vue index 8ed645e31a..ba645f0969 100644 --- a/test/fixtures/basic/pages/[...slug].vue +++ b/test/fixtures/basic/pages/[...slug].vue @@ -1,7 +1,7 @@ diff --git a/test/fixtures/basic/pages/navigate-to-forbidden.vue b/test/fixtures/basic/pages/navigate-to-forbidden.vue new file mode 100644 index 0000000000..479116242b --- /dev/null +++ b/test/fixtures/basic/pages/navigate-to-forbidden.vue @@ -0,0 +1,18 @@ + + + diff --git a/test/hmr.test.ts b/test/hmr.test.ts index 498e531180..184975d280 100644 --- a/test/hmr.test.ts +++ b/test/hmr.test.ts @@ -70,7 +70,7 @@ if (process.env.TEST_ENV !== 'built' && !isWindows) { it('should detect new routes', async () => { const html = await $fetch('/some-404') - expect(html).toContain('404 at some-404') + expect(html).toContain('catchall at some-404') // write new page route const indexVue = await fsp.readFile(join(fixturePath, 'pages/index.vue'), 'utf8')