mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-11 08:33:53 +00:00
feat(nuxt): add experimental app:chunkError
hook and reload strategy (#19038)
This commit is contained in:
parent
a1252d3a30
commit
96b09ea982
@ -33,6 +33,7 @@ export interface RuntimeNuxtHooks {
|
||||
'app:suspense:resolve': (Component?: VNode) => HookResult
|
||||
'app:error': (err: any) => HookResult
|
||||
'app:error:cleared': (options: { redirect?: string }) => HookResult
|
||||
'app:chunkError': (options: { error: any }) => HookResult
|
||||
'app:data:refresh': (keys?: string[]) => HookResult
|
||||
'link:prefetch': (link: string) => 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
|
||||
const runtimeConfig = process.server
|
||||
? options.ssrContext!.runtimeConfig
|
||||
|
25
packages/nuxt/src/app/plugins/chunk-reload.client.ts
Normal file
25
packages/nuxt/src/app/plugins/chunk-reload.client.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
@ -192,6 +192,11 @@ async function initNuxt (nuxt: Nuxt) {
|
||||
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
|
||||
if (nuxt.options.builder === '@nuxt/webpack-builder') {
|
||||
addPlugin(resolve(nuxt.options.appDir, 'plugins/preload.server'))
|
||||
|
@ -27,6 +27,18 @@ export default defineUntypedSchema({
|
||||
*/
|
||||
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
|
||||
*
|
||||
|
@ -11,6 +11,7 @@ import { defu } from 'defu'
|
||||
import type { OutputOptions } from 'rollup'
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { cacheDirPlugin } from './plugins/cache-dir'
|
||||
import { chunkErrorPlugin } from './plugins/chunk-error'
|
||||
import type { ViteBuildContext, ViteOptions } from './vite'
|
||||
import { devStyleSSRPlugin } from './plugins/dev-ssr-css'
|
||||
import { runtimePathsPlugin } from './plugins/paths'
|
||||
@ -82,6 +83,11 @@ export async function buildClient (ctx: ViteBuildContext) {
|
||||
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
|
||||
clientConfig.build!.rollupOptions = defu(clientConfig.build!.rollupOptions!, {
|
||||
output: {
|
||||
|
30
packages/vite/src/plugins/chunk-error.ts
Normal file
30
packages/vite/src/plugins/chunk-error.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
28
packages/webpack/src/plugins/chunk.ts
Normal file
28
packages/webpack/src/plugins/chunk.ts
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ import { joinURL } from 'ufo'
|
||||
import { logger, useNuxt } from '@nuxt/kit'
|
||||
import { composableKeysPlugin } from '../../vite/src/plugins/composable-keys'
|
||||
import { DynamicBasePlugin } from './plugins/dynamic-base'
|
||||
import { ChunkErrorPlugin } from './plugins/chunk'
|
||||
import { createMFS } from './utils/mfs'
|
||||
import { registerVirtualModules } from './virtual-modules'
|
||||
import { client, server } from './configs'
|
||||
@ -39,6 +40,10 @@ export async function bundle (nuxt: Nuxt) {
|
||||
config.plugins!.push(DynamicBasePlugin.webpack({
|
||||
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({
|
||||
sourcemap: nuxt.options.sourcemap[config.name as 'client' | 'server'],
|
||||
rootDir: nuxt.options.rootDir
|
||||
|
@ -330,6 +330,15 @@ describe('errors', () => {
|
||||
const res = await fetch('/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', () => {
|
||||
|
1
test/fixtures/basic/nuxt.config.ts
vendored
1
test/fixtures/basic/nuxt.config.ts
vendored
@ -149,6 +149,7 @@ export default defineNuxtConfig({
|
||||
}
|
||||
},
|
||||
experimental: {
|
||||
emitRouteChunkError: 'reload',
|
||||
inlineSSRStyles: id => !!id && !id.includes('assets.vue'),
|
||||
componentIslands: true,
|
||||
reactivityTransform: true,
|
||||
|
17
test/fixtures/basic/pages/chunk-error.vue
vendored
Normal file
17
test/fixtures/basic/pages/chunk-error.vue
vendored
Normal 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>
|
3
test/fixtures/basic/pages/index.vue
vendored
3
test/fixtures/basic/pages/index.vue
vendored
@ -14,6 +14,9 @@
|
||||
<NuxtLink to="/">
|
||||
Link
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/chunk-error" :prefetch="false">
|
||||
Chunk error
|
||||
</NuxtLink>
|
||||
<NestedSugarCounter :multiplier="2" />
|
||||
<CustomComponent />
|
||||
<Spin>Test</Spin>
|
||||
|
3
test/fixtures/basic/plugins/chunk-error.ts
vendored
Normal file
3
test/fixtures/basic/plugins/chunk-error.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.hook('app:chunkError', () => console.log('caught chunk load error'))
|
||||
})
|
Loading…
Reference in New Issue
Block a user