Skip to content

Commit 303f9e4

Browse files
authored
fix: Vite HMR broken when using reactDevtools() plugin (#34)
1 parent 02e8cf0 commit 303f9e4

File tree

4 files changed

+77
-8
lines changed

4 files changed

+77
-8
lines changed

.changeset/fix-vite-hmr.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-react-devtools": patch
3+
---
4+
5+
Fixed Vite HMR (hot module replacement) breaking when the `reactDevtools()` plugin is added to `vite.config.ts`. The connect module now preserves the react-refresh runtime's inject wrapper when replacing the devtools hook, so both Fast Refresh and devtools inspection work correctly.

packages/agent-react-devtools/src/connect.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ const isProd =
5757
// This MUST happen at module evaluation time — if deferred to an async
5858
// callback, react-dom may initialize first and miss the hook entirely.
5959
if (!isSSR && !isProd) {
60+
// Save any existing inject wrapper (e.g., react-refresh runtime from
61+
// @vitejs/plugin-react) before replacing the hook stub.
62+
const oldHook = (window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__;
63+
const oldInject = oldHook?.inject;
64+
6065
// Remove Vite's plugin-react hook stub so react-devtools-core can install the full hook
6166
try {
6267
delete (window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__;
@@ -65,6 +70,24 @@ if (!isSSR && !isProd) {
6570
}
6671

6772
initialize();
73+
74+
// If the old hook had a wrapped inject (from react-refresh runtime),
75+
// re-apply it so Fast Refresh / HMR continues to work. The refresh
76+
// wrapper inspects the `injected` argument to capture scheduleRefresh;
77+
// calling it after the real inject ensures both devtools and HMR work.
78+
const newHook = (window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__;
79+
if (oldInject && newHook && oldInject !== newHook.inject) {
80+
const realInject = newHook.inject;
81+
newHook.inject = function (injected: unknown) {
82+
const id = realInject.apply(this, arguments);
83+
try {
84+
oldInject.call(this, injected);
85+
} catch {
86+
// Ignore errors from the old stub inject
87+
}
88+
return id;
89+
};
90+
}
6891
}
6992

7093
export const ready: Promise<void> = isSSR || isProd ? noop() : connect();

packages/agent-react-devtools/src/vite-plugin.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,13 @@ export function reactDevtools(options?: ReactDevtoolsOptions): Plugin {
1515
name: 'agent-react-devtools',
1616
apply: 'serve',
1717
config() {
18-
// The connect module uses top-level await to block React from loading
19-
// before the devtools hook is installed. Vite's dep optimizer uses
20-
// esbuild which defaults to es2020 (no TLA support), so we enable it.
18+
// The connect module is injected via transformIndexHtml, so Vite's
19+
// dep scanner won't discover react-devtools-core (a CJS package)
20+
// until the page loads. Without this hint, Vite triggers dep
21+
// re-optimization at runtime which causes a full-reload and breaks HMR.
2122
return {
2223
optimizeDeps: {
23-
esbuildOptions: {
24-
supported: {
25-
'top-level-await': true,
26-
},
27-
},
24+
include: ['react-devtools-core'],
2825
},
2926
};
3027
},

packages/e2e-tests/src/browser.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
22
import { chromium, type Browser, type Page } from 'playwright';
33
import path from 'node:path';
4+
import fs from 'node:fs';
45
import type { ChildProcess } from 'node:child_process';
56
import {
67
createTempStateDir,
@@ -121,4 +122,47 @@ describe('Browser e2e', () => {
121122
expect(summary.commitCount).toBeGreaterThan(0);
122123
expect(summary.componentRenderCounts.length).toBeGreaterThan(0);
123124
});
125+
126+
it('HMR works — file change applies without full reload', async () => {
127+
const appFile = path.resolve(
128+
import.meta.dirname,
129+
'../../../examples/vite-app/src/App.tsx',
130+
);
131+
const original = fs.readFileSync(appFile, 'utf-8');
132+
133+
try {
134+
// Set a window marker to detect full page reloads
135+
await page.evaluate(() => {
136+
(window as any).__hmrMarker = 'alive';
137+
});
138+
139+
// Verify the original heading is present
140+
const h1 = page.locator('h1');
141+
expect(await h1.textContent()).toBe('Perf Debug App');
142+
143+
// Modify the heading text
144+
const modified = original.replace('Perf Debug App', 'HMR Test Heading');
145+
fs.writeFileSync(appFile, modified, 'utf-8');
146+
147+
// Wait for HMR to apply the change
148+
const deadline = Date.now() + 10_000;
149+
let found = false;
150+
while (Date.now() < deadline) {
151+
const text = await h1.textContent().catch(() => null);
152+
if (text === 'HMR Test Heading') {
153+
found = true;
154+
break;
155+
}
156+
await sleep(250);
157+
}
158+
expect(found, 'File change should be reflected in the browser').toBe(true);
159+
160+
// Verify no full page reload occurred — the marker should still be set
161+
const marker = await page.evaluate(() => (window as any).__hmrMarker);
162+
expect(marker).toBe('alive');
163+
} finally {
164+
// Always restore the original file
165+
fs.writeFileSync(appFile, original, 'utf-8');
166+
}
167+
});
124168
});

0 commit comments

Comments
 (0)