feat(nuxt): add experimental app:chunkError hook and reload strategy (#19038)

This commit is contained in:
Daniel Roe 2023-02-16 12:43:58 +00:00 committed by GitHub
parent a1252d3a30
commit 96b09ea982
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 152 additions and 0 deletions

View File

@ -33,6 +33,7 @@ export interface RuntimeNuxtHooks {
'app:suspense:resolve': (Component?: VNode) => HookResult 'app:suspense:resolve': (Component?: VNode) => HookResult
'app:error': (err: any) => HookResult 'app:error': (err: any) => HookResult
'app:error:cleared': (options: { redirect?: string }) => HookResult 'app:error:cleared': (options: { redirect?: string }) => HookResult
'app:chunkError': (options: { error: any }) => HookResult
'app:data:refresh': (keys?: string[]) => HookResult 'app:data:refresh': (keys?: string[]) => HookResult
'link:prefetch': (link: string) => HookResult 'link:prefetch': (link: string) => HookResult
'page:start': (Component?: VNode) => HookResult 'page:start': (Component?: VNode) => HookResult
@ -186,6 +187,13 @@ export function createNuxtApp (options: CreateOptions) {
} }
} }
// Listen to chunk load errors
if (process.client) {
window.addEventListener('nuxt.preloadError', (event) => {
nuxtApp.callHook('app:chunkError', { error: (event as Event & { payload: Error }).payload })
})
}
// Expose runtime config // Expose runtime config
const runtimeConfig = process.server const runtimeConfig = process.server
? options.ssrContext!.runtimeConfig ? options.ssrContext!.runtimeConfig

View File

@ -0,0 +1,25 @@
import { defineNuxtPlugin } from '#app/nuxt'
import { useRouter } from '#app/composables/router'
export default defineNuxtPlugin((nuxtApp) => {
const router = useRouter()
const chunkErrors = new Set()
router.beforeEach(() => { chunkErrors.clear() })
nuxtApp.hook('app:chunkError', ({ error }) => { chunkErrors.add(error) })
router.onError((error, to) => {
if (!chunkErrors.has(error)) { return }
let handledPath: Record<string, any> = {}
try {
handledPath = JSON.parse(localStorage.getItem('nuxt:reload') || '{}')
} catch {}
if (handledPath?.path !== to.fullPath || handledPath?.expires < Date.now()) {
localStorage.setItem('nuxt:reload', JSON.stringify({ path: to.fullPath, expires: Date.now() + 10000 }))
window.location.href = to.fullPath
}
})
})

View File

@ -192,6 +192,11 @@ async function initNuxt (nuxt: Nuxt) {
addPlugin(resolve(nuxt.options.appDir, 'plugins/cross-origin-prefetch.client')) addPlugin(resolve(nuxt.options.appDir, 'plugins/cross-origin-prefetch.client'))
} }
// Add experimental page reload support
if (nuxt.options.experimental.emitRouteChunkError === 'reload') {
addPlugin(resolve(nuxt.options.appDir, 'plugins/chunk-reload.client'))
}
// Track components used to render for webpack // Track components used to render for webpack
if (nuxt.options.builder === '@nuxt/webpack-builder') { if (nuxt.options.builder === '@nuxt/webpack-builder') {
addPlugin(resolve(nuxt.options.appDir, 'plugins/preload.server')) addPlugin(resolve(nuxt.options.appDir, 'plugins/preload.server'))

View File

@ -27,6 +27,18 @@ export default defineUntypedSchema({
*/ */
treeshakeClientOnly: true, treeshakeClientOnly: true,
/**
* Emit `app:chunkError` hook when there is an error loading vite/webpack
* chunks.
*
* You can set this to `reload` to perform a hard reload of the new route
* when a chunk fails to load when navigating to a new route.
*
* @see https://github.com/nuxt/nuxt/pull/19038
* @type {boolean | 'reload'}
*/
emitRouteChunkError: false,
/** /**
* Use vite-node for on-demand server chunk loading * Use vite-node for on-demand server chunk loading
* *

View File

@ -11,6 +11,7 @@ import { defu } from 'defu'
import type { OutputOptions } from 'rollup' import type { OutputOptions } from 'rollup'
import { defineEventHandler } from 'h3' import { defineEventHandler } from 'h3'
import { cacheDirPlugin } from './plugins/cache-dir' import { cacheDirPlugin } from './plugins/cache-dir'
import { chunkErrorPlugin } from './plugins/chunk-error'
import type { ViteBuildContext, ViteOptions } from './vite' import type { ViteBuildContext, ViteOptions } from './vite'
import { devStyleSSRPlugin } from './plugins/dev-ssr-css' import { devStyleSSRPlugin } from './plugins/dev-ssr-css'
import { runtimePathsPlugin } from './plugins/paths' import { runtimePathsPlugin } from './plugins/paths'
@ -82,6 +83,11 @@ export async function buildClient (ctx: ViteBuildContext) {
clientConfig.server!.hmr = false clientConfig.server!.hmr = false
} }
// Emit chunk errors if the user has opted in to `experimental.emitRouteChunkError`
if (ctx.nuxt.options.experimental.emitRouteChunkError) {
clientConfig.plugins!.push(chunkErrorPlugin({ sourcemap: ctx.nuxt.options.sourcemap.client }))
}
// We want to respect users' own rollup output options // We want to respect users' own rollup output options
clientConfig.build!.rollupOptions = defu(clientConfig.build!.rollupOptions!, { clientConfig.build!.rollupOptions = defu(clientConfig.build!.rollupOptions!, {
output: { output: {

View File

@ -0,0 +1,30 @@
import MagicString from 'magic-string'
import type { Plugin } from 'vite'
import type { SourceMap } from 'rollup'
export function chunkErrorPlugin (options: { sourcemap?: boolean }): Plugin {
return {
name: 'nuxt:chunk-error',
transform (code, id) {
if (id !== '\0vite/preload-helper' || code.includes('nuxt.preloadError')) { return }
const s = new MagicString(code)
s.replace(/__vitePreload/g, '___vitePreload')
s.append(`
export const __vitePreload = (...args) => ___vitePreload(...args).catch(err => {
const e = new Event("nuxt.preloadError");
e.payload = err;
window.dispatchEvent(e);
throw err;
})`)
return {
code: s.toString(),
map: options.sourcemap
? s.generateMap({ source: id, includeContent: true }) as SourceMap
: undefined
}
}
}
}

View File

@ -0,0 +1,28 @@
import type { Compiler } from 'webpack'
import { RuntimeGlobals } from 'webpack'
const pluginName = 'ChunkErrorPlugin'
const script = `
if (typeof ${RuntimeGlobals.require} !== "undefined") {
var _ensureChunk = ${RuntimeGlobals.ensureChunk};
${RuntimeGlobals.ensureChunk} = function (chunkId) {
return Promise.resolve(_ensureChunk(chunkId)).catch(err => {
const e = new Event("nuxt.preloadError");
e.payload = err;
window.dispatchEvent(e);
throw err;
});
};
};`
export class ChunkErrorPlugin {
apply (compiler: Compiler) {
compiler.hooks.thisCompilation.tap(pluginName, compilation =>
compilation.mainTemplate.hooks.localVars.tap(
{ name: pluginName, stage: 1 },
source => source + script
)
)
}
}

View File

@ -12,6 +12,7 @@ import { joinURL } from 'ufo'
import { logger, useNuxt } from '@nuxt/kit' import { logger, useNuxt } from '@nuxt/kit'
import { composableKeysPlugin } from '../../vite/src/plugins/composable-keys' import { composableKeysPlugin } from '../../vite/src/plugins/composable-keys'
import { DynamicBasePlugin } from './plugins/dynamic-base' import { DynamicBasePlugin } from './plugins/dynamic-base'
import { ChunkErrorPlugin } from './plugins/chunk'
import { createMFS } from './utils/mfs' import { createMFS } from './utils/mfs'
import { registerVirtualModules } from './virtual-modules' import { registerVirtualModules } from './virtual-modules'
import { client, server } from './configs' import { client, server } from './configs'
@ -39,6 +40,10 @@ export async function bundle (nuxt: Nuxt) {
config.plugins!.push(DynamicBasePlugin.webpack({ config.plugins!.push(DynamicBasePlugin.webpack({
sourcemap: nuxt.options.sourcemap[config.name as 'client' | 'server'] sourcemap: nuxt.options.sourcemap[config.name as 'client' | 'server']
})) }))
// Emit chunk errors if the user has opted in to `experimental.emitRouteChunkError`
if (config.name === 'client' && nuxt.options.experimental.emitRouteChunkError) {
config.plugins!.push(new ChunkErrorPlugin())
}
config.plugins!.push(composableKeysPlugin.webpack({ config.plugins!.push(composableKeysPlugin.webpack({
sourcemap: nuxt.options.sourcemap[config.name as 'client' | 'server'], sourcemap: nuxt.options.sourcemap[config.name as 'client' | 'server'],
rootDir: nuxt.options.rootDir rootDir: nuxt.options.rootDir

View File

@ -330,6 +330,15 @@ describe('errors', () => {
const res = await fetch('/error') const res = await fetch('/error')
expect(await res.text()).toContain('This is a custom error') expect(await res.text()).toContain('This is a custom error')
}) })
// TODO: need to create test for webpack
it.runIf(!isDev() && !isWebpack)('should handle chunk loading errors', async () => {
const { page, consoleLogs } = await renderPage('/')
await page.getByText('Chunk error').click()
await page.waitForURL(url('/chunk-error'))
expect(consoleLogs.map(c => c.text).join('')).toContain('caught chunk load error')
expect(await page.innerText('div')).toContain('Chunk error page')
})
}) })
describe('navigate external', () => { describe('navigate external', () => {

View File

@ -149,6 +149,7 @@ export default defineNuxtConfig({
} }
}, },
experimental: { experimental: {
emitRouteChunkError: 'reload',
inlineSSRStyles: id => !!id && !id.includes('assets.vue'), inlineSSRStyles: id => !!id && !id.includes('assets.vue'),
componentIslands: true, componentIslands: true,
reactivityTransform: true, reactivityTransform: true,

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
definePageMeta({
middleware: defineNuxtRouteMiddleware(async (to, from) => {
const nuxtApp = useNuxtApp()
if (process.client && from !== to && !nuxtApp.isHydrating) {
// trigger a loading error when navigated to via client-side navigation
await import(/* webpackIgnore: true */ /* @vite-ignore */ `some-non-exis${''}ting-module`)
}
})
})
</script>
<template>
<div>
Chunk error page
</div>
</template>

View File

@ -14,6 +14,9 @@
<NuxtLink to="/"> <NuxtLink to="/">
Link Link
</NuxtLink> </NuxtLink>
<NuxtLink to="/chunk-error" :prefetch="false">
Chunk error
</NuxtLink>
<NestedSugarCounter :multiplier="2" /> <NestedSugarCounter :multiplier="2" />
<CustomComponent /> <CustomComponent />
<Spin>Test</Spin> <Spin>Test</Spin>

View File

@ -0,0 +1,3 @@
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('app:chunkError', () => console.log('caught chunk load error'))
})