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({