Migrating a 5-year-old React admin app from CRA + Webpack to Vite + SWC โ€” 166 files, 70 days
DEV Community

Migrating a 5-year-old React admin app from CRA + Webpack to Vite + SWC - 166 files, 70 days

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry. The build was fine. Everything else was on fire. Every codebase has a number that nobody says out loud. Ours was the time between hitting Ctrl+S and seeing the change in the browser. Long enough to check Slack. Long enough to forget what you were testing. On a cold start, long enough to make coffee. DreamNet is the internal admin platform for ZURU's housing division - user management, RBAC, asset catalogues, project publishing, release builds, an order-pricing engine, event management, dashboards with charts and maps. Roughly 130 source files across src/scene alone. It was scaffolded with Create React App and had been running on react-scripts@3.0.1 - a release from 2019 - held together with rewire, env-cmd , and node-sass@4.14.1 , which needed a specific Node version to even compile. The proposal was simple: replace the build tool. Vite + SWC. Faster cold start, near-instant HMR, better production builds. The reality was that the build tool was the only part that went smoothly. This is the story of MR !907 - 166 changed files, 28+ diff revisions, 19 review comments, and 70 days between "let's swap the bundler" and green on production. Why a bundler swap is never a bundler swap Here's the thing nobody tells you about CRA: CRA is not a bundler. ** CRA is an API. Over five years, a codebase doesn't just use webpack. It absorbs webpack's semantics into its source. process.env , %PUBLIC_URL% , import { ReactComponent as Icon } , JSX inside .js files, implicit Node globals in browser code, automatic Babel transpilation of every CommonJS dependency you ever installed. None of those is React features. All of them are load-bearing. So the first thing I did was not write vite.config.mjs . I ran a survey: every place the source assumed that only webpack could satisfy. That list became the actual scope of work - and it was about ten times larger than the config file. The config file, for the record, is 23 lines: import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; import svgr from 'vite-plugin-svgr'; export default defineConfig({ plugins: [react(), svgr({ exportAsDefault: true })], envDir: 'environments', server: { port: 3000 }, preview: { port: 3000 }, build: { outDir: 'build' }, }); build.outDir: 'build' and port: 3000 are deliberate. Vite defaults to dist and 5173 . Our GitLab CI jobs, Docker image, and deploy scripts all expected build/ on :3000 . Changing the bundler and the deployment contract in the same MR is how you end up with a broken pipeline you can't attribute to anything. Absorb the churn in config, not in infrastructure. Constraint one: React 16 stays The tempting move here is to bundle the migration with a React 18 upgrade. ReactDOM.render is deprecated, createRoot is right there, and you're already in the file. I didn't. We stayed on react@16.14.0 , ReactDOM.render , and react-router-dom@4.3.1 . All of it. The reasoning: a build migration has no user-visible intent. If nothing changes for the user, then every visual or behavioural difference is a regression, full stop. That is an incredibly powerful invariant to review against - the reviewer can just diff the two environments side by side, and any delta is a bug. Fold a React 18 upgrade in, and you lose it: now some differences are expected, some aren't, and every discussion becomes an argument about which is which. The MR is labelled a breaking change for tooling. It should be a no-op for behaviour. Chapter 1: type: module and the file-extension domino Adding "type": "module" to package.json is one line. It cost a day. Node now treats every .js file in the project as ESM. Which means: - .eslintrc.js usedmodule.exports โ†’ crashes. Renamed to.eslintrc.cjs . - The Vite config had to be .mjs to be unambiguous. - .lintstagedrc.js had to be written asexport default { ... } . Then esbuild's rule hit: esbuild will not parse JSX inside a .js file. Babel happily did. esbuild refuses, by design, because the .js extension makes no promise about JSX and guessing costs parse time. That forced a wave of renames - src/app.js โ†’ app.jsx , src/index.js โ†’ index.jsx , src/service/apollo_wrapper.js โ†’ .jsx - and one genuine refactor. src/constants.js was a data module that had quietly grown JSX in it: nav configs, ingredient lists, session-OS maps, all carrying icon: . I split it into a new src/constant.jsx holding everything JSX-bearing, leaving constants.js as pure data. That split turned out to be worth doing on its own merits - it stopped a 200-line render-bearing module from being imported by things that only wanted string constants. Takeaway: extension discipline feels like bureaucracy under Babel. Under esbuild it's a type system. It's a better default. Chapter 2: process.env doesn't exist in the browser CRA injected process.env into browser code. Vite doesn't - it exposes import.meta.env , and only for variables prefixed VITE_ . Every REACT_APP_* reference had to move. src/config.js alone was a solid block of it: - const cloudFunctionsEnvironment = process.env.REACT_APP_API_ENV; - export const serverProxyURL = process.env.REACT_APP_PROXY_URL; - export const redirectURL = process.env.REACT_APP_REDIRECT_URL; + const cloudFunctionsEnvironment = import.meta.env.VITE_API_ENV; + export const serverProxyURL = import.meta.env.VITE_PROXY_URL; + export const redirectURL = import.meta.env.VITE_REDIRECT_URL; โ€ฆplus the entire cookie-name map, which derives ten keys off VITE_COOKIE_PREFIX , and the OAuth token-refresh config in helper.jsx . The subtle part isn't the rename. It's that process.env.FOO on an unset variable silently yields undefined , and you get a broken URL at runtime. There is no build-time error. A single missed rename ships a undefinedaccess_token cookie to production, and you find out from a support ticket. Two things de-risked this: - A grep-and-verify pass, not a find-and-replace pass. Every hit reviewed individually, because some process.env references were in Node-side config that should stayprocess.env . - Consolidating the env files. CRA's env-cmd setup had.env ,.env.local ,.env.localV2 ,.env.development ,.env.staging scattered at the repo root, selected by npm script. I moved them intoenvironments/ and let Vite's native mode flag drive selection: "local": "vite --mode localhost", "build:dev": "vite build --mode development", "build:stage": "vite build --mode staging", "build": "vite build" with envDir: 'environments' in the config. env-cmd deleted. One mechanism instead of two, and the mode name is now visible in the command you actually type. index.html also moved from public/ to the project root - Vite treats it as the build entry, not a template - and %PUBLIC_URL% placeholders became plain absolute paths. Chapter 3: the white screen with no stack trace First successful build. Open the app. White screen. Console: Uncaught ReferenceError: global is not defined . This is the migration bug that eats an afternoon, because the error points at bundled vendor code and tells you nothing about why it's there. The cause: global is a Node identifier. It does not exist in browsers. Webpack, being a Node-first bundler, silently shimmed it for every CommonJS dependency that reached for it. Vite - an ESM-first, browser-first bundler - does not. Our dependency tree was old enough to be full of candidates. socket.io-client@2.3.0 and draft-js@0.10.5 are both from an era when "just assume global " was normal library code. Because the reference is inside a dependency, you can't fix it in your own source. The fix I shipped is deliberately blunt - a shim in index.html , before the module entry: var global = global || window Why this and not define: { global: 'window' } in the Vite config? define does a raw textual substitution across every module at build time. That's a shotgun: it rewrites the identifier global everywhere, including inside strings and comments in dependency code, and it behaves differently in dev vs build. I hit a real inconsistency between vite dev and vite build output while testing it. The HTML shim is honest about what it is: one global, defined once, in the document, visible to anyone who opens index.html . It's not elegant. It's legible, and for a shim whose entire purpose is to be deleted the day those two dependencies get upgraded, legibility beats elegance. That's the trade-off I'd defend in review, and did. Chapter 4: five dependencies that couldn't make the jump This is where the migration stopped being about the build and started being about the product. Some packages simply don't survive contact with ESM + esbuild - they ship CommonJS-only, depend on Node builtins, or were abandoned before ESM mattered. Five had to go: | Package | Replaced with | |---|---| reactjs-localstorage | a 15-line storageWrapper | rc-time-picker-date-fns | native | react-sortable-hoc + array-move | plain function-returned JSX | react-numeric-input | native numeric input | node-sass@4.14.1 | sass@1.69.7 (Dart Sass) | node-sass deserves a footnote: it's a native binding compiled against a specific Node ABI. It's the reason the project was pinned to an old Node in the first place. Deleting it is what let CI move from node:14.16.1 to node:16.20.2 - which Vite 4 requires anyway. One dependency was holding the entire toolchain hostage. reactjs-localstorage was the easy one. The library's whole surface is get /set /getObject /setObject , and helper.jsx had grown nine thin wrappers around it (StoreCookie , GetDcIdToken , StoreLastLoggedInUser , DeleteIdTokenCookie โ€ฆ) most of which nothing called any more. I replaced the package and the nine wrappers with one object: export const storageWrapper = { set: (key, value) => { localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value)); }, get: (key, defaultValue) => localStorage.getItem(key) || defaultValue, remove: (key) => localStorage.removeItem(key), clear: () => localStorage.clear(), }; Net: one

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.