Skip to content

Commit 8f084ef

Browse files
amir-zahediclaude
andauthored
feat(gdu): upgrade Vite 7 to Vite 8 + Rolldown (#396)
* feat(gdu): replace require() with dynamic import and add Rolldown ESM external plugin Switch Vite and Vanilla Extract imports from synchronous require() to native dynamic import() to prevent TypeScript CJS output from rewriting them. Integrate Rolldown's esmExternalRequirePlugin to properly convert CJS require() calls for externalised modules into ESM imports, removing duplicate external handling when the plugin is active. AG-17612 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: add changeset for dynamic import and ESM external plugin Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(gdu): resolve lint errors in Vite build and dev server Extract ESM external plugin loading into a helper function to reduce cognitive complexity, and avoid accessing a member directly from an await expression in the dev server. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: Upgrade to Vite 8 with Rolldown integration and ESM external plugin - Bump `vite` dependency from `^7.0.0` to `^8.0.0-beta.0` - Replace Rollup + esbuild with Rolldown + OXC for improved bundling performance - Rename `rollupOptions` to `rolldownOptions` and update configuration types accordingly - Change minification strategy to use OXC by default - Import `esmExternalRequirePlugin` from `vite` instead of `rolldown/plugins` - Fix bug related to external module handling in the build process - Add `peerDependencyMeta` for `@vanilla-extract/vite-plugin` to suppress warnings - Document upgrade status and testing guide for Vite 8 * refactor: Simplify dynamic import function and clean up type definitions in Vite integration --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 15421b8 commit 8f084ef

File tree

8 files changed

+665
-35
lines changed

8 files changed

+665
-35
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'gdu': minor
3+
---
4+
5+
Upgrade from Vite 7 to Vite 8 (Rolldown + OXC)
6+
7+
- Bump `vite` dependency from `^7.0.0` to `^8.0.0-beta.0` — replaces Rollup + esbuild with Rolldown + OXC for 10-30x faster bundling
8+
- Rename `rollupOptions` to `rolldownOptions` and `esbuild` config to `oxc` across all Vite configuration
9+
- Change `minify: 'esbuild'` to `minify: true` (OXC minifier is the new default)
10+
- Import `esmExternalRequirePlugin` from `'vite'` instead of `'rolldown/plugins'` (now a first-class Vite 8 re-export)
11+
- Fix bug where `external: undefined` was set when esmExternalRequirePlugin loaded, which would have caused all externals to be bundled
12+
- Add `peerDependencyMeta` override for `@vanilla-extract/vite-plugin` to suppress Vite 8 peer dep warning

docs/vite8-upgrade-status.md

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Vite 8 + Rolldown Upgrade — Status & Testing Guide
2+
3+
**Branch:** `feat/AG-17612-vite-dynamic-imports-and-esm-externals`
4+
**Date:** 2026-02-24
5+
6+
---
7+
8+
## Current Status
9+
10+
All code changes are complete in octane. TypeScript compiles cleanly, lint passes, and all 71 tests pass. The changes have **not yet been tested** with a real MFE build — the local prod server was serving stale assets from a previous build.
11+
12+
---
13+
14+
## What Changed (Summary)
15+
16+
| File | Change |
17+
|------|--------|
18+
| `packages/gdu/package.json` | `vite` bumped from `^7.0.0` to `^8.0.0-beta.0`; added `peerDependencyMeta` for `@vanilla-extract/vite-plugin` |
19+
| `packages/gdu/config/vite/types.ts` | Renamed `Rollup*` types to `Rolldown*`; added `oxc` config type alongside existing `esbuild` type; `minify` type widened to `boolean \| string` |
20+
| `packages/gdu/config/vite/vite.config.ts` | `rollupOptions` renamed to `rolldownOptions`; `minify: 'esbuild'` changed to `minify: true` (OXC minifier); added `oxc` config block for JSX transform; kept `esbuild` config for `pure` + `legalComments` (deprecated but functional in Vite 8) |
21+
| `packages/gdu/commands/build/buildSPA-vite.ts` | `esmExternalRequirePlugin` imported from `'vite'` instead of `'rolldown/plugins'`; **fixed bug** where `external: undefined` was set when the plugin loaded (would have bundled all externals); simplified merge config |
22+
| `packages/gdu/commands/start/runSPA-vite.ts` | Added `oxc: base.oxc` for JSX in dev; `esbuild.pure` cleared for dev mode |
23+
| `.changeset/vite-dynamic-imports-esm-externals.md` | Updated to `minor` bump with Vite 8 upgrade description |
24+
25+
---
26+
27+
## Key Architectural Decisions
28+
29+
### OXC vs esbuild coexistence
30+
31+
Vite 8 introduces `oxc` as the new transform engine and deprecates `esbuild`. However, both can coexist:
32+
33+
- **`oxc`** handles JSX/TS transforms (the `jsx: { runtime: 'automatic', importSource: 'react' }` config)
34+
- **`esbuild`** (deprecated but functional) handles the renderChunk pass for `pure` (console stripping) and `legalComments: 'none'`
35+
36+
There is no direct OXC equivalent of esbuild's `pure: ['console.log', ...]` option. Rolldown's minifier has `dropConsole: boolean` in `CompressOptions`, but that drops **all** console methods including `console.error`. Our config selectively strips `log`, `info`, `debug`, and `warn` while preserving `error`.
37+
38+
**Note:** With `minify: true` (OXC minifier), the esbuild renderChunk plugin runs for target downlevelling but with `treeShaking: false`, so the `pure` annotations may not actually strip console calls in the final output. If console stripping is critical, consider either:
39+
1. Changing `minify` to `'esbuild'` (keeps esbuild as the minifier)
40+
2. Using `define` to replace console methods with no-ops: `'console.log': '(()=>{})'`
41+
42+
### The external removal bug fix
43+
44+
The previous code had:
45+
```ts
46+
...(hasEsmExternalPlugin ? { external: undefined } : {})
47+
```
48+
This would set `external` to `undefined` when `esmExternalRequirePlugin` loaded successfully — effectively removing all externals from the Rolldown config and causing them to be bundled into the output. The `esmExternalRequirePlugin` is designed to **coexist** with `external`, not replace it. The plugin handles CJS `require()` shims while `external` keeps the modules out of the bundle.
49+
50+
---
51+
52+
## How to Test with fmo-booking
53+
54+
### Step 1: Build gdu in octane
55+
56+
```bash
57+
cd ~/Documents/GitHub/octane
58+
yarn workspace gdu build
59+
```
60+
61+
### Step 2: Copy gdu build to MFE
62+
63+
```bash
64+
cd ~/Documents/GitHub/mfe
65+
yarn gdu:local
66+
```
67+
68+
This runs `.scripts/copy-gdu.js` which:
69+
1. Builds gdu in the sibling `octane` repo (`yarn workspace gdu build`)
70+
2. Copies `octane/packages/gdu/dist/` to `mfe/node_modules/gdu/dist/`
71+
3. Copies `octane/packages/gdu/entry/` to `mfe/node_modules/gdu/entry/`
72+
4. Copies `octane/packages/babel-preset/` to `mfe/node_modules/@autoguru/babel-preset/`
73+
74+
**Important:** The `copy-gdu.js` script runs its own `yarn workspace gdu build` internally, so you don't strictly need Step 1 separately. However, running it first lets you catch TypeScript errors before copying.
75+
76+
### Step 3: Ensure Vite 8 is installed in MFE
77+
78+
The MFE's `node_modules/gdu/node_modules/vite` (or the hoisted `node_modules/vite`) must be Vite 8. Since `gdu:local` only copies the `dist/` and `entry/` directories, it does **not** update `node_modules/vite`.
79+
80+
You need to ensure the MFE has Vite 8 available. The simplest approach:
81+
82+
```bash
83+
cd ~/Documents/GitHub/mfe
84+
85+
# Check current vite version
86+
node -e "console.log(require('vite/package.json').version)"
87+
88+
# If it's still 7.x, you may need to manually update or add a resolutions override
89+
# in the MFE root package.json:
90+
# "resolutions": { "vite": "8.0.0-beta.15" }
91+
# Then run:
92+
yarn install
93+
```
94+
95+
### Step 4: Build fmo-booking
96+
97+
```bash
98+
cd ~/Documents/GitHub/mfe
99+
APP_ENV=dev_au yarn workspace @autoguru/fmo-booking build:mfe
100+
```
101+
102+
### Step 5: Verify the build output
103+
104+
```bash
105+
# Check build manifest exists
106+
cat apps/fmo-booking/dist/dev_au/build-manifest.json | jq .
107+
108+
# Check the main JS bundle imports externals from esm.sh (not bundled inline)
109+
head -5 apps/fmo-booking/dist/dev_au/main-*.js
110+
111+
# Check for external import specifiers in the bundle
112+
grep -c 'esm.sh' apps/fmo-booking/dist/dev_au/main-*.js
113+
114+
# Check bundle size (should be significantly smaller than 2.6 MB)
115+
du -sh apps/fmo-booking/dist/dev_au/main-*.js
116+
```
117+
118+
**What to look for:**
119+
- The main JS should have `import` statements pointing to `esm.sh` URLs for react, react-dom, @datadog/*, etc.
120+
- These modules should NOT be bundled inline
121+
- Bundle size should be significantly smaller than 2.6 MB
122+
123+
### Step 6: Test with the local prod server
124+
125+
```bash
126+
# Kill any existing server on port 8080
127+
lsof -ti :8080 | xargs kill -9 2>/dev/null
128+
129+
# Start fresh server (reads build manifest at startup)
130+
node .scripts/mfe-local-prod-server.js fmo-booking dev au 8080
131+
```
132+
133+
Then open: http://localhost:8080/au/custom-fleet/booking
134+
135+
**Important:** The local prod server caches `build-manifest.json` at startup. If you rebuild, you **must** restart the server to pick up the new asset hashes.
136+
137+
---
138+
139+
## What Was Tried During Investigation
140+
141+
### Browser debugging session
142+
143+
Navigated to `http://localhost:8080/au/custom-fleet/booking?status=COMPLETED&...` and found:
144+
145+
1. **Page was blank** — only the environment banner and empty `<div id="__app__">` rendered
146+
2. **Network tab showed 4 requests**, all returning HTTP 200:
147+
- The HTML document
148+
- `main-DZvqnZTf.css` (200)
149+
- `main-Cm3RdaoF.js` (200)
150+
- `favicon.ico` (200)
151+
3. **The JS file was returning HTML**`curl http://localhost:8080/main-Cm3RdaoF.js` returned the SPA fallback HTML, not JavaScript
152+
4. **Root cause:** The build manifest on disk contained `main-B8JrHicf.js` but the server was serving HTML referencing `main-Cm3RdaoF.js` — the server had been started with an older build and the manifest was cached at startup. The hash mismatch meant `express.static` couldn't find the file, so the catch-all `app.get('*')` returned HTML instead
153+
154+
### Verification completed
155+
156+
- `tsc --noEmit` — passes (no type errors)
157+
- `yarn lint` — passes
158+
- `yarn test` — all 71 tests pass (11 suites)
159+
- `yarn install` — resolved Vite 8.0.0-beta.15 + Rolldown 1.0.0-rc.5 successfully
160+
- `esmExternalRequirePlugin` — confirmed exported from `vite` in v8 (`typeof vite.esmExternalRequirePlugin === 'function'`)
161+
- Rolldown `OutputOptions.paths` — confirmed supported (for external URL rewriting)
162+
163+
---
164+
165+
## Known Risks
166+
167+
1. **Vite 8 is beta** (`8.0.0-beta.15`) — may have undiscovered bugs
168+
2. **`@vanilla-extract/vite-plugin`** doesn't list `vite@8` in peer deps — added `peerDependencyMeta` override to suppress warnings. Rolldown supports Rollup plugins so it should work, but untested with this specific combination
169+
3. **`esbuild.pure` may not strip console calls** with `minify: true` (OXC) — the esbuild renderChunk plugin runs for target downlevelling but with `treeShaking: false`. Verify by checking if `console.log` appears in production bundles
170+
4. **Rolldown output format differences** — subtle differences between Rollup and Rolldown ES module output could cause runtime issues. Verify by checking import/export shapes in the bundle

packages/gdu/commands/build/buildSPA-vite.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { join } from 'path';
33

44
import { cyan, magenta } from 'kleur';
55

6+
import { getExternals } from '../../config/shared/externals';
67
import viteConfigs from '../../config/vite';
78
import { translationHashingPlugin } from '../../config/vite/plugins/TranslationHashingPlugin';
89
import { guruConfigCjsPlugin } from '../../config/vite/plugins/guruConfigCjs';
@@ -11,6 +12,36 @@ import type { InlineConfig } from '../../config/vite/types';
1112
import { GuruConfig } from '../../lib/config';
1213
import { CALLING_WORKSPACE_ROOT, PROJECT_ROOT } from '../../lib/roots';
1314

15+
// Native dynamic import that TypeScript CJS output won't rewrite to require()
16+
const dynamicImport = new Function('specifier', 'return import(specifier)') as (
17+
specifier: string,
18+
) => Promise<any>;
19+
20+
async function loadEsmExternalPlugin(
21+
externalKeys: string[],
22+
plugins: unknown[],
23+
): Promise<boolean> {
24+
if (externalKeys.length === 0) return false;
25+
26+
try {
27+
const { esmExternalRequirePlugin } = (await dynamicImport('vite')) as {
28+
esmExternalRequirePlugin: (config: {
29+
external: Array<string | RegExp>;
30+
}) => unknown;
31+
};
32+
plugins.push(esmExternalRequirePlugin({ external: externalKeys }));
33+
return true;
34+
} catch {
35+
console.warn(
36+
magenta(
37+
'Warning: esmExternalRequirePlugin could not be loaded from vite. ' +
38+
'CJS require() shims for externalised modules may not be resolved correctly.',
39+
),
40+
);
41+
return false;
42+
}
43+
}
44+
1445
export const buildSPAVite = async (guruConfig: GuruConfig) => {
1546
console.log(cyan('Building SPA with Vite...'));
1647

@@ -33,9 +64,9 @@ export const buildSPAVite = async (guruConfig: GuruConfig) => {
3364
const runtimePlugins: unknown[] = [];
3465

3566
try {
36-
const {
37-
vanillaExtractPlugin,
38-
} = require('@vanilla-extract/vite-plugin');
67+
const { vanillaExtractPlugin } = await dynamicImport(
68+
'@vanilla-extract/vite-plugin',
69+
);
3970
runtimePlugins.push(vanillaExtractPlugin());
4071
} catch {
4172
// Vanilla Extract plugin not available.
@@ -54,20 +85,25 @@ export const buildSPAVite = async (guruConfig: GuruConfig) => {
5485
}
5586

5687
runtimePlugins.push(guruConfigCjsPlugin());
88+
89+
const externalKeys = Object.keys(getExternals(guruConfig?.standalone));
90+
await loadEsmExternalPlugin(externalKeys, runtimePlugins);
91+
5792
runtimePlugins.push(
5893
translationHashingPlugin({
5994
appDir: PROJECT_ROOT,
6095
workspaceRoot: CALLING_WORKSPACE_ROOT || PROJECT_ROOT,
6196
}),
6297
);
6398

64-
const { build } = require('vite') as {
99+
const { build } = (await dynamicImport('vite')) as {
65100
build: (config: InlineConfig) => Promise<unknown>;
66101
};
67102

68103
for (const config of configs) {
69104
const mergedConfig: InlineConfig = {
70105
...config,
106+
build: config.build ? { ...config.build } : undefined,
71107
plugins: [...runtimePlugins, ...(config.plugins || [])],
72108
};
73109

packages/gdu/commands/start/runSPA-vite.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ import {
1919
PROJECT_ROOT,
2020
} from '../../lib/roots';
2121

22+
// Native dynamic import that TypeScript CJS output won't rewrite to require()
23+
const dynamicImport = new Function('specifier', 'return import(specifier)') as (
24+
specifier: string,
25+
) => Promise<any>;
26+
2227
const getConsumerHtmlTemplate = (
2328
guruConfig: GuruConfig,
2429
): string | undefined => {
@@ -180,15 +185,15 @@ export const runSPAVite = async (guruConfig: GuruConfig, isDebug: boolean) => {
180185
standalone: guruConfig?.standalone,
181186
}) as Record<string, any>;
182187

183-
const { createServer } = require('vite') as {
188+
const { createServer } = (await dynamicImport('vite')) as {
184189
createServer: (config: InlineConfig) => Promise<ViteDevServer>;
185190
};
186191

187192
// Load Vite-dependent plugins at runtime to avoid tsc compilation errors.
188193
let vanillaExtractPlugin: (() => unknown) | undefined;
189194
try {
190-
vanillaExtractPlugin =
191-
require('@vanilla-extract/vite-plugin').vanillaExtractPlugin;
195+
const veModule = await dynamicImport('@vanilla-extract/vite-plugin');
196+
vanillaExtractPlugin = veModule.vanillaExtractPlugin;
192197
} catch {
193198
// Vanilla Extract plugin not available — .css.ts files will fail at runtime.
194199
}
@@ -226,11 +231,13 @@ export const runSPAVite = async (guruConfig: GuruConfig, isDebug: boolean) => {
226231
devSourcemap: true,
227232
},
228233

229-
// Use esbuild's automatic JSX runtime (lighter than @vitejs/plugin-react
234+
// Use OXC's automatic JSX runtime (lighter than @vitejs/plugin-react
230235
// which uses Babel and causes OOM in large monorepos).
236+
oxc: base.oxc,
237+
238+
// Remove production-only console stripping for dev mode.
231239
esbuild: {
232240
...base.esbuild,
233-
// Remove production-only console stripping for dev mode.
234241
pure: undefined,
235242
},
236243

0 commit comments

Comments
 (0)