diff --git a/.changeset/silent-parents-sin.md b/.changeset/silent-parents-sin.md
new file mode 100644
index 000000000000..ffdc2f378573
--- /dev/null
+++ b/.changeset/silent-parents-sin.md
@@ -0,0 +1,6 @@
+---
+'@modern-js/utils': patch
+---
+
+fix: invalidate nested ESM chunks in dev mode
+fix:在开发模式下正确使嵌套的 ESM chunks 失效
diff --git a/docs/debug/2026-03-21-ssr-esm-cache-invalidation-design-notes.md b/docs/debug/2026-03-21-ssr-esm-cache-invalidation-design-notes.md
new file mode 100644
index 000000000000..b9fef34cdc47
--- /dev/null
+++ b/docs/debug/2026-03-21-ssr-esm-cache-invalidation-design-notes.md
@@ -0,0 +1,584 @@
+# SSR ESM cache invalidation 设计复盘笔记
+
+## 背景
+
+Issue #8373 的现象是:
+
+- dev + SSR + ESM 模式下
+- 修改页面文件后刷新浏览器
+- 服务端渲染结果还是旧内容
+- 客户端拿到新内容
+- 最终触发 hydration mismatch
+
+这个问题看上去像“ESM 缓存没有失效”,但真正麻烦的地方不是顶层入口 bundle,而是入口 bundle 继续加载的子 chunk。
+
+## 一句话结论
+
+这次修复不是去改 rspack 本身,也不是直接改正式产物文件,而是:
+
+- 读取 SSR bundle
+- 在运行时把 rspack 产物里的 nested chunk dynamic import 改写成带时间戳的形式
+- 写一份临时 patched bundle
+- 用 Node 原生 `import()` 执行这份临时文件
+- 执行结束后删除临时文件
+
+核心目的是:不仅让入口 bundle 失效,还让它内部继续 import 的子 chunk 也一起失效。
+
+---
+
+## 修复前的执行链路
+
+### 现象流程图
+
+```text
+你修改了 page.tsx
+ |
+ v
+Modern dev 重新构建 SSR 入口 bundle
+dist/bundles/index.js
+ |
+ v
+Node 重新 import 入口 bundle
+import("index.js?t=NEW")
+ |
+ v
+入口 bundle 运行时继续加载子 chunk
+import("./" + __webpack_require__.u(chunkId))
+ |
+ v
+子 chunk URL 没变
+例如 ./page.js
+ |
+ v
+Node ESM cache 命中旧的 page.js
+ |
+ +--> 服务端 SSR 还是旧内容
+ |
+ +--> 客户端拿到新内容
+ |
+ v
+Hydration mismatch
+```
+
+### 关键判断
+
+真正的问题不是“入口 bundle 没重新加载”,而是:
+
+```text
+入口 bundle 重新加载了
+但是它内部继续 import 的子 chunk 没有变成新 URL
+因此子 chunk 仍然命中了旧的 ESM cache
+```
+
+也就是说,顶层 `?t=` 已经存在,但它只解决了入口模块失效,不解决 nested chunk 失效。
+
+---
+
+## 我最后采用的修复思路
+
+### 运行时 patch 思路图
+
+```text
+read index.js
+ |
+ v
+找到 rspack 产物里的这段代码
+import("./" + __webpack_require__.u(chunkId))
+ |
+ v
+改写成
+import("./" + __webpack_require__.u(chunkId) + "?t=NEW")
+ |
+ v
+把改写后的内容写到临时 .mjs 文件
+ |
+ v
+Node import 这个临时文件
+ |
+ v
+临时文件内部继续加载子 chunk 时
+变成 ./page.js?t=NEW
+ |
+ v
+Node 把它视为新模块
+ |
+ v
+服务端 SSR 拿到新内容
+ |
+ v
+不再 hydration mismatch
+```
+
+### 为什么要写临时文件
+
+这是我当时做方案选择时一个非常重要的点。
+
+Node 原生 `import()` 不是拿源码字符串执行的,它需要的是:
+
+- 一个模块 URL
+- 或者一个文件路径
+
+我如果已经把 bundle 内容改写好了,但还想继续保留:
+
+- Node 原生 ESM 语义
+- 相对路径解析能力
+- nested dynamic import 的正常行为
+
+那么最短路径就是:
+
+```text
+改字符串 -> 落一个临时 .mjs -> 交给原生 import() 执行
+```
+
+所以这里“写文件”不是为了持久修改产物,而是为了让 Node 正常把这份 patched 源码当成 ESM 模块执行。
+
+### 重要澄清
+
+我没有直接篡改 rspack 的正式输出文件。
+
+实际做的是:
+
+- 读正式 bundle
+- 在内存里 patch
+- 写一个短生命周期的临时副本
+- import 临时副本
+- 再删除临时副本
+
+这更接近“运行时执行补丁”,不是“改构建产物本体”。
+
+---
+
+## 为什么我没有最终采用 Node.js import hook 方案
+
+我不是没考虑过 import hook。实际上,这个方向一开始就很自然,因为它看起来:
+
+- 更优雅
+- 不需要写临时文件
+- 更贴近模块加载层
+
+但最后我没有选它,是因为它在这次问题上最大的风险不是“写起来麻烦”,而是“很难证明它真的修到了正确的那一层”。
+
+---
+
+## import hook 路线最难的点到底在哪里
+
+### 难点 1:真正的问题 import 不是普通源码 import,而是运行时拼出来的 import
+
+普通情况下,hook 最好处理的是这种:
+
+```js
+import './foo.js'
+```
+
+但这次真正有问题的是 rspack runtime 产物里的:
+
+```js
+import("./" + __webpack_require__.u(chunkId))
+```
+
+这个 import 有两个特点:
+
+- 不是静态字面量
+- 路径是运行时根据 `chunkId` 算出来的
+
+所以你要靠 hook 修它,真正的问题不是“能不能注册一个 hook”,而是:
+
+```text
+Node loader 在这一刻拿到的,到底是不是我想改写的那个最终 specifier?
+```
+
+如果这个前提不成立,hook 就可能只改到了顶层 import,没改到 nested chunk import。
+
+### 难点 2:你必须证明 hook 改到的是 nested chunk,而不是只改到了入口 bundle
+
+这次 bug 最容易误判的地方就在这里。
+
+因为入口 bundle 本来就已经在 dev 模式下带时间戳了,所以如果你做了一个 hook,很容易出现:
+
+```text
+看起来 import 被改写了
+但其实改写的只是顶层入口 import
+真正有问题的 nested chunk import 没改到
+```
+
+如果发生这种情况,现象会非常迷惑:
+
+- 日志看起来 hook 在工作
+- 顶层 bundle 也确实重新加载了
+- 但最终 bug 还是没修掉
+
+所以 import hook 真正难的,不是让它“动起来”,而是确认它动到的是正确层级。
+
+### 难点 3:作用范围很难拿捏,太宽会误伤,太窄会漏修
+
+一旦你启用 hook,它天然就更接近全局机制。
+
+这时你必须判断:
+
+- 哪些 import 应该加 `?t=`
+- 哪些不应该动
+
+至少要分清:
+
+- 本地 chunk
+- node_modules
+- `node:` 内建模块
+- 其他非 SSR 的 dev import
+- 不是 rspack runtime chunk 的普通 import
+
+如果规则写成“所有本地模块都加时间戳”,很可能误伤。
+
+如果规则写得过于保守,又很可能漏掉真正要修的 nested chunk import。
+
+所以它最难的工程点之一是:
+
+```text
+如何把规则收敛到足够窄,但又不漏掉问题路径
+```
+
+### 难点 4:你必须依赖 parent context,而不是只看 specifier 本身
+
+一个 `specifier` 单看经常不够。
+
+比如:
+
+```text
+./page.js
+```
+
+它本身并不能告诉你:
+
+- 这是普通模块导入
+- 还是 SSR bundle 里的 nested chunk 导入
+- 还是别的上下文产生的相对引用
+
+因此 hook 真要做稳,通常必须结合:
+
+- `specifier`
+- `parentURL`
+- 当前 parent 是否来自目标 SSR bundle
+- 当前 import 是否处于 rspack runtime chunk loading 路径
+
+这会让逻辑比看上去复杂很多。
+
+### 难点 5:验证成本更高
+
+临时 patched bundle 的好处是,验证非常直接。
+
+我可以直接看到:
+
+```text
+原始 bundle 里是什么
+-> 我改成了什么
+-> import 的临时文件是什么
+-> 最终 nested chunk URL 变成了什么
+```
+
+但 hook 方案更间接。
+
+你通常需要更多 instrumentation 才能证明:
+
+- hook 确实命中了目标 nested chunk import
+- 改写后的路径确实参与了解析
+- 真正载入的是新模块而不是旧缓存
+- 没对无关 import 产生副作用
+
+所以它的问题不是“理论上不优雅”,而是“验证链路更长,证据更难拿齐”。
+
+---
+
+## 两条路线的对比
+
+### 对比表
+
+| 方案 | 优点 | 缺点 | 我这次为什么没选/选了 |
+|---|---|---|---|
+| import hook | 架构上更优雅;不需要写临时文件;更接近 loader 层 | 作用范围更大;更难只修到目标路径;更难证明 nested chunk 一定被改写 | 没选,因为这次问题最怕“看起来生效,实际上没改到 nested chunk” |
+| 临时 patched bundle | 非常定点;容易验证;测试可控;只影响当前 dev SSR ESM 路径 | 需要写临时文件;依赖当前 rspack 产物形态;不如 hook 优雅 | 选了,因为它更窄、更直接、更容易证明修到了问题核心 |
+
+### 简化流程对比
+
+#### import hook 路线
+
+```text
+Node import entry
+ |
+ v
+entry 执行
+ |
+ v
+rspack runtime 生成 nested chunk import
+ |
+ v
+hook 是否真的拦到这里?
+ |
+ +-- 拦不到 -> 修复无效
+ +-- 拦到了但规则过宽 -> 误伤其他 import
+ +-- 拦到了且规则正确 -> 才算真正修稳
+```
+
+#### temporary patched bundle 路线
+
+```text
+直接改 entry bundle 里的 nested chunk import 源码
+ |
+ v
+把目标 import 明确变成带 ?t= 的 URL
+ |
+ v
+再交给 Node 原生 import 执行
+```
+
+---
+
+## 为什么我认为这次选择是合理的
+
+我当时的取舍不是“哪个方案更优雅”,而是:
+
+```text
+哪个方案更容易在当前问题上形成闭环证据
+```
+
+这里的闭环证据包括:
+
+1. 能解释 root cause
+2. 能精确改到 root cause
+3. 能写测试覆盖它
+4. 能验证修复前后差异
+
+临时 patched bundle 方案虽然没那么漂亮,但在这四点上都更直接。
+
+---
+
+## 未来如果要重做 import hook 方案,我会重点盯什么
+
+如果将来真的要把这套逻辑抽象成更“正统”的 loader/hook 方案,我认为最需要先解决的是这几个问题:
+
+1. **确认 hook 是否稳定命中 nested chunk import**
+ - 不是只命中 entry import
+ - 需要有非常明确的 tracing
+
+2. **确认改写范围足够窄**
+ - 只在 dev SSR ESM 触发
+ - 只影响目标 bundle 及其 nested chunk
+
+3. **确认 parent context 可可靠识别**
+ - 能判断当前 import 是否来自目标 SSR bundle
+ - 不依赖脆弱假设
+
+4. **补齐更强的验证手段**
+ - 不只是看有没有 hydration error
+ - 还要验证 SSR HTML 本身已经变成新内容
+ - 最好还能验证 nested chunk URL 确实发生变化
+
+---
+
+## 最后一句总结
+
+这次我没有选 import hook,不是因为它“不好”,而是因为:
+
+```text
+它更像一个更通用、更优雅的架构方案,
+但对这个具体 bug 来说,
+临时 patched bundle 是更窄、更可验证、更容易形成修复闭环的方案。
+```
+
+---
+
+## 决策过程复盘(接近思维链的版本)
+
+这一节不是逐字的内部推理记录,而是我在处理这个问题时真实采用的“假设 -> 证据 -> 调整方向”的决策过程复盘。
+
+### 阶段 1:先确认问题是否还存在
+
+最开始我没有默认 issue 描述一定还是当前代码里的真实问题,而是先把目标收敛成:
+
+```text
+先证明 bug 还存在,再讨论修法
+```
+
+所以第一步不是选方案,而是:
+
+- 读 issue 内容
+- 理解用户描述的触发路径
+- 在当前分支上构造/运行复现测试
+
+这一步的目标很明确:避免为一个已经变化或已经被别处修掉的问题设计方案。
+
+### 阶段 2:先怀疑顶层 bundle 失效有问题
+
+在还没看到真实产物之前,一个自然假设是:
+
+```text
+是不是顶层 SSR bundle 自己都没有重新加载?
+```
+
+因为从表面现象看,“服务端还是旧内容”很像入口模块没有失效。
+
+所以我优先检查:
+
+- 顶层 SSR bundle 的加载路径
+- `compatibleRequire()` 在 dev + esm 条件下做了什么
+- 入口 bundle 文件本身是否有变化
+
+这一步的思考重点是:先抓离现象最近的一层,而不是一上来就怀疑复杂的运行时细节。
+
+### 阶段 3:发现顶层 bundle 已经在变,于是转向下一层
+
+当我确认顶层 bundle 其实已经在重新加载时,原来的假设就被推翻了。
+
+这个时候我的问题变成:
+
+```text
+如果入口 bundle 已经重新加载,为什么 SSR 结果还是旧的?
+```
+
+这迫使我继续沿着执行链往里看,而不是继续在“入口是否失效”这个方向打转。
+
+也就是从:
+
+```text
+是不是入口没更新?
+```
+
+切换成:
+
+```text
+入口更新后,内部是不是还有别的缓存层?
+```
+
+### 阶段 4:把问题重新定位为 nested chunk cache 没失效
+
+继续读 rspack 产物以后,我看到了这种代码:
+
+```js
+import("./" + __webpack_require__.u(chunkId))
+```
+
+到这里,问题的结构才真正清晰起来。
+
+我当时的判断发生了一个关键转折:
+
+```text
+不是“入口 bundle 没失效”
+而是“入口 bundle 失效了,但它内部继续 import 的子 chunk 还是旧 URL”
+```
+
+一旦这样理解,很多现象就都能对上:
+
+- 为什么入口加载逻辑看起来没问题
+- 为什么服务端还是旧内容
+- 为什么客户端却能拿到新内容
+- 为什么最终是 hydration mismatch
+
+### 阶段 5:开始比较候选方案,而不是只盯着一个“看起来优雅”的解法
+
+到这里,我脑子里实际上有几条路线:
+
+1. import hook / loader 路线
+2. `requireFromString` / `_compile` 路线
+3. 直接 patch bundle runtime import 的路线
+
+这时我没有直接按“优雅程度”选,而是用更工程化的标准去筛:
+
+- 它是不是直接命中 root cause?
+- 它的影响范围能不能控制得很窄?
+- 它能不能被测试清楚地证明?
+- 如果失败,调试成本高不高?
+
+这个阶段最重要的思考不是“哪个方案看起来更高级”,而是:
+
+```text
+哪个方案更容易形成闭环证据
+```
+
+### 阶段 6:为什么 import hook 最后没有赢
+
+我当时对 import hook 的直觉评价其实是:
+
+```text
+更优雅,但不够短路径
+```
+
+它的问题不是理论上做不到,而是:
+
+- 我需要证明它真的拦到了 nested chunk import
+- 而不是只改到了顶层 import
+- 同时还要证明不会误伤别的 import
+
+这意味着它的验证链条更长。
+
+对这次 bug 来说,我最不想要的是:
+
+```text
+代码看起来很漂亮
+日志看起来也像在工作
+但实际上根本没改到真正出问题的那层
+```
+
+所以我最终没有把修复赌在 hook 上。
+
+### 阶段 7:为什么 temporary patched bundle 赢了
+
+当我看到可以直接对 rspack 产物里的这段代码做精确改写时,判断开始明显偏向这个方案:
+
+```text
+先把 nested chunk import 变成我明确想要的样子
+再把它交给 Node 原生 import 执行
+```
+
+这个路径的好处是:
+
+- root cause 很直接
+- patch 点很直接
+- 观察点很直接
+- 测试也很直接
+
+虽然“写临时文件”看起来有点怪,但在当时的决策里,它属于:
+
+```text
+局部不够优雅,但整体更可控
+```
+
+我当时更看重“这次把 bug 修稳”,而不是“这次方案是不是最漂亮”。
+
+### 阶段 8:实现后继续收紧,而不是停在“能跑通”
+
+修复能工作以后,我没有把它当成结束,而是继续问两个问题:
+
+1. 这个实现有没有明显风险点?
+2. 测试是不是足够证明它修到了原问题?
+
+这就引出了后续的两个修正:
+
+- 临时文件命名冲突风险 -> 改成 `pid + counter + randomUUID`
+- 集成测试最初只看 error,不够强 -> 加上刷新后 HTML 内容断言
+
+也就是说,决策过程不是:
+
+```text
+想到一个方案 -> 写完 -> 结束
+```
+
+而是:
+
+```text
+先找 root cause
+-> 选最短证据链的方案
+-> 实现后继续用 review 和测试把证据补齐
+```
+
+---
+
+## 用更短的话概括我的决策过程
+
+如果要把整个思考过程压缩成最核心的几句,它大概是这样的:
+
+```text
+先证明 bug 还在
+-> 先怀疑入口 bundle
+-> 发现入口其实已经失效
+-> 顺着执行链发现 nested chunk 仍然命中旧缓存
+-> 比较几条修法时,优先选“最容易闭环验证”的方案
+-> 因此最终选择 temporary patched bundle,而不是把修复押在更全局的 import hook 上
+```
diff --git a/packages/toolkit/utils/src/cli/require.ts b/packages/toolkit/utils/src/cli/require.ts
index 863f0876b8a2..4ddd44c6e704 100644
--- a/packages/toolkit/utils/src/cli/require.ts
+++ b/packages/toolkit/utils/src/cli/require.ts
@@ -1,7 +1,51 @@
-import { isAbsolute } from 'node:path';
+import { randomUUID } from 'node:crypto';
+import { promises as fs } from 'node:fs';
+import { basename, dirname, extname, isAbsolute, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { moduleResolve } from 'import-meta-resolve';
import { findExists } from './fs';
+const RSPACK_CHUNK_IMPORT_RE =
+ /import\("\.\/"\s*\+\s*__webpack_require__\.u\(chunkId\)\)/g;
+const DEV_BUNDLE_TEMP_MARKER = '.__modern_dev__.';
+let patchedBundleCounter = 0;
+
+function patchRspackChunkImports(bundleContent: string, timestamp: string) {
+ return bundleContent.replace(
+ RSPACK_CHUNK_IMPORT_RE,
+ `import("./" + __webpack_require__.u(chunkId) + "?t=${timestamp}")`,
+ );
+}
+
+export function createPatchedBundlePath(path: string, timestamp: string) {
+ const extension = extname(path);
+ const filename = basename(path, extension);
+ patchedBundleCounter += 1;
+ const uniqueSuffix = `${process.pid}.${patchedBundleCounter}.${randomUUID()}`;
+
+ return join(
+ dirname(path),
+ `${filename}${DEV_BUNDLE_TEMP_MARKER}${timestamp}.${uniqueSuffix}${extension}`,
+ );
+}
+
+async function importPatchedBundle(path: string, timestamp: string) {
+ const originalBundle = await fs.readFile(path, 'utf-8');
+ const patchedBundle = patchRspackChunkImports(originalBundle, timestamp);
+
+ if (patchedBundle === originalBundle) {
+ return importPath(path);
+ }
+
+ const tempBundlePath = createPatchedBundlePath(path, timestamp);
+
+ await fs.writeFile(tempBundlePath, patchedBundle);
+
+ try {
+ return await importPath(tempBundlePath);
+ } finally {
+ await fs.unlink(tempBundlePath).catch(() => undefined);
+ }
+}
async function importPath(path: string, options?: any) {
const modulePath = isAbsolute(path) ? pathToFileURL(path).href : path;
@@ -32,6 +76,13 @@ async function compatibleRequireESM(
return res.default;
}
+ if (process.env.NODE_ENV === 'development') {
+ const timestamp = Date.now().toString();
+ const module = await importPatchedBundle(path, timestamp);
+
+ return interop ? module.default : module;
+ }
+
const requiredModule = await importPath(path);
return interop ? requiredModule.default : requiredModule;
}
diff --git a/packages/toolkit/utils/tests/compatRequire.test.ts b/packages/toolkit/utils/tests/compatRequire.test.ts
index 8b2da5dc0878..41caddc860c9 100644
--- a/packages/toolkit/utils/tests/compatRequire.test.ts
+++ b/packages/toolkit/utils/tests/compatRequire.test.ts
@@ -1,9 +1,16 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
import path from 'path';
import { cleanRequireCache, compatibleRequire } from '../src';
+import { createPatchedBundlePath } from '../src/cli/require';
describe('compat require', () => {
const fixturePath = path.resolve(__dirname, './fixtures/compat-require');
+ afterEach(() => {
+ rstest.unstubAllEnvs();
+ });
+
test(`should support default property`, async () => {
expect(await compatibleRequire(path.join(fixturePath, 'esm.js'))).toEqual({
name: 'esm',
@@ -22,6 +29,49 @@ describe('compat require', () => {
);
});
+ test('should invalidate rspack chunk imports in development esm mode', async () => {
+ rs.stubEnv('NODE_ENV', 'development');
+ rs.stubEnv('MODERN_LIB_FORMAT', 'esm');
+
+ const tempDir = await mkdtemp(path.join(tmpdir(), 'compat-require-'));
+ const entryPath = path.join(tempDir, 'index.mjs');
+ const chunkPath = path.join(tempDir, 'page.mjs');
+
+ await writeFile(
+ entryPath,
+ [
+ "const chunkId = 'page';",
+ 'const __webpack_require__ = {',
+ ' u(id) {',
+ ' return `${id}.mjs`;',
+ ' },',
+ '};',
+ 'const module = await import("./" + __webpack_require__.u(chunkId));',
+ 'export default module.default;',
+ ].join('\n'),
+ );
+ await writeFile(chunkPath, "export default { name: 'before' };\n");
+
+ try {
+ expect(await compatibleRequire(entryPath)).toEqual({ name: 'before' });
+
+ await writeFile(chunkPath, "export default { name: 'after' };\n");
+
+ expect(await compatibleRequire(entryPath)).toEqual({ name: 'after' });
+ } finally {
+ await rm(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ test('should create unique temp bundle paths for dev esm patching', () => {
+ const firstPath = createPatchedBundlePath('/tmp/index.mjs', '123');
+ const secondPath = createPatchedBundlePath('/tmp/index.mjs', '123');
+
+ expect(firstPath).not.toBe(secondPath);
+ expect(firstPath).toContain('.__modern_dev__.123.');
+ expect(path.extname(firstPath)).toBe('.mjs');
+ });
+
// The native Node.js require behavior does not support testing in the rstest
test.skip('should clean cache after fn', () => {
const foo = module.require('./fixtures/compat-require/foo');
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 474192e2c53e..15581b12974e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4273,6 +4273,34 @@ importers:
specifier: ^5
version: 5.9.3
+ tests/integration/ssr/fixtures/esm-cache-invalidation:
+ dependencies:
+ '@modern-js/runtime':
+ specifier: workspace:*
+ version: link:../../../../../packages/runtime/plugin-runtime
+ react:
+ specifier: ^19.2.4
+ version: 19.2.4
+ react-dom:
+ specifier: ^19.2.4
+ version: 19.2.4(react@19.2.4)
+ devDependencies:
+ '@modern-js/app-tools':
+ specifier: workspace:*
+ version: link:../../../../../packages/solutions/app-tools
+ '@modern-js/tsconfig':
+ specifier: workspace:*
+ version: link:../../../../../packages/tsconfig
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ typescript:
+ specifier: ^5
+ version: 5.9.3
+
tests/integration/ssr/fixtures/fallback:
dependencies:
'@modern-js/runtime':
diff --git a/tests/integration/ssr/fixtures/esm-cache-invalidation/modern.config.ts b/tests/integration/ssr/fixtures/esm-cache-invalidation/modern.config.ts
new file mode 100644
index 000000000000..663727a360e4
--- /dev/null
+++ b/tests/integration/ssr/fixtures/esm-cache-invalidation/modern.config.ts
@@ -0,0 +1,9 @@
+import { appTools, defineConfig } from '@modern-js/app-tools';
+
+// https://modernjs.dev/en/configure/app/usage
+export default defineConfig({
+ server: {
+ ssr: true,
+ },
+ plugins: [appTools()],
+});
diff --git a/tests/integration/ssr/fixtures/esm-cache-invalidation/package.json b/tests/integration/ssr/fixtures/esm-cache-invalidation/package.json
new file mode 100644
index 000000000000..a3c9e1568297
--- /dev/null
+++ b/tests/integration/ssr/fixtures/esm-cache-invalidation/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "ssr-esm-cache-invalidation-test",
+ "version": "2.66.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "modern dev",
+ "build": "modern build",
+ "serve": "modern serve"
+ },
+ "dependencies": {
+ "@modern-js/runtime": "workspace:*",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4"
+ },
+ "devDependencies": {
+ "@modern-js/app-tools": "workspace:*",
+ "@modern-js/tsconfig": "workspace:*",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "typescript": "^5"
+ }
+}
diff --git a/tests/integration/ssr/fixtures/esm-cache-invalidation/src/modern-app-env.d.ts b/tests/integration/ssr/fixtures/esm-cache-invalidation/src/modern-app-env.d.ts
new file mode 100644
index 000000000000..1e851dcf7213
--- /dev/null
+++ b/tests/integration/ssr/fixtures/esm-cache-invalidation/src/modern-app-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/tests/integration/ssr/fixtures/esm-cache-invalidation/src/modern.runtime.ts b/tests/integration/ssr/fixtures/esm-cache-invalidation/src/modern.runtime.ts
new file mode 100644
index 000000000000..7437c8314e58
--- /dev/null
+++ b/tests/integration/ssr/fixtures/esm-cache-invalidation/src/modern.runtime.ts
@@ -0,0 +1,3 @@
+import { defineRuntimeConfig } from '@modern-js/runtime';
+
+export default defineRuntimeConfig({});
diff --git a/tests/integration/ssr/fixtures/esm-cache-invalidation/src/routes/index.css b/tests/integration/ssr/fixtures/esm-cache-invalidation/src/routes/index.css
new file mode 100644
index 000000000000..50fa601d0554
--- /dev/null
+++ b/tests/integration/ssr/fixtures/esm-cache-invalidation/src/routes/index.css
@@ -0,0 +1,116 @@
+html,
+body {
+ padding: 0;
+ margin: 0;
+ font-family: PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
+ background: linear-gradient(to bottom, transparent, #fff) #eceeef;
+}
+
+p {
+ margin: 0;
+}
+
+* {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ box-sizing: border-box;
+}
+
+.container-box {
+ min-height: 100vh;
+ max-width: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ padding-top: 10px;
+}
+
+main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+}
+
+.title {
+ display: flex;
+ margin: 4rem 0 4rem;
+ align-items: center;
+ font-size: 4rem;
+ font-weight: 600;
+}
+
+.logo {
+ width: 6rem;
+ margin: 7px 0 0 1rem;
+}
+
+.name {
+ color: #4ecaff;
+}
+
+.description {
+ text-align: center;
+ line-height: 1.5;
+ font-size: 1.3rem;
+ color: #1b3a42;
+ margin-bottom: 5rem;
+}
+
+.code {
+ background: #fafafa;
+ border-radius: 12px;
+ padding: 0.6rem 0.9rem;
+ font-size: 1.05rem;
+ font-family:
+ Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
+ Bitstream Vera Sans Mono, Courier New, monospace;
+}
+
+.container-box .grid {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 1100px;
+ margin-top: 3rem;
+}
+
+.card {
+ padding: 1.5rem;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ height: 100px;
+ color: inherit;
+ text-decoration: none;
+ transition: 0.15s ease;
+ width: 45%;
+}
+
+.card:hover,
+.card:focus {
+ transform: scale(1.05);
+}
+
+.card h2 {
+ display: flex;
+ align-items: center;
+ font-size: 1.5rem;
+ margin: 0;
+ padding: 0;
+}
+
+.card p {
+ opacity: 0.6;
+ font-size: 0.9rem;
+ line-height: 1.5;
+ margin-top: 1rem;
+}
+
+.arrow-right {
+ width: 1.3rem;
+ margin-left: 0.5rem;
+ margin-top: 3px;
+}
diff --git a/tests/integration/ssr/fixtures/esm-cache-invalidation/src/routes/layout.tsx b/tests/integration/ssr/fixtures/esm-cache-invalidation/src/routes/layout.tsx
new file mode 100644
index 000000000000..6433ea79e92b
--- /dev/null
+++ b/tests/integration/ssr/fixtures/esm-cache-invalidation/src/routes/layout.tsx
@@ -0,0 +1,9 @@
+import { Outlet } from '@modern-js/runtime/router';
+
+export default function Layout() {
+ return (
+
+
+
+ );
+}
diff --git a/tests/integration/ssr/fixtures/esm-cache-invalidation/src/routes/page.tsx b/tests/integration/ssr/fixtures/esm-cache-invalidation/src/routes/page.tsx
new file mode 100644
index 000000000000..e06e1b4c868c
--- /dev/null
+++ b/tests/integration/ssr/fixtures/esm-cache-invalidation/src/routes/page.tsx
@@ -0,0 +1,96 @@
+import { Helmet } from '@modern-js/runtime/head';
+import './index.css';
+
+const Index = () => (
+
+
+
+
+
+
+ Welcome to111
+

+
Modern.js
+
+
+ Get started by editing src/routes/page.tsx
+
+
+
+
+);
+
+export default Index;
diff --git a/tests/integration/ssr/fixtures/esm-cache-invalidation/tsconfig.json b/tests/integration/ssr/fixtures/esm-cache-invalidation/tsconfig.json
new file mode 100644
index 000000000000..ce7f951eefc3
--- /dev/null
+++ b/tests/integration/ssr/fixtures/esm-cache-invalidation/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "@modern-js/tsconfig/base",
+ "compilerOptions": {
+ "declaration": false,
+ "jsx": "preserve",
+ "baseUrl": "./",
+ "paths": {
+ "@/*": ["./src/*"],
+ "@shared/*": ["./shared/*"]
+ }
+ },
+ "include": ["src", "shared", "config", "modern.config.ts"],
+ "exclude": ["**/node_modules"]
+}
diff --git a/tests/integration/ssr/tests/esm-cache-invalidation.test.ts b/tests/integration/ssr/tests/esm-cache-invalidation.test.ts
new file mode 100644
index 000000000000..084c9a0eedaa
--- /dev/null
+++ b/tests/integration/ssr/tests/esm-cache-invalidation.test.ts
@@ -0,0 +1,112 @@
+import dns from 'node:dns';
+import path, { join } from 'path';
+import { fs } from '@modern-js/utils';
+import puppeteer, { type Browser, type Page } from 'puppeteer';
+import {
+ getPort,
+ killApp,
+ launchApp,
+ launchOptions,
+} from '../../../utils/modernTestUtils';
+
+dns.setDefaultResultOrder('ipv4first');
+const fixtureDir = path.resolve(__dirname, '../fixtures');
+const pagePath = join(
+ fixtureDir,
+ 'esm-cache-invalidation',
+ 'src/routes/page.tsx',
+);
+
+const ORIGINAL_TEXT = 'Welcome to111';
+const MODIFIED_TEXT = 'ESM-CACHE-REPRODUCE';
+
+describe('ESM Cache Invalidation - Issue #8373', () => {
+ let app: any;
+ let appPort: number;
+ let page: Page;
+ let browser: Browser;
+
+ beforeAll(async () => {
+ const appDir = join(fixtureDir, 'esm-cache-invalidation');
+ appPort = await getPort();
+ app = await launchApp(appDir, appPort);
+
+ browser = await puppeteer.launch(launchOptions as any);
+ page = await browser.newPage();
+ });
+
+ afterAll(async () => {
+ // Restore file to original content after test
+ const currentContent = await fs.readFile(pagePath, 'utf-8');
+ if (currentContent.includes(MODIFIED_TEXT)) {
+ await fs.writeFile(
+ pagePath,
+ currentContent.replace(MODIFIED_TEXT, ORIGINAL_TEXT),
+ );
+ }
+
+ if (browser) await browser.close();
+ if (app) await killApp(app);
+ });
+
+ test('reproduce hydration mismatch bug from issue #8373', async () => {
+ const consoleMessages: string[] = [];
+ const pageErrors: string[] = [];
+
+ page.on('console', message => {
+ consoleMessages.push(message.text());
+ });
+
+ page.on('pageerror', error => {
+ const msg = error instanceof Error ? error.message : String(error);
+ pageErrors.push(msg);
+ });
+
+ // Ensure file starts with original content
+ let fileContent = await fs.readFile(pagePath, 'utf-8');
+ if (!fileContent.includes(ORIGINAL_TEXT)) {
+ fileContent = fileContent.replace(/ESM-CACHE-\w+/g, ORIGINAL_TEXT);
+ await fs.writeFile(pagePath, fileContent);
+ await new Promise(r => setTimeout(r, 2000));
+ }
+
+ // Load initial page
+ await page.goto(`http://localhost:${appPort}`, {
+ waitUntil: ['networkidle0'],
+ });
+ await new Promise(r => setTimeout(r, 1000));
+
+ const content = await page.content();
+ expect(content).toMatch(new RegExp(ORIGINAL_TEXT));
+
+ // Modify file
+ fileContent = await fs.readFile(pagePath, 'utf-8');
+ await fs.writeFile(
+ pagePath,
+ fileContent.replace(ORIGINAL_TEXT, MODIFIED_TEXT),
+ );
+
+ // Wait 500ms (as user described)
+ await new Promise(r => setTimeout(r, 500));
+
+ // Refresh browser
+ await page.reload({ waitUntil: ['networkidle0'] });
+ await new Promise(r => setTimeout(r, 3000));
+
+ const refreshedContent = await page.content();
+
+ expect(refreshedContent).toMatch(new RegExp(MODIFIED_TEXT));
+ expect(refreshedContent).not.toMatch(new RegExp(ORIGINAL_TEXT));
+
+ // Check for hydration mismatch error
+ const hydrationSignals = [...pageErrors, ...consoleMessages].filter(
+ err =>
+ err.includes('Hydration failed') ||
+ err.includes('did not match') ||
+ err.includes('rendered text') ||
+ err.includes('Hydration mismatch'),
+ );
+
+ expect(hydrationSignals.length).toBe(0);
+ });
+});