From a28e00c0daa76242058b958cc1fc5fd99aeae2dc Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 26 Sep 2024 17:07:46 +0100 Subject: [PATCH] fix(nuxt): use `routeRules` to hint pages to prerender (#29172) --- packages/nuxt/src/core/templates.ts | 2 ++ packages/nuxt/src/pages/module.ts | 18 +++++++++++++-- .../pages/runtime/plugins/prerender.server.ts | 23 ++++++++++++++++--- test/basic.test.ts | 5 ++++ test/fixtures/basic/nuxt.config.ts | 1 + test/fixtures/basic/pages/prerender/test.vue | 9 ++++++++ 6 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 test/fixtures/basic/pages/prerender/test.vue diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts index e1ce502825..5a35c33927 100644 --- a/packages/nuxt/src/core/templates.ts +++ b/packages/nuxt/src/core/templates.ts @@ -8,6 +8,7 @@ import { hash } from 'ohash' import { camelCase } from 'scule' import { filename } from 'pathe/utils' import type { NuxtTemplate, NuxtTypeTemplate } from 'nuxt/schema' +import type { Nitro } from 'nitropack' import { annotatePlugins, checkForCircularDependencies } from './app' @@ -512,6 +513,7 @@ export const nuxtConfigTemplate: NuxtTemplate = { `export const outdatedBuildInterval = ${ctx.nuxt.options.experimental.checkOutdatedBuildInterval}`, `export const multiApp = ${!!ctx.nuxt.options.future.multiApp}`, `export const chunkErrorEvent = ${ctx.nuxt.options.experimental.emitRouteChunkError ? ctx.nuxt.options.builder === '@nuxt/vite-builder' ? '"vite:preloadError"' : '"nuxt:preloadError"' : 'false'}`, + `export const crawlLinks = ${!!((ctx.nuxt as any)._nitro as Nitro).options.prerender.crawlLinks}`, ].join('\n\n') }, } diff --git a/packages/nuxt/src/pages/module.ts b/packages/nuxt/src/pages/module.ts index 20ec12d582..568d32ed1d 100644 --- a/packages/nuxt/src/pages/module.ts +++ b/packages/nuxt/src/pages/module.ts @@ -8,6 +8,7 @@ import type { Nuxt, NuxtApp, NuxtPage } from 'nuxt/schema' import { createRoutesContext } from 'unplugin-vue-router' import { resolveOptions } from 'unplugin-vue-router/options' import type { EditableTreeNode, Options as TypedRouterOptions } from 'unplugin-vue-router' +import { createRouter as createRadixRouter, toRouteMatcher } from 'radix3' import type { NitroRouteConfig } from 'nitropack' import { defu } from 'defu' @@ -272,7 +273,7 @@ export default defineNuxtModule({ nuxt.hook('app:resolve', (app) => { const nitro = useNitro() - if (nitro.options.prerender.crawlLinks) { + if (nitro.options.prerender.crawlLinks || Object.values(nitro.options.routeRules).some(rule => rule.prerender)) { app.plugins.push({ src: resolve(runtimeDir, 'plugins/prerender.server'), mode: 'server', @@ -310,7 +311,20 @@ export default defineNuxtModule({ }) nuxt.hook('nitro:build:before', (nitro) => { - if (nuxt.options.dev || !nitro.options.static || nuxt.options.router.options.hashMode || !nitro.options.prerender.crawlLinks) { return } + if (nuxt.options.dev || nuxt.options.router.options.hashMode) { return } + + // Inject page patterns that explicitly match `prerender: true` route rule + if (!nitro.options.static && !nitro.options.prerender.crawlLinks) { + const routeRulesMatcher = toRouteMatcher(createRadixRouter({ routes: nitro.options.routeRules })) + for (const route of prerenderRoutes) { + const rules = defu({} as Record, ...routeRulesMatcher.matchAll(route).reverse()) + if (rules.prerender) { + nitro.options.prerender.routes.push(route) + } + } + } + + if (!nitro.options.static || !nitro.options.prerender.crawlLinks) { return } // Only hint the first route when `ssr: true` and no routes are provided // as the rest will be injected at runtime when this is prerendered diff --git a/packages/nuxt/src/pages/runtime/plugins/prerender.server.ts b/packages/nuxt/src/pages/runtime/plugins/prerender.server.ts index 9a191f9dce..3b880b1223 100644 --- a/packages/nuxt/src/pages/runtime/plugins/prerender.server.ts +++ b/packages/nuxt/src/pages/runtime/plugins/prerender.server.ts @@ -1,20 +1,31 @@ import type { RouteRecordRaw } from 'vue-router' import { joinURL } from 'ufo' +import { createRouter as createRadixRouter, toRouteMatcher } from 'radix3' +import defu from 'defu' -import { defineNuxtPlugin } from '#app/nuxt' +import { defineNuxtPlugin, useRuntimeConfig } from '#app/nuxt' import { prerenderRoutes } from '#app/composables/ssr' // @ts-expect-error virtual file import _routes from '#build/routes' import routerOptions from '#build/router.options' +// @ts-expect-error virtual file +import { crawlLinks } from '#build/nuxt.config.mjs' let routes: string[] +let _routeRulesMatcher: undefined | ReturnType = undefined + export default defineNuxtPlugin(async () => { if (!import.meta.server || !import.meta.prerender || routerOptions.hashMode) { return } if (routes && !routes.length) { return } + const routeRules = useRuntimeConfig().nitro!.routeRules + if (!crawlLinks && routeRules && Object.values(routeRules).some(r => r.prerender)) { + _routeRulesMatcher = toRouteMatcher(createRadixRouter({ routes: routeRules })) + } + routes ||= Array.from(processRoutes(await routerOptions.routes?.(_routes) ?? _routes)) const batch = routes.splice(0, 10) prerenderRoutes(batch) @@ -24,10 +35,14 @@ export default defineNuxtPlugin(async () => { const OPTIONAL_PARAM_RE = /^\/?:.*(?:\?|\(\.\*\)\*)$/ +function shouldPrerender (path: string) { + return !_routeRulesMatcher || defu({} as Record, ..._routeRulesMatcher.matchAll(path).reverse()).prerender +} + function processRoutes (routes: RouteRecordRaw[], currentPath = '/', routesToPrerender = new Set()) { for (const route of routes) { // Add root of optional dynamic paths and catchalls - if (OPTIONAL_PARAM_RE.test(route.path) && !route.children?.length) { + if (OPTIONAL_PARAM_RE.test(route.path) && !route.children?.length && shouldPrerender(currentPath)) { routesToPrerender.add(currentPath) } // Skip dynamic paths @@ -35,7 +50,9 @@ function processRoutes (routes: RouteRecordRaw[], currentPath = '/', routesToPre continue } const fullPath = joinURL(currentPath, route.path) - routesToPrerender.add(fullPath) + if (shouldPrerender(fullPath)) { + routesToPrerender.add(fullPath) + } if (route.children) { processRoutes(route.children, fullPath, routesToPrerender) } diff --git a/test/basic.test.ts b/test/basic.test.ts index e7b122735d..ecf59e0ada 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -618,6 +618,11 @@ describe('pages', () => { expect(status).toBe(200) } }) + + it.skipIf(isDev() || isWebpack /* TODO: fix bug with import.meta.prerender being undefined in webpack build */)('prerenders pages hinted with a route rule', async () => { + const html = await $fetch('/prerender/test') + expect(html).toContain('should be prerendered: true') + }) }) describe('nuxt composables', () => { diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index afa740255d..aa710d94bc 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -67,6 +67,7 @@ export default defineNuxtConfig({ '/route-rules/middleware': { appMiddleware: 'route-rules-middleware' }, '/hydration/spa-redirection/**': { ssr: false }, '/no-scripts': { experimentalNoScripts: true }, + '/prerender/**': { prerender: true }, }, output: { dir: process.env.NITRO_OUTPUT_DIR }, prerender: { diff --git a/test/fixtures/basic/pages/prerender/test.vue b/test/fixtures/basic/pages/prerender/test.vue new file mode 100644 index 0000000000..8da7b99a6b --- /dev/null +++ b/test/fixtures/basic/pages/prerender/test.vue @@ -0,0 +1,9 @@ + + +