fix(nuxt): use routeRules to hint pages to prerender (#29172)

This commit is contained in:
Daniel Roe 2024-09-26 17:07:46 +01:00 committed by GitHub
parent 959061fc29
commit 781d8c4174
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 53 additions and 5 deletions

View File

@ -8,6 +8,7 @@ import { hash } from 'ohash'
import { camelCase } from 'scule' import { camelCase } from 'scule'
import { filename } from 'pathe/utils' import { filename } from 'pathe/utils'
import type { NuxtTemplate } from 'nuxt/schema' import type { NuxtTemplate } from 'nuxt/schema'
import type { Nitro } from 'nitro/types'
import { annotatePlugins, checkForCircularDependencies } from './app' import { annotatePlugins, checkForCircularDependencies } from './app'
@ -516,6 +517,7 @@ export const nuxtConfigTemplate: NuxtTemplate = {
`export const outdatedBuildInterval = ${ctx.nuxt.options.experimental.checkOutdatedBuildInterval}`, `export const outdatedBuildInterval = ${ctx.nuxt.options.experimental.checkOutdatedBuildInterval}`,
`export const multiApp = ${!!ctx.nuxt.options.future.multiApp}`, `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 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') ].join('\n\n')
}, },
} }

View File

@ -8,6 +8,7 @@ import type { Nuxt, NuxtApp, NuxtPage } from 'nuxt/schema'
import { createRoutesContext } from 'unplugin-vue-router' import { createRoutesContext } from 'unplugin-vue-router'
import { resolveOptions } from 'unplugin-vue-router/options' import { resolveOptions } from 'unplugin-vue-router/options'
import type { EditableTreeNode, Options as TypedRouterOptions } from 'unplugin-vue-router' import type { EditableTreeNode, Options as TypedRouterOptions } from 'unplugin-vue-router'
import { createRouter as createRadixRouter, toRouteMatcher } from 'radix3'
import type { NitroRouteConfig } from 'nitro/types' import type { NitroRouteConfig } from 'nitro/types'
import { defu } from 'defu' import { defu } from 'defu'
@ -277,7 +278,7 @@ export default defineNuxtModule({
nuxt.hook('app:resolve', (app) => { nuxt.hook('app:resolve', (app) => {
const nitro = useNitro() 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({ app.plugins.push({
src: resolve(runtimeDir, 'plugins/prerender.server'), src: resolve(runtimeDir, 'plugins/prerender.server'),
mode: 'server', mode: 'server',
@ -315,7 +316,20 @@ export default defineNuxtModule({
}) })
nuxt.hook('nitro:build:before', (nitro) => { 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<string, any>, ...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 // 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 // as the rest will be injected at runtime when this is prerendered

View File

@ -1,20 +1,31 @@
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import { joinURL } from 'ufo' 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' import { prerenderRoutes } from '#app/composables/ssr'
// @ts-expect-error virtual file // @ts-expect-error virtual file
import _routes from '#build/routes' import _routes from '#build/routes'
import routerOptions from '#build/router.options' import routerOptions from '#build/router.options'
// @ts-expect-error virtual file
import { crawlLinks } from '#build/nuxt.config.mjs'
let routes: string[] let routes: string[]
let _routeRulesMatcher: undefined | ReturnType<typeof toRouteMatcher> = undefined
export default defineNuxtPlugin(async () => { export default defineNuxtPlugin(async () => {
if (!import.meta.server || !import.meta.prerender || routerOptions.hashMode) { if (!import.meta.server || !import.meta.prerender || routerOptions.hashMode) {
return return
} }
if (routes && !routes.length) { 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)) routes ||= Array.from(processRoutes(await routerOptions.routes?.(_routes) ?? _routes))
const batch = routes.splice(0, 10) const batch = routes.splice(0, 10)
prerenderRoutes(batch) prerenderRoutes(batch)
@ -24,10 +35,14 @@ export default defineNuxtPlugin(async () => {
const OPTIONAL_PARAM_RE = /^\/?:.*(?:\?|\(\.\*\)\*)$/ const OPTIONAL_PARAM_RE = /^\/?:.*(?:\?|\(\.\*\)\*)$/
function shouldPrerender (path: string) {
return !_routeRulesMatcher || defu({} as Record<string, any>, ..._routeRulesMatcher.matchAll(path).reverse()).prerender
}
function processRoutes (routes: RouteRecordRaw[], currentPath = '/', routesToPrerender = new Set<string>()) { function processRoutes (routes: RouteRecordRaw[], currentPath = '/', routesToPrerender = new Set<string>()) {
for (const route of routes) { for (const route of routes) {
// Add root of optional dynamic paths and catchalls // 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) routesToPrerender.add(currentPath)
} }
// Skip dynamic paths // Skip dynamic paths
@ -35,7 +50,9 @@ function processRoutes (routes: RouteRecordRaw[], currentPath = '/', routesToPre
continue continue
} }
const fullPath = joinURL(currentPath, route.path) const fullPath = joinURL(currentPath, route.path)
if (shouldPrerender(fullPath)) {
routesToPrerender.add(fullPath) routesToPrerender.add(fullPath)
}
if (route.children) { if (route.children) {
processRoutes(route.children, fullPath, routesToPrerender) processRoutes(route.children, fullPath, routesToPrerender)
} }

View File

@ -620,6 +620,11 @@ describe('pages', () => {
expect(status).toBe(200) 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', () => { describe('nuxt composables', () => {

View File

@ -66,6 +66,7 @@ export default defineNuxtConfig({
'/route-rules/middleware': { appMiddleware: 'route-rules-middleware' }, '/route-rules/middleware': { appMiddleware: 'route-rules-middleware' },
'/hydration/spa-redirection/**': { ssr: false }, '/hydration/spa-redirection/**': { ssr: false },
'/no-scripts': { experimentalNoScripts: true }, '/no-scripts': { experimentalNoScripts: true },
'/prerender/**': { prerender: true },
}, },
prerender: { prerender: {
routes: [ routes: [

View File

@ -0,0 +1,9 @@
<script setup lang="ts">
const wasPrerendered = useState(() => import.meta.prerender)
</script>
<template>
<div>
should be prerendered: {{ wasPrerendered }}
</div>
</template>