Skip to content

Commit 50d5e7d

Browse files
committed
[Vite] Add support for React HMR
1 parent 7e6ae3e commit 50d5e7d

9 files changed

Lines changed: 152 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# CHANGELOG
22

3+
## 0.4.0
4+
5+
- Support React Fast Refresh (HMR) with Vite by rendering the `@vitejs/plugin-react` preamble in dev
6+
7+
## 0.3.0
8+
9+
- Fix an entry's CSS being silently dropped under Vite when the entry is emitted as a facade chunk (a top-level `await` in an entry also imported by another entry)
10+
- Prefer `build.rolldownOptions` over the deprecated `build.rollupOptions` (rolldown-vite / Vite 8)
11+
312
## 0.2.0
413

514
- Update Unplugin from ^2.3.4 to ^3.3.0

assets/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"@rspack/core": "~2.1.3",
9090
"@tsconfig/node22": "^22.0.5",
9191
"@types/node": "^26.1.0",
92+
"@vitejs/plugin-react": "^6.0.3",
9293
"jsdom": "^29.1.1",
9394
"nodemon": "^3.1.14",
9495
"tsdown": "^0.22.3",

assets/src/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,21 @@ export const unpluginFactory: UnpluginFactory<Options | undefined> = (options, _
127127
});
128128
server.config.server.origin = origin; // keep Vite's internal URL rewriting in sync
129129

130-
// Vite serves `@vite/client` under `base` (publicPath), not at the origin root.
130+
// Vite serves `@vite/client` (and `@react-refresh`) under `base` (publicPath), not
131+
// at the origin root.
131132
const urlPrefix = resolvePublicPath(resolved.publicPath, origin);
133+
// `@vitejs/plugin-react` (and `-react-swc`) register plugins named `vite:react-*`.
134+
// When present, Symfony must emit the Fast Refresh preamble itself (it can't touch the HTML).
135+
const usesReactPlugin = server.config.plugins.some((plugin) =>
136+
plugin.name?.startsWith('vite:react')
137+
);
132138
const ctx: BuildContext = {
133139
isProd: false,
134-
devServer: { origin, client: joinUrl(urlPrefix, '@vite/client') },
140+
devServer: {
141+
origin,
142+
client: joinUrl(urlPrefix, '@vite/client'),
143+
reactRefresh: usesReactPlugin ? joinUrl(urlPrefix, '@react-refresh') : null,
144+
},
135145
publicPath: resolved.publicPath,
136146
urlPrefix,
137147
manifestKeyPrefix: resolved.manifestKeyPrefix,

assets/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,13 @@ export interface DevServer {
185185
* into the entry (Rsbuild) so nothing extra needs to be rendered.
186186
*/
187187
client: string | null;
188+
/**
189+
* URL of Vite's React Fast Refresh runtime (`@react-refresh`) to inject as a preamble before
190+
* the entry in dev, set when `@vitejs/plugin-react` is used. `@vitejs/plugin-react` cannot inject
191+
* this itself when Symfony renders the HTML (backend integration). `null`/absent otherwise, and
192+
* always under Rsbuild, which wires React refresh into the bundle itself.
193+
*/
194+
reactRefresh?: string | null;
188195
}
189196

190197
export interface AssetEntry {

assets/test/integration/vite-dev.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { mkdtempSync, readFileSync } from 'node:fs';
22
import { tmpdir } from 'node:os';
33
import { join } from 'node:path';
4+
import react from '@vitejs/plugin-react';
45
import { createServer } from 'vite';
56
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
67
import Symfony from '../../src/vite';
@@ -36,9 +37,29 @@ describe('vite serve writes a dev entrypoints.json', () => {
3637
expect(origin).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
3738
// The HMR client is served under `base` (our publicPath), so its URL carries `/build/`.
3839
expect(entry.devServer.client).toBe(`${origin}/build/@vite/client`);
40+
// No React plugin in this fixture, so no Fast Refresh preamble URL.
41+
expect(entry.devServer.reactRefresh ?? null).toBe(null);
3942

4043
expect(Object.keys(entry.entryPoints).sort()).toEqual(['admin', 'app']);
4144
expect(entry.entryPoints.app.js).toEqual([`${origin}/build/app.js`]);
4245
expect(entry.entryPoints.app.css).toEqual([]);
4346
});
47+
48+
it('exposes the React Fast Refresh URL when a React plugin is present', async () => {
49+
const reactOut = mkdtempSync(join(tmpdir(), 'ups-dev-react-'));
50+
const reactServer = await createServer({
51+
root: fixture,
52+
logLevel: 'silent',
53+
server: { port: 0, host: '127.0.0.1' },
54+
build: { rollupOptions: { input: { app: join(fixture, 'app.js') } } },
55+
plugins: [react(), Symfony({ outputPath: reactOut, publicPath: '/build/' })],
56+
});
57+
await reactServer.listen();
58+
try {
59+
const entry = JSON.parse(readFileSync(join(reactOut, 'entrypoints.json'), 'utf8'));
60+
expect(entry.devServer.reactRefresh).toBe(`${entry.devServer.origin}/build/@react-refresh`);
61+
} finally {
62+
await reactServer.close();
63+
}
64+
});
4465
});

pnpm-lock.yaml

Lines changed: 36 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Asset/DevServer.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ final class DevServer
2323
public function __construct(
2424
public readonly string $origin,
2525
public readonly ?string $client,
26+
public readonly ?string $reactRefresh = null,
2627
) {
2728
}
2829

@@ -41,6 +42,11 @@ public static function fromArray(array $data): self
4142
throw new InvalidEntrypointsException('The dev-server "client" must be a string or null.');
4243
}
4344

44-
return new self($origin, $client);
45+
$reactRefresh = $data['reactRefresh'] ?? null;
46+
if (null !== $reactRefresh && !\is_string($reactRefresh)) {
47+
throw new InvalidEntrypointsException('The dev-server "reactRefresh" must be a string or null.');
48+
}
49+
50+
return new self($origin, $client, $reactRefresh);
4551
}
4652
}

src/Asset/TagRenderer.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ public function renderScriptTags(string $entryName, ?string $packageName = null)
5353
$devServer = $this->lookup->getDevServer();
5454
if (!$this->clientInjected && null !== $devServer && null !== $devServer->client) {
5555
$tags[] = \sprintf('<script type="module" src="%s"></script>', htmlspecialchars($devServer->client, \ENT_QUOTES));
56+
if (null !== $devServer->reactRefresh) {
57+
$tags[] = $this->renderReactRefreshPreamble($devServer->reactRefresh);
58+
}
5659
$this->clientInjected = true;
5760
}
5861

@@ -113,6 +116,27 @@ public function reset(): void
113116
$this->clientInjected = false;
114117
}
115118

119+
/**
120+
* Emits Vite's React Fast Refresh preamble. `@vitejs/plugin-react` normally injects this into the
121+
* HTML itself, but cannot when Symfony renders the page (backend integration), so we render it here
122+
* before the entry. See https://vite.dev/guide/backend-integration.
123+
*/
124+
private function renderReactRefreshPreamble(string $reactRefreshUrl): string
125+
{
126+
return \sprintf(
127+
<<<'HTML'
128+
<script type="module">
129+
import RefreshRuntime from "%s";
130+
RefreshRuntime.injectIntoGlobalHook(window);
131+
window.$RefreshReg$ = () => {};
132+
window.$RefreshSig$ = () => (type) => type;
133+
window.__vite_plugin_react_preamble_installed__ = true;
134+
</script>
135+
HTML,
136+
htmlspecialchars($reactRefreshUrl, \ENT_QUOTES),
137+
);
138+
}
139+
116140
private function url(string $reference, ?string $packageName): string
117141
{
118142
return $this->packages->getUrl($reference, $packageName ?? $this->defaultPackage);

tests/Asset/TagRendererTest.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,41 @@ public function testDoesNotInjectHmrClientInProd()
187187
$this->assertStringNotContainsString('@vite/client', $html);
188188
}
189189

190+
public function testInjectsReactRefreshPreambleAfterTheClientAndBeforeTheEntryInDev()
191+
{
192+
$renderer = $this->renderer(
193+
js: ['http://127.0.0.1:5173/build/app.js'],
194+
devServer: new DevServer(
195+
'http://127.0.0.1:5173',
196+
'http://127.0.0.1:5173/build/@vite/client',
197+
'http://127.0.0.1:5173/build/@react-refresh',
198+
),
199+
);
200+
201+
$html = $renderer->renderScriptTags('app');
202+
203+
$this->assertStringContainsString('import RefreshRuntime from "http://127.0.0.1:5173/build/@react-refresh"', $html);
204+
$this->assertStringContainsString('window.__vite_plugin_react_preamble_installed__ = true', $html);
205+
206+
// Order matters: the HMR client, then the preamble, then the entry that imports the components.
207+
$clientPos = strpos($html, '@vite/client');
208+
$preamblePos = strpos($html, '@react-refresh');
209+
$entryPos = strpos($html, 'build/app.js');
210+
$this->assertLessThan($preamblePos, $clientPos);
211+
$this->assertLessThan($entryPos, $preamblePos);
212+
}
213+
214+
public function testDoesNotInjectReactRefreshPreambleWhenNotAReactApp()
215+
{
216+
$html = $this->renderer(
217+
js: ['http://127.0.0.1:5173/build/app.js'],
218+
devServer: new DevServer('http://127.0.0.1:5173', 'http://127.0.0.1:5173/build/@vite/client'),
219+
)->renderScriptTags('app');
220+
221+
$this->assertStringNotContainsString('@react-refresh', $html);
222+
$this->assertStringNotContainsString('__vite_plugin_react_preamble_installed__', $html);
223+
}
224+
190225
public function testRendersModulepreloadLinksWithIntegrityBeforeScripts()
191226
{
192227
$html = $this->renderer(

0 commit comments

Comments
 (0)