Migrate from Webpack to Vite#1526
Conversation
|
@copilot resolve the merge conflicts in this pull request |
Resolved by merging |
Phase 0 of migrating the root editor-ui build from the custom ejected-CRA webpack 5 setup to Vite ~8.1.x, matching the toolchain already used by apps/scratch-frame (rolldown-vite, @vitejs/plugin-react classic runtime, envPrefix REACT_APP_, vite-plugin-static-copy). Captures the locked decisions and the phased approach: - Keep Jest (bundler-independent; one edit to scripts/test.js to drop the config/env dependency, preserve Jest-only deps). - Use a `define` shim so process.env.* stays in source (keeps Jest green); no codemod to import.meta.env. - Land as a single PR; root pinned to ~8.1.x, scratch-frame left at ^8.0.16. Documents the non-trivial webpack behaviours needing deliberate Vite equivalents: SCSS-as-string Shadow DOM injection (?inline), split SVG handling (svgr for icons, URLs otherwise), the standalone PyodideWorker.js output plus COOP/COEP/CORP headers, multi-page HTML entries, node polyfills, static-copy, and dev server on port 3011 for Cypress. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 1 of the Vite migration: add the build-time devDependencies needed for the Vite config, without yet touching webpack. Webpack-only packages are intentionally left in place and will be removed in the final phase, once the Vite build is verified green. Added (resolved versions): - vite ~8.1 -> 8.1.2 (Rolldown-powered, exposes build.rolldownOptions, matching apps/scratch-frame's config style) - @vitejs/plugin-react ^6 -> 6.0.3 (classic JSX runtime, hosts the babel-plugin-prismjs config) - vite-plugin-svgr ^5.2.0 (SVG-as-React-component for src/assets/icons, replacing @svgr/webpack) - vite-plugin-static-copy ^4 -> 4.1.1 (replaces copy-webpack-plugin for src/projects and python-error-copydecks; same plugin scratch-frame uses) - vite-plugin-node-polyfills ^0.28.0 (replaces webpack resolve.fallback for stream/assert/path/url pulled in transitively by skulpt/jszip) sass, postcss, babel-plugin-prismjs and the stylelint stack are already present and reused. The pre-existing peer-dependency warnings (eslint, react ranges) are unchanged by this step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Vite is HTML-centric and emits each HTML input at its path relative to the project root, so the two entry templates must live at the root (not under src/) to build to build/web-component.html and build/html-renderer.html -- matching the filenames webpack's HtmlWebpackPlugin produced today. - Move src/web-component.html -> web-component.html - Move src/index-html-renderer.html -> html-renderer.html - Add explicit <script type="module"> tags pointing at the entry modules (/src/web-component.js and /src/html-renderer.jsx). HtmlWebpackPlugin injected these automatically; Vite requires them in the template. - Point the still-present webpack HtmlWebpackPlugin templates at the new root paths so webpack.config.js stays loadable until it is removed in the final phase. All references to the served URLs (/web-component.html in Cypress specs and docs, /html-renderer.html in HtmlRunner's iframe) are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
e603ef0 to
e8dbe2f
Compare
e8dbe2f to
27ab964
Compare
27ab964 to
64fd022
Compare
dd08a45 to
e576900
Compare
e576900 to
861c10e
Compare
External host sites embed the editor with a plain classic <script src="...web-component.js"> tag (README "Usage"), not an ES module, and web-component.js is side-effect-only (it just registers the <editor-wc> custom element). Vite's default HTML-entry output is an ES module loaded via <script type="module">, which a classic <script> tag cannot execute -- this would silently break every consuming site. The app also has no dynamic imports, so, exactly like the old webpack `[name].js` entries, each entry can and must be a single self-contained bundle. Change the build to emit classic IIFE bundles instead of an HTML-entry MPA: - vite.lib.js: shared helpers (buildDefine, resolveBase, appPlugins, emitClassicHtml, iifeBuildOptions). iifeBuildOptions produces an unhashed [name].js IIFE; emitClassicHtml emits the entry's HTML template with its dev-only module <script> rewritten to a classic <script src="./bundle.js">, so the deployed preview pages load the bundle the same way external hosts do. - vite.config.js: primary build emits web-component.js (IIFE) + its preview page, empties build/ and copies public/. Dev server behaviour is unchanged (appType "mpa" for the two HTML test pages, CORP headers, worker dev serving). - vite.html-renderer.config.js: emits html-renderer.js (IIFE) + its preview page, appended to the same build/. It is loaded as a full page inside an iframe, so a single classic script is simplest and most robust cross-origin. A classic IIFE build cannot contain more than one entry, so the three bundles (web-component, html-renderer, PyodideWorker) are separate build configs chained in the build script (wired in phase 4). No import.meta usage exists in src, so IIFE is safe. The IIFE mechanism is already verified by the PyodideWorker build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Vite/Rolldown native transform (builtin:vite-transform, oxc) treats .js as JSX-free and errored on the <editor-wc> JSX in src/web-component.js; unlike webpack's babel-loader (which ran on all .js), oxc keys JSX parsing on the file extension and OxcOptions omits `lang`, so it cannot be forced per-extension. The codebase convention is already .jsx for JSX and .js for plain JS. Only three non-test .js files actually contain JSX, so rename them to match: - src/web-component.js -> src/web-component.jsx - src/utils/Notifications.js -> src/utils/Notifications.jsx - src/utils/ResizableWithHandle.js -> src/utils/ResizableWithHandle.jsx The emitted bundle keeps the name web-component.js (entryFileNames), so external consumers are unaffected. All importers use extensionless imports, which resolve to .jsx under Vite, webpack (resolve.extensions) and Jest (moduleFileExtensions), so no import sites change. The entry references are updated: the dev module script in web-component.html, the Vite build entry, and the webpack entry. Note: this only fixes JSX parsing of the entry graph. A full `vite build` still depends on the phase 3 source touch-ups (?inline SCSS, ?raw markdown) and is verified end-to-end in phase 5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Under webpack the CSS rule used css-loader with NO style-loader, so no .scss or
.css import ever injected into the DOM; the web component instead injects the
aggregated stylesheets into its shadow root manually via
<style>{internalStyles.toString()}</style>. Vite, by contrast, auto-injects any
plain CSS import into the light DOM, which would both leak styles onto the host
page and emit dead CSS.
Append ?inline to every .scss and .css import in src so each returns the
compiled CSS as a string and never auto-injects - exactly matching webpack:
- The four aggregator imports (InternalStyles, ExternalStyles, index, HtmlRunner)
are used as strings; .toString() on a string is a no-op, so the call sites are
unchanged and still feed the shadow-DOM <style> tags.
- The 56 per-component side-effect .scss imports and 3 third-party .css imports
were redundant no-ops under webpack (the real styles come from the aggregated
InternalStyles/ExternalStyles injected into the shadow root); ?inline keeps
them no-ops instead of letting Vite inject them.
There are no CSS-module (.module.scss) imports, so ?inline is safe everywhere.
Jest: add moduleNameMapper entries stripping the ?inline query so the existing
jest-scss-transform / jest-css-modules-transform still apply. Verified
WebComponentLoader.test.js (which asserts on the injected <style> contents)
still passes 33/33.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The single .md import (demoInstructions in InstructionsPanel) was handled by raw-loader under webpack, giving the markdown source as a string. Vite's equivalent is the ?raw query suffix. Append it so the import keeps returning the raw markdown string. Jest: add a moduleNameMapper entry stripping the ?raw query so the import resolves to the .md file, which continues to hit jest-transform-stub exactly as before (markdown was already stubbed in tests, not loaded as real content). Verified InstructionsPanel.test.js still passes 36/36. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first real `vite build` failed in vite-plugin-static-copy: it globs with
onlyFiles:true, so `copydecks/*` matched nothing (copydecks contains only an
`en/` subdirectory, no top-level files). It also builds each destination path
relative to the project root, so a deep node_modules source would otherwise be
recreated verbatim under dest.
Add a copyDirTarget helper (vite.lib.js) that copies a directory's contents
recursively (`**/*`) and strips exactly the leading path segments up to and
including the source dir via rename.stripBase - the same technique
apps/scratch-frame uses. This reproduces copy-webpack-plugin's
`{ from: dir, to: dest }` behaviour:
- src/projects -> build/projects/*.json
- .../python-friendly-error-messages/copydecks -> build/python-error-copydecks/
preserving en/ (loadCopydeckFor fetches `${base}/${lang}/copydeck.json`).
With this, `vite build` completes and emits build/web-component.js. Verified it
is a self-contained classic IIFE (starts with `(function(){`), contains no
top-level import/export and no import.meta, and parses under vm.Script as a
classic script - i.e. external sites loading it via a plain <script> tag keep
working. The preview build/web-component.html correctly references it with a
classic <script src="web-component.js">.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cut the root start/build/analyze scripts over from webpack to the three Vite configs (webpack.config.js itself is left in place; it is deleted in the final phase once everything is verified): - start: "vite" (was webpack serve). Dev server config, port and headers are unchanged - all defined in vite.config.js already. - build: chains the three per-entry Vite builds in the order they must run - `vite build` (web-component, primary: empties build/ and copies public/), then `vite build --config vite.html-renderer.config.js` (appends html-renderer.js), then `vite build --config vite.worker.config.js` (appends PyodideWorker.js) - because a classic IIFE build can only have one entry. Verified: full chained build reproduces the old build/ tree (public/ assets, projects/, python-error-copydecks/, plus all three bundles), and both web-component.js and html-renderer.js parse as valid classic scripts. - analyze: "ANALYZE=true yarn build" replaces "ANALYZE_WEBPACK_BUNDLE=true yarn build". Added rollup-plugin-visualizer (devDependency) and wired it into the primary build only, gated on ANALYZE=true, writing an interactive treemap to build/stats.html. It's ESM-only, so it's imported dynamically inside the (now async) config function rather than statically, so requiring the config never has to load it when analysis isn't requested. Verified the report is generated. NODE_ENV/BABEL_ENV are no longer exported manually - the Vite CLI sets mode (development for `vite`, production for `vite build`) automatically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Update the docs that described the now-replaced ejected-CRA webpack build: - AGENTS.md (symlinked from CLAUDE.md, read by AI agents working in this repo): replace "webpack dev server" / "webpack compiled successfully" mentions with the Vite equivalents, correct the dev-server startup timing (Vite compiles on demand and is ready in well under a second, not ~15s), note that config/ is now Jest-only (CRA/webpack-era leftovers, unused by the Vite build), and add an orientation note listing the three root vite.config*.js files so a future agent touching the build knows where to look. - README.md: describe the build as Vite-based, drop the now-inaccurate "build is minified and the filenames include the hashes" (bundle filenames are fixed, not hashed - other sites load web-component.js by name) and the CRA deployment-docs link (replaced with the repo's own Deployment section), document the new `yarn analyze` output. - docs/WebComponent.md: replace the CRA-eject / webpack.config.js description with the three Vite config files and what each builds. docs/Deployment.md's webpack mention is left as-is; it's an explicitly dated 2022-08 historical snapshot, not living build documentation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found while verifying the full Cypress suite against `yarn start`: nearly
every spec that visits /web-component.html failed (initially as 18/19
failures in missionZero-wc.cy.js, traced via Cypress's own CI log output).
The page had a <style> block between <html> and <head>, with <head> opening
partway through the document, and a trailing <script> placed after </body>
but before </html>. Browsers silently error-correct this via the standard
HTML5 tree-construction algorithm, so it worked fine served statically by
webpack's HtmlWebpackPlugin. Vite's dev/build HTML pipeline parses the file
with parse5 (confirmed via `parse5.parse(html, { onParseError })`: the
original structure raises `misplaced-start-tag-for-head-element`), and
whatever recovery it performs left the module script effectively failing to
wire up correctly, so the web component never mounted - explaining the
sweeping, unrelated-looking Cypress failures across many specs.
Fix: move <head> back to being the true first child of <html>, move the
<style> block inside it, and move the trailing <script> inside <body>.
Purely structural - no styles, script logic, or attributes changed. Verified
0 parse5 errors on the corrected file (vs. 1 on the original), and the full
Cypress suite (72 tests across 7 specs) now passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…r (phase 5)
Found via the same Cypress verification pass, after fixing the HTML structure:
every test that triggers a friendly-error-copydeck load (Skulpt/Pyodide
runners) still failed with "No copydeck found for en" - an unhandled promise
rejection. Traced it down to two compounding bugs, both specific to `yarn
start` (dev serve); `yarn build` was never affected.
1. vite@8.1.2's built-in `vite:define` plugin no-ops for the dev server's
"client" consumer - its transform handler literally returns early when
`environment.config.consumer === "client"` (only the bundled/build code path
applies `define`). So every `process.env.X` reference survived as literal
source text during dev; `define` only ever worked for `vite build`, which is
why the extensive build-time verification in phases 2-4 never caught this.
2. vite-plugin-node-polyfills defaults `globals.process` to `true`, which
injects a per-module local `process` binding (via esbuild/rolldown inject)
with an empty, fake `.env`. A local binding always shadows a global of the
same name, so even after working around bug 1 with a real `window.process`
global, the app kept reading the polyfill's empty env instead - evaluating
`process.env.PUBLIC_URL` to the literal string "undefined" rather than
throwing or using the real value (confirmed by intercepting fetch: the app
was requesting .../undefined/python-error-copydecks/en/copydeck.json).
Fixes:
- vite.lib.js: extract `processEnvValues(mode, env)` as the single source of
truth for the app's process.env.* keys, used both by `buildDefine` (build's
literal-replacement) and the new `processEnvDevServer` plugin, which injects
a `<script>window.process = { env: {...} }</script>` via `transformIndexHtml`
(dev-only, `apply: "serve"`) so the real values are available as an actual
runtime global before any module script executes.
- Disable vite-plugin-node-polyfills' `process` global (`globals.process:
false`) so nothing shadows it. `global` stays enabled - plotly.js references
the Node `global` object directly and throws ReferenceError without it
(confirmed: disabling it broke spec-wc-pyodide's plotly-dependent tests).
Verified via direct browser-context reproduction (window.process.env, a
fetch-interception harness) before and after each fix, then confirmed the
full Cypress suite passes: 72 tests across all 7 specs (71 passing, 1
pre-existing it.skip), including spec-wc-pyodide.cy.js (23/23) and
missionZero-wc.cy.js (19/19, was 1/19 before this fix).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…hase 5)
Final cleanup now that the Vite build is fully verified: yarn test (931/931),
yarn lint, yarn stylelint, yarn build (all three bundles, confirmed valid
classic scripts), and the full Cypress suite (72/72, 1 pre-existing skip) all
pass without webpack in the picture.
- Delete webpack.config.js (superseded by vite.config.js,
vite.html-renderer.config.js, vite.worker.config.js).
- Delete config/env.js and config/paths.js (CRA/webpack-era dotenv-loading and
path-resolution helpers). Their only remaining consumer was
scripts/test.js's `require("../config/env")`; inlined the same dotenv
precedence there directly (.env.test.local, .env.test, .env - .env.local is
intentionally skipped for test runs) using the dotenv/dotenv-expand
dependencies directly, so Jest's env loading is unaffected. Their
getClientEnvironvironment export and NODE_PATH setup were already dead code
(webpack.config.js never imported config/env.js).
- Remove 17 webpack-only devDependencies: webpack, webpack-cli,
webpack-dev-server, webpack-bundle-analyzer, html-webpack-plugin,
copy-webpack-plugin, dotenv-webpack, worker-plugin, @svgr/webpack,
css-loader, sass-loader, resolve-url-loader, file-loader, url-loader,
raw-loader, babel-loader, react-dev-utils. Audited every one for use
outside package.json first; the only other hits were explanatory comments
in the new vite.*.js files and unrelated vendored third-party files under
public/ (p5.js, pygal.js) mentioning "webpack" in their own source.
sass (used directly by Vite and the watch-css script) is unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously, preview omitted the cross-origin headers and resource policy middleware that host applications require to load the built classic bundle. The generated preview page also evaluated that bundle before document.body existed. This change shares the dev-server resource policy and isolation headers with preview, and emits deferred classic bundle scripts. A host application can now validate its production loading contract against yarn preview on localhost:3011.
I noticed that some tests were failing which might be because vite can take longer to build assets the first time they are requested. Using the build is better anyway as it is more likely to match production.
I'd like to try to keep browser support the same as we switch to vite to avoid unexpected changes. We should review our browser support as a separate task
These tests fail intermittently locally and on CI because the resize assertion can run before the native handle drag has completed. Return the Cypress drag chain from the resize helpers and chain each assertion from it, so the width or height is checked only after the drag resolves.
Resizing appended a pixel delta to CSS dimensions. Percentage defaults such as `50%` therefore became invalid values like `50%-100`, leaving the pane at its original size. Persist the dimension measured by `re-resizable` after each drag, updating only the resized axis. Add coverage for both horizontal and vertical resizes.
Vite's CSS minifier treats the old webpack CSS-loader :export syntax as an invalid pseudo-class. The editor only injects this stylesheet into its Shadow DOM and never consumes those exports, so removing the unused block eliminates repeated production-build warnings without changing the emitted styles.
Sentry 7.16.0 ships ESM helpers that refer to CommonJS exports, which Rolldown warns may fail in the Vite build. Upgrade to the Node-16-compatible Sentry 8 line and use its functional browser tracing integration; this also removes the obsolete @sentry/tracing package.
The friendly-error package includes an import.meta fallback, while the editor is deliberately emitted as a classic IIFE where import.meta is invalid. All editor callers supply the package's explicit copydeck base URL, so explicitly replacing import.meta with an empty object matches the required runtime contract and removes Rolldown's warning.
External hosts load the web component as one fixed-name classic script, so Vite cannot code-split it without changing that public contract. Set the shared IIFE warning threshold to 10 MiB to retain a useful size guard while allowing the current production bundle.
27d0a2c to
bcddb12
Compare
Previously, Vite rewrote the logical handle offsets into\nRTL-specific selectors before style-it scoped them, so the resulting\nselector did not match the resize handle.\n\nThis change uses physical offsets for the two resize handles, avoiding\nthat transform and preserving their centred position in generated CSS.
3c3df1d to
3c48070
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 3c48070. Configure here.
| NODE_ENV: mode, | ||
| PUBLIC_URL: env.PUBLIC_URL ?? "", | ||
| ASSETS_URL: env.ASSETS_URL || env.PUBLIC_URL || "", | ||
| HTML_RENDERER_URL: env.HTML_RENDERER_URL ?? "", |
There was a problem hiding this comment.
Missing HTML renderer URL fallback
Medium Severity
The Cypress CI workflow doesn't set HTML_RENDERER_URL, causing browserProcessEnvValues to default it to an empty string during the build. This empty value is baked into HTML preview bundles, breaking HtmlRunner's iframe src and postMessage targetOrigin, which prevents HTML preview messaging from working in CI.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 3c48070. Configure here.
Previously, the real-events plugin could miss the narrow vertical resize handle in CI. The resulting mousedown never reached re-resizable, so the resize state stayed unchanged and the test timed out. This change dispatches MouseEvents directly to the handle and application window. It continues to exercise re-resizable’s resize path without relying on browser-level hit testing.
3c48070 to
7fdf6c8
Compare


Summary
Migrates the root
editor-uibuild from webpack to Vite 8.1.Key decisions
web-component.js,html-renderer.js, andPyodideWorker.js—as separate chained Vite builds. In particular, the Pyodide worker remains a standalone scriptprocess.env.*usage and provide Vitedefinereplacements plus a dev-server shim, avoiding a broadimport.meta.envmigration and keeping Jest compatible.?inlineShadow DOM styles,?rawMarkdown imports, static copying for non-public assets, and Node polyfills.See commits for more detail, including the migration plan document in the first commit.
Verification
Comparison
yarn buildtimeweb-component.jssizeNote the Cypress CI speedup is partly mostly due to testing against the the built code rather than the build server and a little due to the faster build time.
Generated with the help of @cocomarine, @zetter-rpf, Claude and Codex