Skip to content

Commit a6eb132

Browse files
committed
feature #43 [Vite] Add support for React HMR (Kocal)
This PR was squashed before being merged into the main branch. Discussion ---------- [Vite] Add support for React HMR | Q | A | -------------- | --- | Bug fix? | no | New feature? | yes <!-- please update CHANGELOG.md file --> | Deprecations? | no <!-- if yes, also update UPGRADE-*.md and CHANGELOG.md files --> | Documentation? | no <!-- required for new features, or documentation updates --> | Issues | Fix #... <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead --> | License | MIT Related to: - symfony/ux#3714 - symfony/recipes#1546 HMR and page reload are correctly working for React and Vue, with Vite and Rsbuild 🎉 https://github.com/user-attachments/assets/981d8744-e2aa-448a-86d0-21a8020cb527 Do not mind about the cuty kitty!! Commits ------- 50d5e7d [Vite] Add support for React HMR 7e6ae3e [Playground] Demo UX React and UX Vue with import.meta.glob()
2 parents d988b09 + 50d5e7d commit a6eb132

23 files changed

Lines changed: 953 additions & 6 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
});

playground/assets/app.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import './stimulus_bootstrap.js';
22
import { startStimulusApp } from '@symfony/reprise/stimulus'
3+
import { registerReactControllerComponents } from '@symfony/ux-react'
4+
import { registerVueControllerComponents } from '@symfony/ux-vue'
35
import './styles/app.css'
46
import { add, subtract } from './calc'
57
import krkr from './images/krkr.webp';
68

9+
// UX React and UX Vue read their components from Vite's / Rsbuild's import.meta.glob()
10+
// instead of Webpack's require.context(). The "eager" option is required.
11+
registerReactControllerComponents(import.meta.glob('./react/controllers/**/*.{jsx,tsx}', { eager: true }))
12+
registerVueControllerComponents(import.meta.glob('./vue/controllers/**/*.vue', { eager: true }))
13+
714
const app = startStimulusApp()
815

916
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');

playground/assets/controllers.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@
55
"enabled": true,
66
"fetch": "lazy"
77
}
8+
},
9+
"@symfony/ux-react": {
10+
"react": {
11+
"enabled": true,
12+
"fetch": "eager"
13+
}
14+
},
15+
"@symfony/ux-vue": {
16+
"vue": {
17+
"enabled": true,
18+
"fetch": "eager"
19+
}
820
}
921
},
1022
"entrypoints": []
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import React from 'react';
2+
3+
const step = 1;
4+
5+
export function Counter() {
6+
const [count, setCount] = React.useState(0);
7+
8+
return (
9+
<>
10+
Counter: {count}
11+
<button type="button" onClick={() => setCount(count - step)}>-</button>
12+
<button type="button" onClick={() => setCount(count + step)}>+</button>
13+
</>
14+
)
15+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import React from 'react';
2+
import {Counter} from '../Counter.jsx';
3+
4+
export default function (props) {
5+
return <div>
6+
<div>Hello {props.fullName} (rendered by UX React)!</div>
7+
<Counter />
8+
</div>;
9+
}

playground/assets/vue/Counter.vue

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<script setup>
2+
import {ref} from "vue";
3+
4+
const step = 1;
5+
const count = ref(0);
6+
</script>
7+
8+
<template>
9+
Counter: {{ count }}
10+
<button type="button" @click="count -= step">-</button>
11+
<button type="button" @click="count += step">+</button>
12+
</template>

0 commit comments

Comments
 (0)