From 71056a66608b295b9548e1dd02e80bbce291fe40 Mon Sep 17 00:00:00 2001 From: Oskar Lebuda Date: Mon, 7 Sep 2026 11:38:05 +0200 Subject: [PATCH] feat: allow HMR in server builds `hotReload: true` now opts a server build into hot reload as well, so a dev server that renders on the server can hot swap modules instead of restarting the whole process. Server builds stay opt-in: without the flag nothing changes, and `hotReload: false` still wins. Addresses both issues raised in the review of #32: - A server build imports `ssrRender`, but the generated template update callback called `api.rerender(id, render)`, which threw `ReferenceError: render is not defined`. `api.rerender` cannot handle `ssrRender` either - it assigns whatever it is given to `render` and re-renders the mounted instances, which a server build does not have. The new function is written back to the component definition instead, which is the object parent modules hold on to. - The imported-type watch hook only invalidated `clientCache`, so a server build kept rendering with the stale compiled script after an imported prop type changed. Both caches are invalidated now. Tests drive a real watching server build: they render through `vue/server-renderer`, edit a template or an imported type, apply the hot update the way a dev server would, and render again. --- .gitignore | 3 + src/hotReload.ts | 29 +++- src/index.ts | 12 +- src/plugin.ts | 4 +- src/resolveScript.ts | 11 ++ src/util.ts | 6 +- test/serverHotReload.spec.ts | 306 +++++++++++++++++++++++++++++++++++ 7 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 test/serverHotReload.spec.ts diff --git a/.gitignore b/.gitignore index c1e258b..9241894 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ TODOs.md coverage .vscode/* !.vscode/extensions.json + +# test scratch dirs +test/.tmp diff --git a/src/hotReload.ts b/src/hotReload.ts index 55a2338..e25cc08 100644 --- a/src/hotReload.ts +++ b/src/hotReload.ts @@ -2,7 +2,8 @@ export function genHotReloadCode( id: string, - templateRequest: string | undefined + templateRequest: string | undefined, + renderFnName: 'render' | 'ssrRender' = 'render' ): string { return ` /* hot reload */ @@ -13,12 +14,34 @@ if (module.hot) { if (!api.createRecord('${id}', __exports__)) { api.reload('${id}', __exports__) } - ${templateRequest ? genTemplateHotReloadCode(id, templateRequest) : ''} + ${ + templateRequest + ? genTemplateHotReloadCode(id, templateRequest, renderFnName) + : '' + } } ` } -function genTemplateHotReloadCode(id: string, request: string) { +function genTemplateHotReloadCode( + id: string, + request: string, + renderFnName: 'render' | 'ssrRender' +) { + // A server build imports `ssrRender`, which `api.rerender` cannot handle: it + // assigns whatever it is given to `render` and re-renders the mounted + // instances, and a server build has none (only the DOM renderer registers + // them). Write the new function back to the component definition instead - + // that object is what parent modules hold a reference to, so the next render + // on the server picks it up. + if (renderFnName === 'ssrRender') { + return ` + module.hot.accept(${request}, () => { + __exports__.ssrRender = ssrRender + }) +` + } + return ` module.hot.accept(${request}, () => { api.rerender('${id}', render) diff --git a/src/index.ts b/src/index.ts index 9e8d940..31225a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,14 @@ export interface VueLoaderOptions { customElement?: boolean | RegExp + /** + * Whether to generate hot reload code for the component. + * + * Enabled by default for client builds outside production. Set to `true` + * explicitly to opt a server build in as well - useful when a dev server + * renders on the server and wants the server bundle to hot swap modules + * instead of restarting the whole process. + */ hotReload?: boolean exposeFilename?: boolean /** @@ -180,7 +188,7 @@ export default function loader( // feature information const hasScoped = descriptor.styles.some((s) => s.scoped) const needsHotReload = - !isServer && + (!isServer || options.hotReload === true) && !isProduction && !!(descriptor.script || descriptor.scriptSetup || descriptor.template) && options.hotReload !== false @@ -420,7 +428,7 @@ export default function loader( } if (needsHotReload) { - code += genHotReloadCode(id, templateRequest) + code += genHotReloadCode(id, templateRequest, renderFnName) } code += `\n\nexport default __exports__` diff --git a/src/plugin.ts b/src/plugin.ts index 3570825..d6645b2 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -2,7 +2,7 @@ import * as qs from 'querystring' import type { VueLoaderOptions } from '.' import type { RuleSetRule, Compiler, RuleSetUse } from '@rspack/core' import { needHMR } from './util' -import { clientCache, typeDepToSFCMap } from './resolveScript' +import { invalidateScript, typeDepToSFCMap } from './resolveScript' import { compiler as vueCompiler } from './compiler' import { descriptorCache } from './descriptorCache' @@ -286,7 +286,7 @@ class VueLoaderPlugin { for (const sfc of affectedSFCs) { // bust script resolve cache const desc = descriptorCache.get(sfc) - if (desc) clientCache.delete(desc) + if (desc) invalidateScript(desc) // force update importing SFC // @ts-ignore compiler.fileTimestamps.set(sfc, { diff --git a/src/resolveScript.ts b/src/resolveScript.ts index b6a23fd..7e66fc2 100644 --- a/src/resolveScript.ts +++ b/src/resolveScript.ts @@ -14,6 +14,17 @@ const serverCache = new WeakMap() export const typeDepToSFCMap = new Map>() +/** + * Drop the compiled script of a descriptor, so that the next build recompiles + * it. Both caches are cleared because a single descriptor can be compiled for + * a client and a server build, and there is no way to tell here which one the + * stale entry belongs to. + */ +export function invalidateScript(descriptor: SFCDescriptor) { + clientCache.delete(descriptor) + serverCache.delete(descriptor) +} + /** * inline template mode can only be enabled if: * - is production (separate compilation needed for HMR during dev) diff --git a/src/util.ts b/src/util.ts index e0a1638..9ad08f9 100644 --- a/src/util.ts +++ b/src/util.ts @@ -22,7 +22,11 @@ export function needHMR( const isProduction = compilerOptions.mode === 'production' || process.env.NODE_ENV === 'production' - return !isServer && !isProduction && vueLoaderOptions.hotReload !== false + return ( + (!isServer || vueLoaderOptions.hotReload === true) && + !isProduction && + vueLoaderOptions.hotReload !== false + ) } export function resolveTemplateTSOptions( diff --git a/test/serverHotReload.spec.ts b/test/serverHotReload.spec.ts new file mode 100644 index 0000000..7c02991 --- /dev/null +++ b/test/serverHotReload.spec.ts @@ -0,0 +1,306 @@ +import * as fs from 'fs' +import * as path from 'path' +import webpack from 'webpack' +import { VueLoaderPlugin } from 'rspack-vue-loader' +import { bundle } from './utils' + +// hot reload code is wrapped in `if (module.hot)`, which the bundler drops +// unless HMR is actually enabled for the compilation +const enableHMR = (config: webpack.Configuration) => { + config.plugins = [ + ...(config.plugins ?? []), + new webpack.HotModuleReplacementPlugin(), + ] +} + +test('no hot reload code in a server build by default', async () => { + const { code } = await bundle({ + entry: 'basic.vue', + modify: enableHMR, + vue: { + isServerBuild: true, + }, + }) + + expect(code).not.toContain('__exports__.__hmrId') +}) + +test('opt a server build into hot reload with hotReload: true', async () => { + const { code } = await bundle({ + entry: 'basic.vue', + modify: enableHMR, + vue: { + isServerBuild: true, + hotReload: true, + }, + }) + + expect(code).toContain('__exports__.__hmrId') +}) + +test('hotReload: false still wins over a server build opt-in', async () => { + const { code } = await bundle({ + entry: 'basic.vue', + modify: enableHMR, + vue: { + isServerBuild: true, + hotReload: false, + }, + }) + + expect(code).not.toContain('__exports__.__hmrId') +}) + +test('swap ssrRender on a template update in a server build', async () => { + const { code } = await bundle({ + entry: 'basic.vue', + modify: enableHMR, + vue: { + isServerBuild: true, + hotReload: true, + }, + }) + + // the bundler rewrites the imported `ssrRender` into its own reference + expect(code).toContain('__exports__.ssrRender =') + // `api.rerender` assigns to `render`, which a server build never imports + expect(code).not.toContain('api.rerender') +}) + +test('keep using api.rerender on a template update in a client build', async () => { + const { code } = await bundle({ + entry: 'basic.vue', + modify: enableHMR, + }) + + expect(code).toContain('api.rerender') + expect(code).not.toContain('__exports__.ssrRender') +}) + +/** + * The tests below drive a real watching server build: they render the bundle + * with `vue/server-renderer`, edit a file, apply the hot update the way a dev + * server would, and render again. + */ + +const tmpRoot = path.join(__dirname, '.tmp') + +const ENTRY = ` +const { createSSRApp } = require('vue') +const { renderToString } = require('vue/server-renderer') + +// required per render, so that a re-executed module is picked up +exports.render = () => renderToString(createSSRApp(require('./target.vue').default)) +exports.hot = module.hot +` + +interface ServerBuild { + /** resolves once the next compilation has finished */ + nextBuild: () => Promise + /** applies the pending hot update inside the running bundle */ + applyUpdate: () => Promise + render: () => Promise + write: (name: string, content: string) => void + close: () => Promise +} + +async function startServerBuild(files: Record) { + const dir = fs.mkdtempSync(path.join(tmpRoot, 'server-hmr-')) + const write = (name: string, content: string) => + fs.writeFileSync(path.join(dir, name), content) + + write('entry.js', ENTRY) + for (const [name, content] of Object.entries(files)) { + write(name, content) + } + + const compiler = webpack({ + mode: 'development', + devtool: false, + target: 'node', + context: dir, + entry: './entry.js', + output: { + path: path.join(dir, 'dist'), + filename: 'bundle.js', + library: { type: 'commonjs2' }, + }, + // share Vue with the test process, so that the bundle talks to the same + // `__VUE_HMR_RUNTIME__` + externals: { + vue: 'commonjs vue', + 'vue/server-renderer': 'commonjs vue/server-renderer', + }, + resolve: { + extensions: ['.js', '.ts'], + }, + resolveLoader: { + alias: { + 'rspack-vue-loader': require.resolve('../dist'), + }, + }, + module: { + rules: [ + { + test: /\.vue$/, + use: [ + { + loader: 'rspack-vue-loader', + options: { + isServerBuild: true, + hotReload: true, + experimentalInlineMatchResource: Boolean( + process.env.INLINE_MATCH_RESOURCE + ), + }, + }, + ], + }, + { + test: /\.ts$/, + loader: require.resolve('ts-loader-v9'), + options: { + transpileOnly: true, + appendTsSuffixTo: [/\.vue$/], + }, + }, + ], + }, + plugins: [new VueLoaderPlugin(), new webpack.HotModuleReplacementPlugin()], + }) + + const finished: webpack.Stats[] = [] + const waiting: ((stats: webpack.Stats) => void)[] = [] + compiler.hooks.done.tap('server-hmr-test', (stats) => { + const waiter = waiting.shift() + if (waiter) { + waiter(stats) + } else { + finished.push(stats) + } + }) + + const nextBuild = () => + new Promise((resolve) => { + const stats = finished.shift() + if (stats) { + resolve(stats) + } else { + waiting.push(resolve) + } + }).then((stats) => { + if (stats.hasErrors()) { + throw new Error(stats.toString({ all: false, errors: true })) + } + return stats + }) + + const watching = compiler.watch({ aggregateTimeout: 50, poll: 100 }, () => {}) + + await nextBuild() + + const bundlePath = path.join(dir, 'dist', 'bundle.js') + const bundled = require(bundlePath) + + const build: ServerBuild = { + nextBuild, + applyUpdate: () => bundled.hot.check(true).then(() => undefined), + render: () => bundled.render(), + write, + close: () => + new Promise((resolve, reject) => { + delete require.cache[require.resolve(bundlePath)] + watching.close((err) => (err ? reject(err) : resolve())) + }).then(() => { + fs.rmSync(dir, { recursive: true, force: true }) + }), + } + return build +} + +const openBuilds: ServerBuild[] = [] + +beforeAll(() => { + fs.mkdirSync(tmpRoot, { recursive: true }) +}) + +afterEach(async () => { + await Promise.all(openBuilds.splice(0).map((build) => build.close())) +}) + +afterAll(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }) +}) + +async function startTrackedServerBuild(files: Record) { + const build = await startServerBuild(files) + openBuilds.push(build) + return build +} + +test('hot swap ssrRender when the template of a server build changes', async () => { + const build = await startTrackedServerBuild({ + 'target.vue': ` + + + +`, + }) + + expect(await build.render()).toBe('
hello
') + + build.write( + 'target.vue', + ` + + + +` + ) + + await build.nextBuild() + await build.applyUpdate() + + expect(await build.render()).toBe('

hello

') +}) + +test('recompile a server build when an imported type changes', async () => { + const build = await startTrackedServerBuild({ + 'types.ts': `export interface Props { msg?: string }\n`, + 'target.vue': ` + + + +`, + }) + + expect(await build.render()).toBe('
msg
') + + build.write( + 'types.ts', + `export interface Props { msg?: string; id?: number }\n` + ) + + await build.nextBuild() + await build.applyUpdate() + + expect(await build.render()).toBe('
msg,id
') +})