fix(nuxt): experimental build manifest + client route rules (#21641)

This commit is contained in:
Daniel Roe 2023-09-19 22:31:18 +01:00 committed by GitHub
parent 2bf9028f7e
commit 7dce07653c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 368 additions and 42 deletions

View File

@ -170,10 +170,13 @@ jobs:
env: ['dev', 'built']
builder: ['vite', 'webpack']
context: ['async', 'default']
manifest: ['manifest-on', 'manifest-off']
node: [18]
exclude:
- env: 'dev'
builder: 'webpack'
- manifest: 'manifest-off'
builder: 'webpack'
timeout-minutes: 15
@ -231,6 +234,7 @@ jobs:
env:
TEST_ENV: ${{ matrix.env }}
TEST_BUILDER: ${{ matrix.builder }}
TEST_MANIFEST: ${{ matrix.manifest }}
TEST_CONTEXT: ${{ matrix.context }}
SKIP_BUNDLE_SIZE: ${{ github.event_name != 'push' || matrix.env == 'dev' || matrix.builder == 'webpack' || matrix.context == 'default' || runner.os == 'Windows' }}

View File

@ -18,7 +18,7 @@
"play": "nuxi dev playground",
"play:build": "nuxi build playground",
"play:preview": "nuxi preview playground",
"test": "pnpm test:fixtures && pnpm test:fixtures:payload && pnpm test:fixtures:dev && pnpm test:fixtures:webpack && pnpm test:unit && pnpm typecheck",
"test": "pnpm test:fixtures && pnpm test:fixtures:dev && pnpm test:fixtures:webpack && pnpm test:unit && pnpm test:runtime && pnpm test:types && pnpm typecheck",
"test:fixtures": "nuxi prepare test/fixtures/basic && nuxi prepare test/fixtures/runtime-compiler && vitest run --dir test",
"test:fixtures:dev": "TEST_ENV=dev pnpm test:fixtures",
"test:fixtures:webpack": "TEST_BUILDER=webpack pnpm test:fixtures",

View File

@ -2,6 +2,13 @@ declare global {
var __NUXT_VERSION__: string
var __NUXT_PREPATHS__: string[] | string | undefined
var __NUXT_PATHS__: string[] | string | undefined
interface Navigator {
connection?: {
type: 'bluetooth' | 'cellular' | 'ethernet' | 'none' | 'wifi' | 'wimax' | 'other' | 'unknown'
effectiveType: 'slow-2g' | '2g' | '3g' | '4g'
}
}
}
export {}

View File

@ -89,6 +89,7 @@
"pathe": "^1.1.1",
"perfect-debounce": "^1.0.0",
"pkg-types": "^1.0.3",
"radix3": "^1.1.0",
"scule": "^1.0.0",
"std-env": "^3.4.3",
"strip-literal": "^1.3.0",

View File

@ -29,6 +29,8 @@ export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBefor
export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router'
export { preloadComponents, prefetchComponents, preloadRouteComponents } from './preload'
export { isPrerendered, loadPayload, preloadPayload, definePayloadReducer, definePayloadReviver } from './payload'
export { getAppManifest, getRouteRules } from './manifest'
export type { NuxtAppManifest, NuxtAppManifestMeta } from './manifest'
export type { ReloadNuxtAppOptions } from './chunk'
export { reloadNuxtApp } from './chunk'
export { useRequestURL } from './url'

View File

@ -0,0 +1,46 @@
import { joinURL } from 'ufo'
import type { MatcherExport, RouteMatcher } from 'radix3'
import { createMatcherFromExport } from 'radix3'
import { defu } from 'defu'
import { useAppConfig, useRuntimeConfig } from '#app'
// @ts-expect-error virtual file
import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs'
export interface NuxtAppManifestMeta {
id: string
timestamp: number
}
export interface NuxtAppManifest extends NuxtAppManifestMeta {
matcher: MatcherExport
prerendered: string[]
}
let manifest: Promise<NuxtAppManifest>
let matcher: RouteMatcher
function fetchManifest () {
if (!isAppManifestEnabled) {
throw new Error('[nuxt] app manifest should be enabled with `experimental.appManifest`')
}
const config = useRuntimeConfig()
// @ts-expect-error private property
const buildId = useAppConfig().nuxt?.buildId
manifest = $fetch<NuxtAppManifest>(joinURL(config.app.cdnURL || config.app.baseURL, config.app.buildAssetsDir, `builds/meta/${buildId}.json`))
manifest.then((m) => {
matcher = createMatcherFromExport(m.matcher)
})
return manifest
}
export function getAppManifest (): Promise<NuxtAppManifest> {
if (!isAppManifestEnabled) {
throw new Error('[nuxt] app manifest should be enabled with `experimental.appManifest`')
}
return manifest || fetchManifest()
}
export async function getRouteRules (url: string) {
await getAppManifest()
return defu({} as Record<string, any>, ...matcher.matchAll(url).reverse())
}

View File

@ -4,8 +4,11 @@ import { useHead } from '@unhead/vue'
import { getCurrentInstance } from 'vue'
import { useNuxtApp, useRuntimeConfig } from '../nuxt'
import { getAppManifest, getRouteRules } from '#app/composables/manifest'
import { useRoute } from '#app/composables'
// @ts-expect-error virtual import
import { renderJsonPayloads } from '#build/nuxt.config.mjs'
import { appManifest, payloadExtraction, renderJsonPayloads } from '#build/nuxt.config.mjs'
interface LoadPayloadOptions {
fresh?: boolean
@ -13,19 +16,24 @@ interface LoadPayloadOptions {
}
export function loadPayload (url: string, opts: LoadPayloadOptions = {}): Record<string, any> | Promise<Record<string, any>> | null {
if (import.meta.server) { return null }
if (import.meta.server || !payloadExtraction) { return null }
const payloadURL = _getPayloadURL(url, opts)
const nuxtApp = useNuxtApp()
const cache = nuxtApp._payloadCache = nuxtApp._payloadCache || {}
if (cache[payloadURL]) {
if (payloadURL in cache) {
return cache[payloadURL]
}
cache[payloadURL] = _importPayload(payloadURL).then((payload) => {
if (!payload) {
delete cache[payloadURL]
cache[payloadURL] = isPrerendered().then((prerendered) => {
if (!prerendered) {
cache[payloadURL] = null
return null
}
return payload
return _importPayload(payloadURL).then((payload) => {
if (payload) { return payload }
delete cache[payloadURL]
return null
})
})
return cache[payloadURL]
}
@ -55,7 +63,7 @@ function _getPayloadURL (url: string, opts: LoadPayloadOptions = {}) {
}
async function _importPayload (payloadURL: string) {
if (import.meta.server) { return null }
if (import.meta.server || !payloadExtraction) { return null }
const payloadPromise = renderJsonPayloads
? fetch(payloadURL).then(res => res.text().then(parsePayload))
: import(/* webpackIgnore: true */ /* @vite-ignore */ payloadURL).then(r => r.default || r)
@ -68,10 +76,19 @@ async function _importPayload (payloadURL: string) {
return null
}
export function isPrerendered () {
export async function isPrerendered (url = useRoute().path) {
// Note: Alternative for server is checking x-nitro-prerender header
const nuxtApp = useNuxtApp()
return !!nuxtApp.payload.prerenderedAt
if (nuxtApp.payload.prerenderedAt) {
return true
}
if (!appManifest) { return false }
const manifest = await getAppManifest()
if (manifest.prerendered.includes(url)) {
return true
}
const rules = await getRouteRules(url)
return !!rules.prerender
}
let payloadCache: any = null

View File

@ -0,0 +1,10 @@
import { defineNuxtRouteMiddleware } from '#app/composables/router'
import { getRouteRules } from '#app/composables/manifest'
export default defineNuxtRouteMiddleware(async (to) => {
if (import.meta.server || import.meta.test) { return }
const rules = await getRouteRules(to.path)
if (rules.redirect) {
return rules.redirect
}
})

View File

@ -16,6 +16,7 @@ import type { NuxtIslandContext } from '../core/runtime/nitro/renderer'
import type { RouteMiddleware } from '../../app'
import type { NuxtError } from '../app/composables/error'
import type { AsyncDataRequestStatus } from '../app/composables/asyncData'
import type { NuxtAppManifestMeta } from '#app/composables'
const nuxtAppCtx = /* #__PURE__ */ getContext<NuxtApp>('nuxt-app', {
asyncContext: !!process.env.NUXT_ASYNC_CONTEXT && process.server
@ -35,6 +36,7 @@ export interface RuntimeNuxtHooks {
'app:error:cleared': (options: { redirect?: string }) => HookResult
'app:chunkError': (options: { error: any }) => HookResult
'app:data:refresh': (keys?: string[]) => HookResult
'app:manifest:update': (meta?: NuxtAppManifestMeta) => HookResult
'link:prefetch': (link: string) => HookResult
'page:start': (Component?: VNode) => HookResult
'page:finish': (Component?: VNode) => HookResult
@ -115,7 +117,7 @@ interface _NuxtApp {
/** @internal */
_observer?: { observe: (element: Element, callback: () => void) => () => void }
/** @internal */
_payloadCache?: Record<string, Promise<Record<string, any>> | Record<string, any>>
_payloadCache?: Record<string, Promise<Record<string, any>> | Record<string, any> | null>
/** @internal */
_appConfig: AppConfig

View File

@ -0,0 +1,23 @@
import { joinURL } from 'ufo'
import type { NuxtAppManifestMeta } from '#app'
import { defineNuxtPlugin, getAppManifest, onNuxtReady, useRuntimeConfig } from '#app'
export default defineNuxtPlugin((nuxtApp) => {
if (import.meta.test) { return }
let timeout: NodeJS.Timeout
const config = useRuntimeConfig()
async function getLatestManifest () {
const currentManifest = await getAppManifest()
if (timeout) { clearTimeout(timeout) }
timeout = setTimeout(getLatestManifest, 1000 * 60 * 60)
const meta = await $fetch<NuxtAppManifestMeta>(joinURL(config.app.cdnURL || config.app.baseURL, config.app.buildAssetsDir, 'builds/latest.json'))
if (meta.id !== currentManifest.id) {
// There is a newer build which we will let the user handle
nuxtApp.hooks.callHook('app:manifest:update', meta)
}
}
onNuxtReady(() => { timeout = setTimeout(getLatestManifest, 1000 * 60 * 60) })
})

View File

@ -1,4 +1,5 @@
import { joinURL } from 'ufo'
import type { RouteLocationNormalized } from 'vue-router'
import { defineNuxtPlugin, useRuntimeConfig } from '#app/nuxt'
import { useRouter } from '#app/composables/router'
import { reloadNuxtApp } from '#app/composables/chunk'
@ -14,12 +15,20 @@ export default defineNuxtPlugin({
router.beforeEach(() => { chunkErrors.clear() })
nuxtApp.hook('app:chunkError', ({ error }) => { chunkErrors.add(error) })
router.onError((error, to) => {
if (chunkErrors.has(error)) {
function reloadAppAtPath (to: RouteLocationNormalized) {
const isHash = 'href' in to && (to.href as string).startsWith('#')
const path = isHash ? config.app.baseURL + (to as any).href : joinURL(config.app.baseURL, to.fullPath)
reloadNuxtApp({ path, persistState: true })
}
nuxtApp.hook('app:manifest:update', () => {
router.beforeResolve(reloadAppAtPath)
})
router.onError((error, to) => {
if (chunkErrors.has(error)) {
reloadAppAtPath(to)
}
})
}
})

View File

@ -1,23 +1,17 @@
import { parseURL } from 'ufo'
import { defineNuxtPlugin } from '#app/nuxt'
import { isPrerendered, loadPayload } from '#app/composables/payload'
import { loadPayload } from '#app/composables/payload'
import { onNuxtReady } from '#app/composables/ready'
import { useRouter } from '#app/composables/router'
import { getAppManifest } from '#app/composables/manifest'
// @ts-expect-error virtual file
import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs'
export default defineNuxtPlugin({
name: 'nuxt:payload',
setup (nuxtApp) {
// Only enable behavior if initial page is prerendered
// TODO: Support hybrid and dev
if (!isPrerendered()) {
return
}
// Load payload into cache
nuxtApp.hooks.hook('link:prefetch', async (url) => {
if (!parseURL(url).protocol) {
await loadPayload(url)
}
})
// TODO: Support dev
if (process.dev) { return }
// Load payload after middleware & once final route is resolved
useRouter().beforeResolve(async (to, from) => {
@ -26,5 +20,17 @@ export default defineNuxtPlugin({
if (!payload) { return }
Object.assign(nuxtApp.static.data, payload.data)
})
onNuxtReady(() => {
// Load payload into cache
nuxtApp.hooks.hook('link:prefetch', async (url) => {
if (!parseURL(url).protocol) {
await loadPayload(url)
}
})
if (isAppManifestEnabled && navigator.connection?.effectiveType !== 'slow-2g') {
setTimeout(getAppManifest, 1000)
}
})
}
})

View File

@ -1,6 +1,9 @@
import { existsSync, promises as fsp, readFileSync } from 'node:fs'
import { cpus } from 'node:os'
import { join, relative, resolve } from 'pathe'
import { createRouter as createRadixRouter, exportMatcher, toRouteMatcher } from 'radix3'
import { randomUUID } from 'uncrypto'
import { joinURL } from 'ufo'
import { build, copyPublicAssets, createDevServer, createNitro, prepare, prerender, scanHandlers, writeTypes } from 'nitropack'
import type { Nitro, NitroConfig } from 'nitropack'
import { logger, resolveIgnorePatterns } from '@nuxt/kit'
@ -198,6 +201,80 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
nitroConfig.srcDir = resolve(nuxt.options.rootDir, nuxt.options.srcDir, nitroConfig.srcDir!)
nitroConfig.ignore = [...(nitroConfig.ignore || []), ...resolveIgnorePatterns(nitroConfig.srcDir)]
// Add app manifest handler and prerender configuration
if (nuxt.options.experimental.appManifest) {
// @ts-expect-error untyped nuxt property
const buildId = nuxt.options.appConfig.nuxt!.buildId ||= randomUUID()
const buildTimestamp = Date.now()
const manifestPrefix = joinURL(nuxt.options.app.buildAssetsDir, 'builds')
const tempDir = join(nuxt.options.buildDir, 'manifest')
nitroConfig.publicAssets!.unshift(
// build manifest
{
dir: join(tempDir, 'meta'),
maxAge: 31536000 /* 1 year */,
baseURL: joinURL(manifestPrefix, 'meta')
},
// latest build
{
dir: tempDir,
maxAge: 1,
baseURL: manifestPrefix
}
)
nuxt.hook('nitro:build:before', async (nitro) => {
const routeRules = {} as Record<string, any>
const _routeRules = nitro.options.routeRules
for (const key in _routeRules) {
if (key === '/__nuxt_error') { continue }
const filteredRules = Object.entries(_routeRules[key])
.filter(([key, value]) => ['prerender', 'redirect'].includes(key) && value)
.map(([key, value]: any) => {
if (key === 'redirect') {
return [key, typeof value === 'string' ? value : value.to]
}
return [key, value]
})
if (filteredRules.length > 0) {
routeRules[key] = Object.fromEntries(filteredRules)
}
}
// Add pages prerendered but not covered by route rules
const prerenderedRoutes = new Set<string>()
const routeRulesMatcher = toRouteMatcher(
createRadixRouter({ routes: routeRules })
)
const payloadSuffix = nuxt.options.experimental.renderJsonPayloads ? '/_payload.json' : '/_payload.js'
for (const route of nitro._prerenderedRoutes || []) {
if (!route.error && route.route.endsWith(payloadSuffix)) {
const url = route.route.slice(0, -payloadSuffix.length) || '/'
const rules = defu({}, ...routeRulesMatcher.matchAll(url).reverse()) as Record<string, any>
if (!rules.prerender) {
prerenderedRoutes.add(url)
}
}
}
const manifest = {
id: buildId,
timestamp: buildTimestamp,
matcher: exportMatcher(routeRulesMatcher),
prerendered: nuxt.options.dev ? [] : [...prerenderedRoutes]
}
await fsp.mkdir(join(tempDir, 'meta'), { recursive: true })
await fsp.writeFile(join(tempDir, 'latest.json'), JSON.stringify({
id: buildId,
timestamp: buildTimestamp
}))
await fsp.writeFile(join(tempDir, `meta/${buildId}.json`), JSON.stringify(manifest))
})
}
// Add fallback server for `ssr: false`
if (!nuxt.options.ssr) {
nitroConfig.virtual!['#build/dist/server/server.mjs'] = 'export default () => {}'

View File

@ -1,7 +1,7 @@
import { join, normalize, relative, resolve } from 'pathe'
import { createDebugger, createHooks } from 'hookable'
import type { LoadNuxtOptions } from '@nuxt/kit'
import { addBuildPlugin, addComponent, addPlugin, addVitePlugin, addWebpackPlugin, installModule, loadNuxtConfig, logger, nuxtCtx, resolveAlias, resolveFiles, resolvePath, tryResolveModule, useNitro } from '@nuxt/kit'
import { addBuildPlugin, addComponent, addPlugin, addRouteMiddleware, addVitePlugin, addWebpackPlugin, installModule, loadNuxtConfig, logger, nuxtCtx, resolveAlias, resolveFiles, resolvePath, tryResolveModule, useNitro } from '@nuxt/kit'
import type { Nuxt, NuxtHooks, NuxtOptions } from 'nuxt/schema'
import escapeRE from 'escape-string-regexp'
@ -90,6 +90,16 @@ async function initNuxt (nuxt: Nuxt) {
addVitePlugin(() => ImportProtectionPlugin.vite(config))
addWebpackPlugin(() => ImportProtectionPlugin.webpack(config))
if (nuxt.options.experimental.appManifest) {
addRouteMiddleware({
name: 'manifest-route-rule',
path: resolve(nuxt.options.appDir, 'middleware/manifest-route-rule'),
global: true
})
addPlugin(resolve(nuxt.options.appDir, 'plugins/check-outdated-build.client'))
}
// add resolver for modules used in virtual files
addVitePlugin(() => resolveDeepImportsPlugin(nuxt))
@ -295,6 +305,11 @@ async function initNuxt (nuxt: Nuxt) {
}
}
// Add prerender payload support
if (!nuxt.options.dev && nuxt.options.experimental.payloadExtraction) {
addPlugin(resolve(nuxt.options.appDir, 'plugins/payload.client'))
}
// Add experimental cross-origin prefetch support using Speculation Rules API
if (nuxt.options.experimental.crossOriginPrefetch) {
addPlugin(resolve(nuxt.options.appDir, 'plugins/cross-origin-prefetch.client'))

View File

@ -337,6 +337,8 @@ export const nuxtConfigTemplate = {
...Object.entries(ctx.nuxt.options.app).map(([k, v]) => `export const ${camelCase('app-' + k)} = ${JSON.stringify(v)}`),
`export const renderJsonPayloads = ${!!ctx.nuxt.options.experimental.renderJsonPayloads}`,
`export const componentIslands = ${!!ctx.nuxt.options.experimental.componentIslands}`,
`export const payloadExtraction = ${!!ctx.nuxt.options.experimental.payloadExtraction}`,
`export const appManifest = ${!!ctx.nuxt.options.experimental.appManifest}`,
`export const remoteComponentIslands = ${ctx.nuxt.options.experimental.componentIslands === 'local+remote'}`,
`export const devPagesDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.dir.pages) : 'null'}`,
`export const devRootDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.rootDir) : 'null'}`,

View File

@ -59,6 +59,8 @@ const appPreset = defineUnimportPreset({
'loadPayload',
'preloadPayload',
'isPrerendered',
'getAppManifest',
'getRouteRules',
'definePayloadReducer',
'definePayloadReviver',
'requestIdleCallback',

View File

@ -474,7 +474,9 @@ export default defineUntypedSchema({
*
* @type {typeof import('../src/types/config').AppConfig}
*/
appConfig: {},
appConfig: {
nuxt: {}
},
$schema: {}
})

View File

@ -120,11 +120,11 @@ export default defineUntypedSchema({
noVueServer: false,
/**
* When this option is enabled (by default) payload of pages generated with `nuxt generate` are extracted
* When this option is enabled (by default) payload of pages that are prerendered are extracted
*
* @type {boolean | undefined}
*/
payloadExtraction: undefined,
payloadExtraction: true,
/**
* Whether to enable the experimental `<NuxtClientFallback>` component for rendering content on the client
@ -207,6 +207,17 @@ export default defineUntypedSchema({
/** Enable the new experimental typed router using [unplugin-vue-router](https://github.com/posva/unplugin-vue-router). */
typedPages: false,
/**
* Use app manifests to respect route rules on client-side.
*/
// TODO: enable by default in v3.8
appManifest: false,
// This is enabled when `experimental.payloadExtraction` is set to `true`.
// appManifest: {
// $resolve: (val, get) => val ?? get('experimental.payloadExtraction')
// },
/**
* Set an alternative watcher that will be used as the watching service for Nuxt.
*

View File

@ -356,6 +356,9 @@ importers:
pkg-types:
specifier: ^1.0.3
version: 1.0.3
radix3:
specifier: ^1.1.0
version: 1.1.0
scule:
specifier: ^1.0.0
version: 1.0.0

View File

@ -11,6 +11,7 @@ import type { NuxtIslandResponse } from '../packages/nuxt/src/core/runtime/nitro
import { expectNoClientErrors, expectWithPolling, gotoPath, isRenderingJson, parseData, parsePayload, renderPage } from './utils'
const isWebpack = process.env.TEST_BUILDER === 'webpack'
const isTestingAppManifest = process.env.TEST_MANIFEST === 'manifest-on'
await setup({
rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)),
@ -1622,18 +1623,24 @@ describe('app config', () => {
it('should work', async () => {
const html = await $fetch('/app-config')
const expectedAppConfig = {
const expectedAppConfig: Record<string, any> = {
fromNuxtConfig: true,
nested: {
val: 2
},
nuxt: {},
fromLayer: true,
userConfig: 123
}
expect(html).toContain(JSON.stringify(expectedAppConfig))
if (isTestingAppManifest) {
expectedAppConfig.nuxt.buildId = 'test'
}
expect.soft(html.replace(/"nuxt":\{"buildId":"[^"]+"\}/, '"nuxt":{"buildId":"test"}')).toContain(JSON.stringify(expectedAppConfig))
const serverAppConfig = await $fetch('/api/app-config')
if (isTestingAppManifest) {
serverAppConfig.appConfig.nuxt.buildId = 'test'
}
expect(serverAppConfig).toMatchObject({ appConfig: expectedAppConfig })
})
})

View File

@ -19,7 +19,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
for (const outputDir of ['.output', '.output-inline']) {
it('default client bundle size', async () => {
const clientStats = await analyzeSizes('**/*.js', join(rootDir, outputDir, 'public'))
expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"96.1k"')
expect.soft(roundToKilobytes(clientStats.totalBytes)).toMatchInlineSnapshot('"97.2k"')
expect(clientStats.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
[
"_nuxt/entry.js",

View File

@ -3,6 +3,7 @@ import { addTypeTemplate } from 'nuxt/kit'
export default defineNuxtConfig({
experimental: {
typedPages: true,
appManifest: true,
typescriptBundlerResolution: process.env.MODULE_RESOLUTION === 'bundler'
},
buildDir: process.env.NITRO_BUILD_DIR,

View File

@ -425,6 +425,7 @@ describe('composables', () => {
describe('app config', () => {
it('merges app config as expected', () => {
interface ExpectedMergedAppConfig {
nuxt: { buildId: string }
fromLayer: boolean
fromNuxtConfig: boolean
nested: {

View File

@ -56,7 +56,8 @@ export default defineNuxtConfig({
routes: [
'/random/a',
'/random/b',
'/random/c'
'/random/c',
'/prefetch/server-components'
]
}
},
@ -193,8 +194,10 @@ export default defineNuxtConfig({
componentIslands: true,
reactivityTransform: true,
treeshakeClientOnly: true,
payloadExtraction: true,
asyncContext: process.env.TEST_CONTEXT === 'async',
// TODO: remove this in v3.8
payloadExtraction: true,
appManifest: process.env.TEST_MANIFEST === 'manifest-on',
headNext: true,
inlineRouteRules: true
},

View File

@ -1 +1,3 @@
export default defineNuxtConfig({})
export default defineNuxtConfig({
experimental: { appManifest: true }
})

View File

@ -43,6 +43,10 @@ describe('config typings', () => {
})
it('appConfig', () => {
expectTypeOf(useAppConfig()).toEqualTypeOf<{ [key: string]: unknown }>()
expectTypeOf(useAppConfig().foo).toEqualTypeOf<unknown>()
expectTypeOf(useAppConfig()).toEqualTypeOf<{
nuxt: { buildId: string }
[key: string]: unknown
}>()
})
})

View File

@ -1,6 +1,9 @@
/// <reference path="../fixtures/basic/.nuxt/nuxt.d.ts" />
import { describe, expect, it, vi } from 'vitest'
import { defineEventHandler } from 'h3'
import { registerEndpoint } from 'nuxt-vitest/utils'
import * as composables from '#app/composables'
@ -10,11 +13,24 @@ import { onNuxtReady } from '#app/composables/ready'
import { setResponseStatus, useRequestEvent, useRequestFetch, useRequestHeaders } from '#app/composables/ssr'
import { clearNuxtState, useState } from '#app/composables/state'
import { useRequestURL } from '#app/composables/url'
import { getAppManifest, getRouteRules } from '#app/composables/manifest'
vi.mock('#app/compat/idle-callback', () => ({
requestIdleCallback: (cb: Function) => cb()
}))
const timestamp = Date.now()
registerEndpoint('/_nuxt/builds/latest.json', defineEventHandler(() => ({
id: 'test',
timestamp
})))
registerEndpoint('/_nuxt/builds/meta/test.json', defineEventHandler(() => ({
id: 'test',
timestamp,
matcher: { static: { '/': null, '/pre': null }, wildcard: { '/pre': { prerender: true } }, dynamic: {} },
prerendered: ['/specific-prerendered']
})))
describe('composables', () => {
it('are all tested', () => {
const testedComposables: string[] = [
@ -27,10 +43,13 @@ describe('composables', () => {
'clearError',
'showError',
'useError',
'getAppManifest',
'getRouteRules',
'onNuxtReady',
'setResponseStatus',
'useRequestEvent',
'useRequestFetch',
'isPrerendered',
'useRequestHeaders',
'clearNuxtState',
'useState',
@ -43,7 +62,6 @@ describe('composables', () => {
'defineNuxtRouteMiddleware',
'definePayloadReducer',
'definePayloadReviver',
'isPrerendered',
'loadPayload',
'navigateTo',
'onBeforeRouteLeave',
@ -204,3 +222,39 @@ describe('url', () => {
expect(url.protocol).toMatchInlineSnapshot('"http:"')
})
})
describe.skipIf(process.env.TEST_MANIFEST !== 'manifest-on')('app manifests', () => {
it('getAppManifest', async () => {
const manifest = await getAppManifest()
delete manifest.timestamp
expect(manifest).toMatchInlineSnapshot(`
{
"id": "test",
"matcher": {
"dynamic": {},
"static": {
"/": null,
"/pre": null,
},
"wildcard": {
"/pre": {
"prerender": true,
},
},
},
"prerendered": [
"/specific-prerendered",
],
}
`)
})
it('getRouteRules', async () => {
const rules = await getRouteRules('/')
expect(rules).toMatchInlineSnapshot('{}')
})
it('isPrerendered', async () => {
expect(await isPrerendered('/specific-prerendered')).toBeTruthy()
expect(await isPrerendered('/prerendered/test')).toBeTruthy()
expect(await isPrerendered('/test')).toBeFalsy()
})
})

View File

@ -1,8 +1,23 @@
import { defineVitestConfig } from 'nuxt-vitest/config'
export default defineVitestConfig({
// TODO: investigate
define: {
'import.meta.test': true
},
test: {
dir: './test/nuxt',
environment: 'nuxt'
environment: 'nuxt',
environmentOptions: {
nuxt: {
overrides: {
appConfig: {
nuxt: {
buildId: 'test'
}
}
}
}
}
}
})