diff --git a/packages/nuxt/src/pages/module.ts b/packages/nuxt/src/pages/module.ts index 44cda863c3..8769ba2ae5 100644 --- a/packages/nuxt/src/pages/module.ts +++ b/packages/nuxt/src/pages/module.ts @@ -455,6 +455,8 @@ export default defineNuxtModule({ addBuildPlugin(PageMetaPlugin({ dev: nuxt.options.dev, sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client, + isPage, + routesPath: resolve(nuxt.options.buildDir, 'routes.mjs'), })) }) @@ -499,13 +501,13 @@ export default defineNuxtModule({ addTemplate({ filename: 'routes.mjs', getContents ({ app }) { - if (!app.pages) { return 'export default []' } + if (!app.pages) { return ROUTES_HMR_CODE + 'export default []' } const { routes, imports } = normalizeRoutes(app.pages, new Set(), { serverComponentRuntime, clientComponentRuntime, overrideMeta: !!nuxt.options.experimental.scanPageMeta, }) - return [...imports, `export default ${routes}`].join('\n') + return ROUTES_HMR_CODE + [...imports, `export default ${routes}`].join('\n') }, }) @@ -610,3 +612,26 @@ export default defineNuxtModule({ }) }, }) + +const ROUTES_HMR_CODE = /* js */` +if (import.meta.hot) { + import.meta.hot.accept((mod) => { + const router = import.meta.hot.data.router + if (!router) { + import.meta.hot.invalidate('[nuxt] Cannot replace routes because there is no active router. Reloading.') + return + } + router.clearRoutes() + for (const route of mod.default || mod) { + router.addRoute(route) + } + router.replace('') + }) +} + +export function handleHotUpdate(_router) { + if (import.meta.hot) { + import.meta.hot.data.router = _router + } +} +` diff --git a/packages/nuxt/src/pages/plugins/page-meta.ts b/packages/nuxt/src/pages/plugins/page-meta.ts index fec8bbb54b..56e88c8f3a 100644 --- a/packages/nuxt/src/pages/plugins/page-meta.ts +++ b/packages/nuxt/src/pages/plugins/page-meta.ts @@ -13,6 +13,8 @@ import { parseAndWalk, withLocations } from '../../core/utils/parse' interface PageMetaPluginOptions { dev?: boolean sourcemap?: boolean + isPage?: (file: string) => boolean + routesPath?: string } const HAS_MACRO_RE = /\bdefinePageMeta\s*\(\s*/ @@ -22,6 +24,11 @@ const __nuxt_page_meta = null export default __nuxt_page_meta ` +const CODE_DEV_EMPTY = ` +const __nuxt_page_meta = {} +export default __nuxt_page_meta +` + const CODE_HMR = ` // Vite if (import.meta.hot) { @@ -89,11 +96,11 @@ export const PageMetaPlugin = (options: PageMetaPluginOptions = {}) => createUnp if (!hasMacro && !code.includes('export { default }') && !code.includes('__nuxt_page_meta')) { if (!code) { - s.append(CODE_EMPTY + (options.dev ? CODE_HMR : '')) + s.append(options.dev ? (CODE_DEV_EMPTY + CODE_HMR) : CODE_EMPTY) const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href)) logger.error(`The file \`${pathname}\` is not a valid page as it has no content.`) } else { - s.overwrite(0, code.length, CODE_EMPTY + (options.dev ? CODE_HMR : '')) + s.overwrite(0, code.length, options.dev ? (CODE_DEV_EMPTY + CODE_HMR) : CODE_EMPTY) } return result() @@ -147,19 +154,23 @@ export const PageMetaPlugin = (options: PageMetaPluginOptions = {}) => createUnp }) if (!s.hasChanged() && !code.includes('__nuxt_page_meta')) { - s.overwrite(0, code.length, CODE_EMPTY + (options.dev ? CODE_HMR : '')) + s.overwrite(0, code.length, options.dev ? (CODE_DEV_EMPTY + CODE_HMR) : CODE_EMPTY) } return result() }, vite: { handleHotUpdate: { - order: 'pre', - handler: ({ modules }) => { - // Remove macro file from modules list to prevent HMR overrides - const index = modules.findIndex(i => i.id?.includes('?macro=true')) - if (index !== -1) { - modules.splice(index, 1) + order: 'post', + handler: ({ file, modules, server }) => { + if (options.isPage?.(file)) { + const macroModule = server.moduleGraph.getModuleById(file + '?macro=true') + const routesModule = server.moduleGraph.getModuleById('virtual:nuxt:' + options.routesPath) + return [ + ...modules, + ...macroModule ? [macroModule] : [], + ...routesModule ? [routesModule] : [], + ] } }, }, diff --git a/packages/nuxt/src/pages/runtime/plugins/router.ts b/packages/nuxt/src/pages/runtime/plugins/router.ts index bb41746480..0bf42801ef 100644 --- a/packages/nuxt/src/pages/runtime/plugins/router.ts +++ b/packages/nuxt/src/pages/runtime/plugins/router.ts @@ -17,7 +17,7 @@ import { navigateTo } from '#app/composables/router' // @ts-expect-error virtual file import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs' // @ts-expect-error virtual file -import _routes from '#build/routes' +import _routes, { handleHotUpdate } from '#build/routes' import routerOptions from '#build/router.options' // @ts-expect-error virtual file import { globalMiddleware, namedMiddleware } from '#build/middleware' @@ -87,6 +87,8 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({ routes, }) + handleHotUpdate(router) + if (import.meta.client && 'scrollRestoration' in window.history) { window.history.scrollRestoration = 'auto' } diff --git a/packages/vite/src/vite-node.ts b/packages/vite/src/vite-node.ts index 5652d0c313..2f2c054cda 100644 --- a/packages/vite/src/vite-node.ts +++ b/packages/vite/src/vite-node.ts @@ -39,33 +39,21 @@ export function viteNodePlugin (ctx: ViteBuildContext): VitePlugin { name: 'nuxt:vite-node-server', enforce: 'post', configureServer (server) { - function invalidateVirtualModules () { - for (const [id, mod] of server.moduleGraph.idToModuleMap) { - if (id.startsWith('virtual:') || id.startsWith('\0virtual:')) { + server.middlewares.use('/__nuxt_vite_node__', toNodeListener(createViteNodeApp(ctx, invalidates))) + + // invalidate changed virtual modules when templates are regenerated + ctx.nuxt.hook('app:templatesGenerated', (_app, changedTemplates) => { + for (const template of changedTemplates) { + const mods = server.moduleGraph.getModulesByFile(`virtual:nuxt:${template.dst}`) + + for (const mod of mods || []) { markInvalidate(mod) } } - - if (ctx.nuxt.apps.default) { - for (const template of ctx.nuxt.apps.default.templates) { - markInvalidates(server.moduleGraph.getModulesByFile(template.dst!)) - } - } - } - - server.middlewares.use('/__nuxt_vite_node__', toNodeListener(createViteNodeApp(ctx, invalidates))) - - // Invalidate all virtual modules when templates are regenerated - ctx.nuxt.hook('app:templatesGenerated', () => { - invalidateVirtualModules() }) server.watcher.on('all', (event, file) => { markInvalidates(server.moduleGraph.getModulesByFile(normalize(file))) - // Invalidate all virtual modules when a file is added or removed - if (event === 'add' || event === 'unlink') { - invalidateVirtualModules() - } }) }, } diff --git a/packages/vite/src/vite.ts b/packages/vite/src/vite.ts index 9cf99b7072..6d86f0ba9e 100644 --- a/packages/vite/src/vite.ts +++ b/packages/vite/src/vite.ts @@ -210,10 +210,11 @@ export const bundle: NuxtBuilder['bundle'] = async (nuxt) => { nuxt.hook('vite:serverCreated', (server: vite.ViteDevServer, env) => { // Invalidate virtual modules when templates are re-generated - ctx.nuxt.hook('app:templatesGenerated', () => { - for (const [id, mod] of server.moduleGraph.idToModuleMap) { - if (id.startsWith('virtual:') || id.startsWith('\0virtual:')) { + ctx.nuxt.hook('app:templatesGenerated', (_app, changedTemplates) => { + for (const template of changedTemplates) { + for (const mod of server.moduleGraph.getModulesByFile(`virtual:nuxt:${template.dst}`) || []) { server.moduleGraph.invalidateModule(mod) + server.reloadModule(mod) } } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index caee1da1af..6eaaa35d7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1114,6 +1114,12 @@ importers: specifier: latest version: 4.5.0(vue@3.5.13(typescript@5.6.3)) + test/fixtures/hmr: + dependencies: + nuxt: + specifier: workspace:* + version: link:../../../packages/nuxt + test/fixtures/minimal: dependencies: nuxt: diff --git a/test/fixtures/basic/components/islands/HmrComponent.vue b/test/fixtures/hmr/components/islands/HmrComponent.vue similarity index 50% rename from test/fixtures/basic/components/islands/HmrComponent.vue rename to test/fixtures/hmr/components/islands/HmrComponent.vue index cbfae371e7..7ab0ba49b5 100644 --- a/test/fixtures/basic/components/islands/HmrComponent.vue +++ b/test/fixtures/hmr/components/islands/HmrComponent.vue @@ -3,7 +3,8 @@ const hmrId = ref(0) diff --git a/test/fixtures/hmr/nuxt.config.ts b/test/fixtures/hmr/nuxt.config.ts new file mode 100644 index 0000000000..d0f5ba26e0 --- /dev/null +++ b/test/fixtures/hmr/nuxt.config.ts @@ -0,0 +1,10 @@ +export default defineNuxtConfig({ + builder: process.env.TEST_BUILDER as 'webpack' | 'rspack' | 'vite' ?? 'vite', + experimental: { + asyncContext: process.env.TEST_CONTEXT === 'async', + appManifest: process.env.TEST_MANIFEST !== 'manifest-off', + renderJsonPayloads: process.env.TEST_PAYLOAD !== 'js', + inlineRouteRules: true, + }, + compatibilityDate: '2024-06-28', +}) diff --git a/test/fixtures/hmr/package.json b/test/fixtures/hmr/package.json new file mode 100644 index 0000000000..b97a566e9c --- /dev/null +++ b/test/fixtures/hmr/package.json @@ -0,0 +1,10 @@ +{ + "private": true, + "name": "fixture-hmr", + "scripts": { + "build": "nuxi build" + }, + "dependencies": { + "nuxt": "workspace:*" + } +} diff --git a/test/fixtures/hmr/pages/index.vue b/test/fixtures/hmr/pages/index.vue new file mode 100644 index 0000000000..b0f4d634eb --- /dev/null +++ b/test/fixtures/hmr/pages/index.vue @@ -0,0 +1,21 @@ + + + diff --git a/test/fixtures/hmr/pages/page-meta.vue b/test/fixtures/hmr/pages/page-meta.vue new file mode 100644 index 0000000000..3efc8ac17d --- /dev/null +++ b/test/fixtures/hmr/pages/page-meta.vue @@ -0,0 +1,11 @@ + + + diff --git a/test/fixtures/hmr/pages/route-rules.vue b/test/fixtures/hmr/pages/route-rules.vue new file mode 100644 index 0000000000..52a5dda9d2 --- /dev/null +++ b/test/fixtures/hmr/pages/route-rules.vue @@ -0,0 +1,13 @@ + + + diff --git a/test/fixtures/hmr/pages/routes/index.vue b/test/fixtures/hmr/pages/routes/index.vue new file mode 100644 index 0000000000..aefe230be2 --- /dev/null +++ b/test/fixtures/hmr/pages/routes/index.vue @@ -0,0 +1,7 @@ + diff --git a/test/fixtures/basic/pages/server-component-hmr.vue b/test/fixtures/hmr/pages/server-component.vue similarity index 100% rename from test/fixtures/basic/pages/server-component-hmr.vue rename to test/fixtures/hmr/pages/server-component.vue diff --git a/test/fixtures/hmr/tsconfig.json b/test/fixtures/hmr/tsconfig.json new file mode 100644 index 0000000000..4b34df1571 --- /dev/null +++ b/test/fixtures/hmr/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} diff --git a/test/hmr.test.ts b/test/hmr.test.ts index ff2783cb66..b3a1ae3c9a 100644 --- a/test/hmr.test.ts +++ b/test/hmr.test.ts @@ -5,7 +5,7 @@ import { isWindows } from 'std-env' import { join } from 'pathe' import { $fetch as _$fetch, fetch, setup } from '@nuxt/test-utils/e2e' -import { expectWithPolling, renderPage } from './utils' +import { expectNoErrorsOrWarnings, expectWithPolling, renderPage } from './utils' // TODO: update @nuxt/test-utils const $fetch = _$fetch as import('nitro/types').$Fetch @@ -14,7 +14,7 @@ const isWebpack = process.env.TEST_BUILDER === 'webpack' || process.env.TEST_BUI // TODO: fix HMR on Windows if (process.env.TEST_ENV !== 'built' && !isWindows) { - const fixturePath = fileURLToPath(new URL('./fixtures-temp/basic', import.meta.url)) + const fixturePath = fileURLToPath(new URL('./fixtures-temp/hmr', import.meta.url)) await setup({ rootDir: fixturePath, dev: true, @@ -26,127 +26,143 @@ if (process.env.TEST_ENV !== 'built' && !isWindows) { }, }) + const indexVue = await fsp.readFile(join(fixturePath, 'pages/index.vue'), 'utf8') + describe('hmr', () => { it('should work', async () => { const { page, pageErrors, consoleLogs } = await renderPage('/') - expect(await page.title()).toBe('Basic fixture') - expect((await page.$('.sugar-counter').then(r => r!.textContent()))!.trim()) - .toEqual('Sugar Counter 12 x 2 = 24 Inc') + expect(await page.title()).toBe('HMR fixture') + expect(await page.getByTestId('count').textContent()).toBe('1') // reactive - await page.$('.sugar-counter button').then(r => r!.click()) - expect((await page.$('.sugar-counter').then(r => r!.textContent()))!.trim()) - .toEqual('Sugar Counter 13 x 2 = 26 Inc') + await page.getByRole('button').click() + expect(await page.getByTestId('count').textContent()).toBe('2') // modify file - let indexVue = await fsp.readFile(join(fixturePath, 'pages/index.vue'), 'utf8') - indexVue = indexVue - .replace('Basic fixture', 'Basic fixture HMR') - .replace('

Hello Nuxt 3!

', '

Hello Nuxt 3! HMR

') - indexVue += '' - await fsp.writeFile(join(fixturePath, 'pages/index.vue'), indexVue) + let newContents = indexVue + .replace('HMR fixture', 'HMR fixture HMR') + .replace('

Home page

', '

Home page - but not as you knew it

') + newContents += '' + await fsp.writeFile(join(fixturePath, 'pages/index.vue'), newContents) - await expectWithPolling( - () => page.title(), - 'Basic fixture HMR', - ) + await expectWithPolling(() => page.title(), 'HMR fixture HMR') // content HMR - const h1 = await page.$('h1') - expect(await h1!.textContent()).toBe('Hello Nuxt 3! HMR') + const h1 = page.getByRole('heading') + expect(await h1!.textContent()).toBe('Home page - but not as you knew it') // style HMR - const h1Color = await h1!.evaluate(el => window.getComputedStyle(el).getPropertyValue('color')) + const h1Color = await h1.evaluate(el => window.getComputedStyle(el).getPropertyValue('color')) expect(h1Color).toMatchInlineSnapshot('"rgb(255, 0, 0)"') // ensure no errors - const consoleLogErrors = consoleLogs.filter(i => i.type === 'error') - const consoleLogWarnings = consoleLogs.filter(i => i.type === 'warn') + expectNoErrorsOrWarnings(consoleLogs) expect(pageErrors).toEqual([]) - expect(consoleLogErrors).toEqual([]) - expect(consoleLogWarnings).toEqual([]) await page.close() - }, 60_000) + }) it('should detect new routes', async () => { - await expectWithPolling( - () => $fetch('/catchall/some-404').then(r => r.includes('catchall at some-404')).catch(() => null), - true, - ) + const res = await fetch('/some-404') + expect(res.status).toBe(404) // write new page route - const indexVue = await fsp.readFile(join(fixturePath, 'pages/index.vue'), 'utf8') - await fsp.writeFile(join(fixturePath, 'pages/catchall/some-404.vue'), indexVue) - - await expectWithPolling( - () => $fetch('/catchall/some-404').then(r => r.includes('Hello Nuxt 3')).catch(() => null), - true, - ) + await fsp.writeFile(join(fixturePath, 'pages/some-404.vue'), indexVue) + await expectWithPolling(() => $fetch('/some-404').then(r => r.includes('Home page')).catch(() => null), true) }) it('should hot reload route rules', async () => { - await expectWithPolling( - () => fetch('/route-rules/inline').then(r => r.headers.get('x-extend') === 'added in routeRules').catch(() => null), - true, - ) + await expectWithPolling(() => fetch('/route-rules').then(r => r.headers.get('x-extend')).catch(() => null), 'added in routeRules') // write new page route - const file = await fsp.readFile(join(fixturePath, 'pages/route-rules/inline.vue'), 'utf8') - await fsp.writeFile(join(fixturePath, 'pages/route-rules/inline.vue'), file.replace('added in routeRules', 'edited in dev')) + const file = await fsp.readFile(join(fixturePath, 'pages/route-rules.vue'), 'utf8') + await fsp.writeFile(join(fixturePath, 'pages/route-rules.vue'), file.replace('added in routeRules', 'edited in dev')) - await expectWithPolling( - () => fetch('/route-rules/inline').then(r => r.headers.get('x-extend') === 'edited in dev').catch(() => null), - true, - ) + await expectWithPolling(() => fetch('/route-rules').then(r => r.headers.get('x-extend')).catch(() => null), 'edited in dev') }) it('should HMR islands', async () => { - const { page, pageErrors, consoleLogs } = await renderPage('/server-component-hmr') + const { page, pageErrors, consoleLogs } = await renderPage('/server-component') - let hmrId = 0 - const resolveHmrId = async () => { - const node = await page.$('#hmr-id') - const text = await node?.innerText() || '' - return Number(text.trim().split(':')[1]?.trim() || '') - } const componentPath = join(fixturePath, 'components/islands/HmrComponent.vue') - const triggerHmr = async () => fsp.writeFile( - componentPath, - (await fsp.readFile(componentPath, 'utf8')) - .replace(`ref(${hmrId++})`, `ref(${hmrId})`), - ) + const componentContents = await fsp.readFile(componentPath, 'utf8') + const triggerHmr = (number: string) => fsp.writeFile(componentPath, componentContents.replace('ref(0)', `ref(${number})`)) // initial state - await expectWithPolling( - resolveHmrId, - 0, - ) + await expectWithPolling(async () => await page.getByTestId('hmr-id').innerText(), '0') // first edit - await triggerHmr() - await expectWithPolling( - resolveHmrId, - 1, - ) + await triggerHmr('1') + await expectWithPolling(async () => await page.getByTestId('hmr-id').innerText(), '1') // just in-case - await triggerHmr() - await expectWithPolling( - resolveHmrId, - 2, - ) + await triggerHmr('2') + await expectWithPolling(async () => await page.getByTestId('hmr-id').innerText(), '2') // ensure no errors - const consoleLogErrors = consoleLogs.filter(i => i.type === 'error') - const consoleLogWarnings = consoleLogs.filter(i => i.type === 'warn') + expectNoErrorsOrWarnings(consoleLogs) expect(pageErrors).toEqual([]) - expect(consoleLogErrors).toEqual([]) - expect(consoleLogWarnings).toEqual([]) await page.close() - }, 60_000) + }) + + it.skipIf(isWebpack)('should HMR page meta', async () => { + const { page, pageErrors, consoleLogs } = await renderPage('/page-meta') + + const pagePath = join(fixturePath, 'pages/page-meta.vue') + const pageContents = await fsp.readFile(pagePath, 'utf8') + + expect(JSON.parse(await page.getByTestId('meta').textContent() || '{}')).toStrictEqual({ some: 'stuff' }) + const initialConsoleLogs = structuredClone(consoleLogs) + + await fsp.writeFile(pagePath, pageContents.replace(`some: 'stuff'`, `some: 'other stuff'`)) + + await expectWithPolling(async () => await page.getByTestId('meta').textContent() || '{}', JSON.stringify({ some: 'other stuff' }, null, 2)) + expect(consoleLogs).toStrictEqual([ + ...initialConsoleLogs, + { + 'text': '[vite] hot updated: /pages/page-meta.vue', + 'type': 'debug', + }, + { + 'text': '[vite] hot updated: /pages/page-meta.vue?macro=true', + 'type': 'debug', + }, + { + 'text': `[vite] hot updated: /@id/virtual:nuxt:${fixturePath}/.nuxt/routes.mjs`, + 'type': 'debug', + }, + ]) + + // ensure no errors + expectNoErrorsOrWarnings(consoleLogs) + expect(pageErrors).toEqual([]) + + await page.close() + }) + + it.skipIf(isWebpack)('should HMR routes', async () => { + const { page, pageErrors, consoleLogs } = await renderPage('/routes') + + await fsp.writeFile(join(fixturePath, 'pages/routes/non-existent.vue'), ``) + + await page.getByRole('link').click() + await expectWithPolling(() => page.getByTestId('contents').textContent(), 'A new route!') + + for (const log of consoleLogs) { + if (log.text.includes('No match found for location with path "/routes/non-existent"')) { + // we expect this warning before the routes are updated + log.type = 'debug' + } + } + + // ensure no errors + expectNoErrorsOrWarnings(consoleLogs) + expect(pageErrors).toEqual([]) + + await page.close() + }) }) } else { describe.skip('hmr', () => {}) diff --git a/test/utils.ts b/test/utils.ts index 3e21fb0c27..4fbba7ff87 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -57,14 +57,18 @@ export async function expectNoClientErrors (path: string) { const { page, pageErrors, consoleLogs } = (await renderPage(path))! + expect(pageErrors).toEqual([]) + expectNoErrorsOrWarnings(consoleLogs) + + await page.close() +} + +export function expectNoErrorsOrWarnings (consoleLogs: Array<{ type: string, text: string }>) { const consoleLogErrors = consoleLogs.filter(i => i.type === 'error') const consoleLogWarnings = consoleLogs.filter(i => i.type === 'warning') - expect(pageErrors).toEqual([]) expect(consoleLogErrors).toEqual([]) expect(consoleLogWarnings).toEqual([]) - - await page.close() } export async function gotoPath (page: Page, path: string) {