diff --git a/e2e/cases/server/environments-hmr/index.test.ts b/e2e/cases/server/environments-hmr/index.test.ts new file mode 100644 index 0000000000..2268a93d70 --- /dev/null +++ b/e2e/cases/server/environments-hmr/index.test.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs'; +import { join } from 'node:path'; +import { dev, gotoPage, rspackOnlyTest } from '@e2e/helper'; +import { expect, test } from '@playwright/test'; +import { pluginReact } from '@rsbuild/plugin-react'; + +const cwd = __dirname; + +rspackOnlyTest( + 'Multiple environments HMR should work correctly', + async ({ page, context }) => { + // HMR cases will fail in Windows + if (process.platform === 'win32') { + test.skip(); + } + + await fs.promises.cp(join(cwd, 'src'), join(cwd, 'test-temp-src'), { + recursive: true, + }); + + const rsbuild = await dev({ + cwd, + rsbuildConfig: { + plugins: [pluginReact()], + environments: { + web: { + source: { + entry: { + index: join(cwd, 'test-temp-src/index.ts'), + }, + }, + }, + web1: { + dev: { + // When generating outputs for multiple web environments, + // if assetPrefix is not added, file search conflicts will occur. + assetPrefix: 'auto', + }, + source: { + entry: { + main: join(cwd, 'test-temp-src/web1.js'), + }, + }, + output: { + distPath: { + root: 'dist/web1', + html: 'html1', + }, + }, + }, + }, + }, + }); + + const web1Page = await context.newPage(); + + await web1Page.goto(`http://localhost:${rsbuild.port}/web1/html1/main`); + + const locator1 = web1Page.locator('#test'); + await expect(locator1).toHaveText('Hello Rsbuild (web1)!'); + + await gotoPage(page, rsbuild); + + const locator = page.locator('#test'); + await expect(locator).toHaveText('Hello Rsbuild!'); + await expect(locator).toHaveCSS('color', 'rgb(255, 0, 0)'); + + const locatorKeep = page.locator('#test-keep'); + const keepNum = await locatorKeep.innerHTML(); + + // web1 live reload correctly and should not trigger index update + const web1JSPath = join(cwd, 'test-temp-src/web1.js'); + + await fs.promises.writeFile( + web1JSPath, + fs.readFileSync(web1JSPath, 'utf-8').replace('(web1)', '(web1-new)'), + ); + + await expect(locator1).toHaveText('Hello Rsbuild (web1)!'); + + await expect(locatorKeep.innerHTML()).resolves.toBe(keepNum); + + // index hmr correctly + const appPath = join(cwd, 'test-temp-src/App.tsx'); + + await fs.promises.writeFile( + appPath, + fs.readFileSync(appPath, 'utf-8').replace('Hello Rsbuild', 'Hello Test'), + ); + + await expect(locator).toHaveText('Hello Test!'); + + // #test-keep should unchanged when app.tsx HMR + await expect(locatorKeep.innerHTML()).resolves.toBe(keepNum); + + await rsbuild.close(); + }, +); diff --git a/e2e/cases/server/environments-hmr/src/App.css b/e2e/cases/server/environments-hmr/src/App.css new file mode 100644 index 0000000000..f49dc220b6 --- /dev/null +++ b/e2e/cases/server/environments-hmr/src/App.css @@ -0,0 +1,3 @@ +#test { + color: rgb(255, 0, 0); +} diff --git a/e2e/cases/server/environments-hmr/src/App.tsx b/e2e/cases/server/environments-hmr/src/App.tsx new file mode 100644 index 0000000000..44baa4fda4 --- /dev/null +++ b/e2e/cases/server/environments-hmr/src/App.tsx @@ -0,0 +1,4 @@ +import './App.css'; + +const App = () =>
Hello Rsbuild!
; +export default App; diff --git a/e2e/cases/server/environments-hmr/src/index.ts b/e2e/cases/server/environments-hmr/src/index.ts new file mode 100644 index 0000000000..0f9a1befc1 --- /dev/null +++ b/e2e/cases/server/environments-hmr/src/index.ts @@ -0,0 +1,17 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; + +const num = Math.ceil(Math.random() * 100); +const testEl = document.createElement('div'); +testEl.id = 'test-keep'; + +testEl.innerHTML = String(num); + +document.body.appendChild(testEl); + +const container = document.getElementById('root'); +if (container) { + const root = createRoot(container); + root.render(React.createElement(App)); +} diff --git a/e2e/cases/server/environments-hmr/src/web1.js b/e2e/cases/server/environments-hmr/src/web1.js new file mode 100644 index 0000000000..1fea4257ce --- /dev/null +++ b/e2e/cases/server/environments-hmr/src/web1.js @@ -0,0 +1,5 @@ +const testEl = document.createElement('div'); +testEl.id = 'test'; +testEl.innerHTML = 'Hello Rsbuild (web1)!'; + +document.body.appendChild(testEl); diff --git a/packages/core/src/client/hmr.ts b/packages/core/src/client/hmr.ts index d2da3570a5..f07b1ccfe4 100644 --- a/packages/core/src/client/hmr.ts +++ b/packages/core/src/client/hmr.ts @@ -7,6 +7,8 @@ import type { ClientConfig, StatsError } from '../types'; import { formatStatsMessages } from './format'; +const compilationName = RSBUILD_COMPILATION_NAME; + function formatURL({ port, protocol, @@ -24,6 +26,7 @@ function formatURL({ url.hostname = hostname; url.protocol = protocol; url.pathname = pathname; + url.searchParams.append('compilationName', compilationName); return url.toString(); } @@ -202,6 +205,11 @@ function onOpen() { function onMessage(e: MessageEvent) { const message = JSON.parse(e.data); + + if (message.compilationName && message.compilationName !== compilationName) { + return; + } + switch (message.type) { case 'hash': // Update the last compilation hash diff --git a/packages/core/src/client/types.d.ts b/packages/core/src/client/types.d.ts index 8f29ee35eb..87812a8bbd 100644 --- a/packages/core/src/client/types.d.ts +++ b/packages/core/src/client/types.d.ts @@ -5,3 +5,5 @@ declare let RSBUILD_CLIENT_CONFIG: ClientConfig; declare let RSBUILD_DEV_LIVE_RELOAD: boolean; declare let __webpack_hash__: string; + +declare let RSBUILD_COMPILATION_NAME: string; diff --git a/packages/core/src/server/compilerDevMiddleware.ts b/packages/core/src/server/compilerDevMiddleware.ts index 1d17a218b7..8e4d6735cc 100644 --- a/packages/core/src/server/compilerDevMiddleware.ts +++ b/packages/core/src/server/compilerDevMiddleware.ts @@ -88,7 +88,10 @@ export class CompilerDevMiddleware { type: string, data?: Record | string | boolean, ): void { - this.socketServer.sockWrite(type, data); + this.socketServer.sockWrite({ + type, + data, + }); } private setupDevMiddleware( @@ -98,8 +101,11 @@ export class CompilerDevMiddleware { const { devConfig, serverConfig } = this; const callbacks = { - onInvalid: () => { - this.socketServer.sockWrite('invalid'); + onInvalid: (compilationName?: string) => { + this.socketServer.sockWrite({ + type: 'invalid', + compilationName, + }); }, onDone: (stats: any) => { this.socketServer.updateStats(stats); diff --git a/packages/core/src/server/devMiddleware.ts b/packages/core/src/server/devMiddleware.ts index 81a23a3a25..7dc8c41f0d 100644 --- a/packages/core/src/server/devMiddleware.ts +++ b/packages/core/src/server/devMiddleware.ts @@ -5,7 +5,7 @@ import type { DevMiddlewareOptions } from '../provider/createCompiler'; import type { DevConfig, NextFunction } from '../types'; type ServerCallbacks = { - onInvalid: () => void; + onInvalid: (compilationName?: string) => void; onDone: (stats: any) => void; }; @@ -37,21 +37,8 @@ const isNodeCompiler = (compiler: { return false; }; -type CompilerTapFn void = () => void> = { - tap: (name: string, cb: CallBack) => void; -}; - export const setupServerHooks = ( - compiler: { - options: { - target?: Compiler['options']['target']; - }; - hooks: { - compile: CompilerTapFn; - invalid: CompilerTapFn; - done: CompilerTapFn; - }; - }, + compiler: Compiler, hookCallbacks: ServerCallbacks, ): void => { // TODO: node SSR HMR is not supported yet @@ -61,8 +48,12 @@ export const setupServerHooks = ( const { compile, invalid, done } = compiler.hooks; - compile.tap('rsbuild-dev-server', hookCallbacks.onInvalid); - invalid.tap('rsbuild-dev-server', hookCallbacks.onInvalid); + compile.tap('rsbuild-dev-server', () => + hookCallbacks.onInvalid(compiler.name), + ); + invalid.tap('rsbuild-dev-server', () => + hookCallbacks.onInvalid(compiler.name), + ); done.tap('rsbuild-dev-server', hookCallbacks.onDone); }; @@ -82,6 +73,7 @@ function applyHMREntry({ } new compiler.webpack.DefinePlugin({ + RSBUILD_COMPILATION_NAME: JSON.stringify(compiler.name!), RSBUILD_CLIENT_CONFIG: JSON.stringify(clientConfig), RSBUILD_DEV_LIVE_RELOAD: liveReload, }).apply(compiler); diff --git a/packages/core/src/server/socketServer.ts b/packages/core/src/server/socketServer.ts index 44159bd6a7..b85deedb46 100644 --- a/packages/core/src/server/socketServer.ts +++ b/packages/core/src/server/socketServer.ts @@ -1,5 +1,6 @@ import type { IncomingMessage } from 'node:http'; import type { Socket } from 'node:net'; +import { parse } from 'node:querystring'; import type Ws from 'ws'; import { getAllStatsErrors, getAllStatsWarnings } from '../helpers'; import { logger } from '../logger'; @@ -29,13 +30,15 @@ export class SocketServer { private readonly options: DevConfig; - private stats?: Stats; - private initialChunks?: Set; + private stats: Record; + private initialChunks: Record>; private timer: ReturnType | null = null; constructor(options: DevConfig) { this.options = options; + this.stats = {}; + this.initialChunks = {}; } public upgrade(req: IncomingMessage, sock: Socket, head: any): void { @@ -77,32 +80,55 @@ export class SocketServer { } }, 30000); - this.wsServer.on('connection', (socket) => { - this.onConnect(socket); + this.wsServer.on('connection', (socket, req) => { + // /rsbuild-hmr?compilationName=web + const queryStr = req.url ? req.url.split('?')[1] : ''; + + this.onConnect( + socket, + queryStr ? (parse(queryStr) as Record) : {}, + ); }); } public updateStats(stats: Stats): void { - this.stats = stats; - this.sendStats(); + const compilationName = stats.compilation.name!; + + this.stats[compilationName] = stats; + + this.sendStats({ + compilationName, + }); } // write message to each socket - public sockWrite( - type: string, - data?: Record | string | boolean, - ): void { + public sockWrite({ + type, + compilationName, + data, + }: { + type: string; + compilationName?: string; + data?: Record | string | boolean; + }): void { for (const socket of this.sockets) { - this.send(socket, JSON.stringify({ type, data })); + this.send(socket, JSON.stringify({ type, data, compilationName })); } } private singleWrite( socket: Ws, - type: string, - data?: Record | string | boolean, + { + type, + data, + compilationName, + }: { + type: string; + compilationName?: string; + data?: Record | string | boolean; + }, ) { - this.send(socket, JSON.stringify({ type, data })); + this.send(socket, JSON.stringify({ type, data, compilationName })); } public close(): void { @@ -116,7 +142,7 @@ export class SocketServer { } } - private onConnect(socket: Ws) { + private onConnect(socket: Ws, params: Record) { const connection = socket as ExtWebSocket; connection.isAlive = true; @@ -139,18 +165,24 @@ export class SocketServer { }); if (this.options.hmr || this.options.liveReload) { - this.singleWrite(connection, 'hot'); + this.singleWrite(connection, { + type: 'hot', + compilationName: params.compilationName, + }); } // send first stats to active client sock if stats exist if (this.stats) { - this.sendStats(true); + this.sendStats({ + force: true, + compilationName: params.compilationName, + }); } } // get standard stats - private getStats() { - const curStats = this.stats; + private getStats(name: string) { + const curStats = this.stats[name]; if (!curStats) { return null; @@ -173,8 +205,14 @@ export class SocketServer { } // determine what message should send by stats - private sendStats(force = false) { - const stats = this.getStats(); + private sendStats({ + force = false, + compilationName, + }: { + compilationName: string; + force?: boolean; + }) { + const stats = this.getStats(compilationName); // this should never happened if (!stats) { @@ -195,13 +233,19 @@ export class SocketServer { } } } + + const initialChunks = this.initialChunks[compilationName]; const shouldReload = Boolean(stats.entrypoints) && - Boolean(this.initialChunks) && - !isEqualSet(this.initialChunks as Set, newInitialChunks); - this.initialChunks = newInitialChunks; + Boolean(initialChunks) && + !isEqualSet(initialChunks, newInitialChunks); + + this.initialChunks[compilationName] = newInitialChunks; if (shouldReload) { - return this.sockWrite('content-changed'); + return this.sockWrite({ + type: 'content-changed', + compilationName, + }); } const shouldEmit = @@ -212,18 +256,36 @@ export class SocketServer { stats.assets.every((asset: any) => !asset.emitted); if (shouldEmit) { - return this.sockWrite('still-ok'); + return this.sockWrite({ + type: 'still-ok', + compilationName, + }); } - this.sockWrite('hash', stats.hash); + this.sockWrite({ + type: 'hash', + compilationName, + data: stats.hash, + }); if (stats.errorsCount) { - return this.sockWrite('errors', getAllStatsErrors(stats)); + return this.sockWrite({ + type: 'errors', + compilationName, + data: getAllStatsErrors(stats), + }); } if (stats.warningsCount) { - return this.sockWrite('warnings', getAllStatsWarnings(stats)); + return this.sockWrite({ + type: 'warnings', + compilationName, + data: getAllStatsWarnings(stats), + }); } - return this.sockWrite('ok'); + return this.sockWrite({ + type: 'ok', + compilationName, + }); } // send message to connecting socket diff --git a/packages/core/tests/server.test.ts b/packages/core/tests/server.test.ts index dd4b16a641..a9caa9ae2d 100644 --- a/packages/core/tests/server.test.ts +++ b/packages/core/tests/server.test.ts @@ -252,18 +252,6 @@ describe('test dev server', () => { ); expect(isOnDoneRegistered).toBeTruthy(); - - const isCompileHookRegistered = compiler.hooks.compile.taps.some( - (tap) => tap.fn === onInvalidFn, - ); - - expect(isCompileHookRegistered).toBeTruthy(); - - const isInvalidHookRegistered = compiler.hooks.invalid.taps.some( - (tap) => tap.fn === onInvalidFn, - ); - - expect(isInvalidHookRegistered).toBeTruthy(); }); test('should not setupServerHooks when compiler is server', () => { const compiler = rspack({