From 96b09ea9820aa1bb2b6a9aa567a58b8bd321fcc9 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 16 Feb 2023 12:43:58 +0000 Subject: [PATCH] feat(nuxt): add experimental `app:chunkError` hook and reload strategy (#19038) --- packages/nuxt/src/app/nuxt.ts | 8 +++++ .../src/app/plugins/chunk-reload.client.ts | 25 ++++++++++++++++ packages/nuxt/src/core/nuxt.ts | 5 ++++ packages/schema/src/config/experimental.ts | 12 ++++++++ packages/vite/src/client.ts | 6 ++++ packages/vite/src/plugins/chunk-error.ts | 30 +++++++++++++++++++ packages/webpack/src/plugins/chunk.ts | 28 +++++++++++++++++ packages/webpack/src/webpack.ts | 5 ++++ test/basic.test.ts | 9 ++++++ test/fixtures/basic/nuxt.config.ts | 1 + test/fixtures/basic/pages/chunk-error.vue | 17 +++++++++++ test/fixtures/basic/pages/index.vue | 3 ++ test/fixtures/basic/plugins/chunk-error.ts | 3 ++ 13 files changed, 152 insertions(+) create mode 100644 packages/nuxt/src/app/plugins/chunk-reload.client.ts create mode 100644 packages/vite/src/plugins/chunk-error.ts create mode 100644 packages/webpack/src/plugins/chunk.ts create mode 100644 test/fixtures/basic/pages/chunk-error.vue create mode 100644 test/fixtures/basic/plugins/chunk-error.ts diff --git a/packages/nuxt/src/app/nuxt.ts b/packages/nuxt/src/app/nuxt.ts index 0a732cade3..74c7372c4b 100644 --- a/packages/nuxt/src/app/nuxt.ts +++ b/packages/nuxt/src/app/nuxt.ts @@ -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 diff --git a/packages/nuxt/src/app/plugins/chunk-reload.client.ts b/packages/nuxt/src/app/plugins/chunk-reload.client.ts new file mode 100644 index 0000000000..dd5abb8eb8 --- /dev/null +++ b/packages/nuxt/src/app/plugins/chunk-reload.client.ts @@ -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 = {} + 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 + } + }) +}) diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index 3d8dddab80..3fd4365507 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -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')) diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 69156f1be1..00694d45ac 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -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 * diff --git a/packages/vite/src/client.ts b/packages/vite/src/client.ts index 06fd9ea74b..43d7a92f51 100644 --- a/packages/vite/src/client.ts +++ b/packages/vite/src/client.ts @@ -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: { diff --git a/packages/vite/src/plugins/chunk-error.ts b/packages/vite/src/plugins/chunk-error.ts new file mode 100644 index 0000000000..3ed32bc850 --- /dev/null +++ b/packages/vite/src/plugins/chunk-error.ts @@ -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 + } + } + } +} diff --git a/packages/webpack/src/plugins/chunk.ts b/packages/webpack/src/plugins/chunk.ts new file mode 100644 index 0000000000..51ed59a39a --- /dev/null +++ b/packages/webpack/src/plugins/chunk.ts @@ -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 + ) + ) + } +} diff --git a/packages/webpack/src/webpack.ts b/packages/webpack/src/webpack.ts index b9874fafef..184d587ec3 100644 --- a/packages/webpack/src/webpack.ts +++ b/packages/webpack/src/webpack.ts @@ -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 diff --git a/test/basic.test.ts b/test/basic.test.ts index e57234c882..1b0e5fc6f3 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -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', () => { diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index 39a8ea0282..e562b0151f 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -149,6 +149,7 @@ export default defineNuxtConfig({ } }, experimental: { + emitRouteChunkError: 'reload', inlineSSRStyles: id => !!id && !id.includes('assets.vue'), componentIslands: true, reactivityTransform: true, diff --git a/test/fixtures/basic/pages/chunk-error.vue b/test/fixtures/basic/pages/chunk-error.vue new file mode 100644 index 0000000000..3c8178dab9 --- /dev/null +++ b/test/fixtures/basic/pages/chunk-error.vue @@ -0,0 +1,17 @@ + + + diff --git a/test/fixtures/basic/pages/index.vue b/test/fixtures/basic/pages/index.vue index 74a8eebe08..f2954cb365 100644 --- a/test/fixtures/basic/pages/index.vue +++ b/test/fixtures/basic/pages/index.vue @@ -14,6 +14,9 @@ Link + + Chunk error + Test diff --git a/test/fixtures/basic/plugins/chunk-error.ts b/test/fixtures/basic/plugins/chunk-error.ts new file mode 100644 index 0000000000..c81ebccdc4 --- /dev/null +++ b/test/fixtures/basic/plugins/chunk-error.ts @@ -0,0 +1,3 @@ +export default defineNuxtPlugin((nuxtApp) => { + nuxtApp.hook('app:chunkError', () => console.log('caught chunk load error')) +})