Skip to content

(POC): Investigate webpack and frontend tooling alternatives#19954

Closed
JacobCoffee wants to merge 6 commits into
poc/uv-localdevfrom
poc/frontend-modernize
Closed

(POC): Investigate webpack and frontend tooling alternatives#19954
JacobCoffee wants to merge 6 commits into
poc/uv-localdevfrom
poc/frontend-modernize

Conversation

@JacobCoffee
Copy link
Copy Markdown
Member

@JacobCoffee JacobCoffee commented Apr 28, 2026

A (fully vibed) continuation POC of pypi/warehouse#19953 but for frontend tooling:

  • npmbun
  • webpackrspack
  • eslintoxlint + oxfmt
Swap Before After Notes
Custom WebpackLocalisationPlugin (200 LOC, webpack-internal Parser hooks) n/a 30 LOC defineLocaleConstants() + DefinePlugin Door-opener for any modern bundler.
npm ci (cold) ~14 s ~7-8 s (bun) bun.lock (270 KB) replaces package-lock.json (540 KB).
webpack build ~24-25 s ~2 s rspack + ~21 s post-build = ~23 s total mini-css-extract / copy-webpack / webpack-manifest / compression / image-min all swapped or replaced (inline manifest plugin + bin/post-build.mjs).
eslint ~700-800 ms lint, ~1 s wall 9 ms oxlint + 27 ms oxfmt --check, ~1 s wall Stylistic rules deferred to oxfmt (one-shot reformat of 53 of 71 files). eslint.config.mjs deleted; @stylistic/eslint-plugin, eslint, @eslint/js, globals dropped.

oxlint caught 5 real bugs eslint missed:

  1. removeEventListener("submit", this.check.bind(this)).bind() returns a new function, so the listener never matches → never gets removed (memory leak + hidden double-firing risk if reconnected).
    2-5. expect(...).toBeNull (no parens) → reads the matcher as a property, never calls it → 4 tests silently passed without asserting anything.

All five fixed in this POC.

Post-build (gzip/brotli/sharp/svgo) is now the long pole, not rspack itself — that work used to live inside webpack's plugin chain, now runs in a separate node script (bin/post-build.mjs) and is parallelisable as a follow-up if needed.

Closing now, but just for ref. later because I imagine we'd want to move off of slow webpack into vite or rspack, into tooling like https://oxc.rs/ suite or biome.js, etc.

The custom WebpackLocalisationPlugin (~200 lines, in webpack.plugin.localize.js)
hooked into webpack's JS Parser internals (factory.hooks.parser.for("javascript/auto")
+ ConstDependency injection) to substitute two top-level variable initialisers
in messages-access.js with locale-specific data, per-locale.

That mechanism is webpack-specific. Any modern bundler (rspack, vite,
esbuild, …) implements parsing in Rust/Go and does not expose webpack's
parser hook API, so the plugin blocks every "swap webpack for X" path.

Replace it with the standard DefinePlugin pattern (works in webpack today,
in rspack tomorrow):

  - webpack.plugin.localize.js: drop the plugin class and its
    `webpack/lib/dependencies/ConstDependency` import; expose
    `defineLocaleConstants(localeData)` which returns the
    `{__WAREHOUSE_LOCALE_DATA__, __WAREHOUSE_PLURAL_FORM_FN__}` object that
    feeds DefinePlugin.

  - webpack.config.js: replace each `new WebpackLocalisationPlugin(...)`
    with `new DefinePlugin(defineLocaleConstants(...))`.

  - warehouse/static/js/warehouse/utils/messages-access.js: read the two
    values from the bundler-injected globals; fall back to English defaults
    via `typeof X !== "undefined"` guards so the file remains importable in
    plain Node / jest (where DefinePlugin doesn't run).

Net diff: ~50 LOC of custom plugin gone, behaviour preserved (jest passes,
build output is identical), and the door is open for swapping bundlers.
Drop package-lock.json (~540 KB) for bun.lock (~270 KB). Node base image
stays — sharp and sass-embedded need Node's native headers — but bun
handles install + script execution substantially faster than npm.

  - Dockerfile static-deps: COPY --from=oven/bun:1.3.9 the bun binary;
    npm ci -> bun install --frozen-lockfile;
    cache mount /root/.npm -> /root/.bun/install/cache.
  - Dockerfile static stage: npm run build -> bun run build.
  - bin/static_pipeline, bin/static_lint, bin/static_tests: npm run -> bun run.
  - Makefile docker-build state files now depend on bun.lock instead of
    package-lock.json.
  - docker-compose.yml: mount bun.lock instead of package-lock.json.

Bench: cold-cache install drops from ~14 s (npm ci) to ~7-8 s (bun install)
inside the Docker static-deps build.
Replaces the webpack 5 toolchain with rspack 2 (Rust, ByteDance) — a
~11x faster bundler. Possible because the previous commit removed the
custom WebpackLocalisationPlugin, the only piece tightly coupled to
webpack's JS Parser hooks.

Plugin compatibility:
  - mini-css-extract-plugin -> @rspack/core's CssExtractRspackPlugin
    (mini-css-extract relies on registerLoader, not implemented in rspack).
  - copy-webpack-plugin -> @rspack/core's CopyRspackPlugin
    (copy-webpack-plugin hangs in Compilation.hooks.processAssets under rspack).
  - webpack.ProvidePlugin -> rspack.ProvidePlugin (drop-in).
  - webpack.DefinePlugin -> rspack.DefinePlugin (drop-in).
  - webpack-manifest-plugin and rspack-manifest-plugin both hook beforeRun
    in a way rspack rejects; replaced with rspack.plugin.manifest.js (~70 LOC,
    handles the four options warehouse uses).
  - webpack-livereload-plugin: removed (rspack-incompatible). `bun run watch`
    rebuilds on change; full HMR via @rspack/dev-server is a follow-up.
  - css-minimizer-webpack-plugin, webpack-remove-empty-scripts: kept as-is
    (work fine under rspack).

Asset-pipeline features that webpack plugins handled in-band, now run
post-build in bin/post-build.mjs (no rspack equivalents exist):
  - compression-webpack-plugin -> gzip + brotli via node:zlib.
  - image-minimizer-webpack-plugin -> sharp (raster) + svgo (svg).

Files renamed for clarity:
  webpack.config.js          -> rspack.config.js
  webpack.plugin.localize.js -> rspack.plugin.localize.js

Verified end-to-end: rspack build ~2 s, post-build ~21 s (matches the
~25 s the old in-bundler equivalents took, but parallelisable later).
make build, full make serve, bin/static_pipeline, bin/static_lint,
bin/static_tests (92/92), bin/lint, uv lock --check, pytest --collect-only
(5500 tests) all pass. HTML responses serve cache-busted asset URLs that
resolve correctly via the new manifest plugin.
…mits

The prior commits on this branch (npm -> bun in 65d4127, webpack -> rspack
in 9916bcd) left behind a few references that point at files / commands
that no longer exist. Most are docs / comments, but one of them broke CI:

  - .github/workflows/node-ci.yml: setup-node was caching for `npm` and the
    install step ran `npm ci` against a deleted package-lock.json. Static
    Lint / Static Tests / Static Pipeline jobs all errored out at "lock file
    not found". Switch to oven-sh/setup-bun + `bun install --frozen-lockfile`.
  - Dockerfile: comments still referred to `webpack.config.js`; updated to
    `rspack.config.js`.
  - docs/dev/application.md: file ref webpack.config.js -> rspack.config.js.
  - docs/dev/translations.md: link webpack.plugin.localize.js ->
    rspack.plugin.localize.js.
  - docs/dev/development/frontend.md: explanation + dev-loop commands now
    show bun + rspack instead of npm + webpack; localization section now
    describes the DefinePlugin-injected globals.
Replace the eslint + @stylistic/eslint-plugin lint stack with the oxc.rs
toolchain: oxlint for correctness/bug rules, oxfmt for style/formatting.

  - Drop eslint, @eslint/js, @stylistic/eslint-plugin, globals,
    eslint.config.mjs.
  - Add oxlint@^1.62.0 and oxfmt@^0.47.0 plus .oxlintrc.json /
    .oxfmtrc.json (oxfmt --init defaults).
  - npm scripts: `lint` -> oxlint && oxfmt --check; `lint:fix` ->
    oxlint --fix && oxfmt.
  - .github/CODEOWNERS: drop the eslint.config.mjs / package-lock.json /
    webpack.* rows; add .oxlintrc.json, .oxfmtrc.json, bun.lock,
    rspack.config.js, rspack.plugin.localize.js, rspack.plugin.manifest.js
    rows. (The package-lock and webpack rows were missed in the earlier
    bun and rspack commits.)
  - docker-compose.yml: drop the eslint.config.mjs static-service mount;
    add .oxlintrc.json + .oxfmtrc.json mounts so the lint config is
    visible inside the container.

Lint speed (this codebase): eslint ~700-800 ms -> oxlint 9 ms (~80x).
Wall-clock is dominated by Node startup at this codebase size, so the
absolute saving is small; the real win is the bug coverage oxlint
brings via the unicorn plugin.

oxlint surfaced 5 real bugs eslint missed; fixed in this commit:

  1. warehouse/static/js/warehouse/controllers/email-confirmation_controller.js
     `removeEventListener("submit", this.check.bind(this))` — `.bind()`
     returns a *new* function, so the listener never matches and is never
     removed. Fixed by storing the bound reference on `this`.

  2-5. tests/frontend/viewport_toggle_controller_test.js
     Four `expect(...).toBeNull` (no parens) calls. The matcher is read
     as a property and never invoked, so the assertions silently pass
     no matter what. Fixed to `.toBeNull()`.

oxfmt reformatted 53 of 71 JS files to its defaults (prettier-shaped:
2-space indent, double quotes, trailing comma all, 80 col, lf). Bigger
diff is one-shot; subsequent edits will land formatted via `lint:fix`.

Verified: `bun run lint`, `bin/static_tests` (92/92 in container with
TZ=UTC), `bin/static_lint`, `bin/static_pipeline` all pass.
Previous commit (382dbad) used a fabricated commit SHA for
oven-sh/setup-bun, which made the action unresolvable and broke all
three Node CI jobs (Static Tests / Static Lint / Static Pipeline).

Pinning to v2.2.0 (0c5077e51419868618aeaa5fe8019c62421857d6) — the
current latest release — verified via gh api.
@JacobCoffee
Copy link
Copy Markdown
Member Author

CI wall-clock comparison: poc/uv-localdev (uv migration only) → poc/frontend-modernize (uv + bun + rspack + oxlint/oxfmt).

Same workflow (.github/workflows/node-ci.yml), same ubuntu-24.04 runners. Three matrix jobs, run in parallel.

Job uv-localdev baseline frontend-modernize Δ
Static Lint (bin/static_lint: bun run lint + bun run stylelint) 34 s 24 s −29% / 1.4×
Static Tests (bin/static_tests: jest 92/92) 29 s 21 s −28% / 1.4×
Static Pipeline (bin/static_pipeline: build + post-build + sourcemaps) 110 s 54 s −51% / 2.0×
Total Node CI wall (max parallel job) 110 s (1m50s) 54 s (54s) −51% / 2.0×

Where the time went:

  • Static Pipeline is where it shows up most — bun install (~7 s vs npm's ~14 s) + rspack build (~2 s vs webpack's ~25 s) + bin/post-build.mjs (~21 s, new — the gzip/brotli/sharp/svgo work that webpack did in-band).
  • Static Lint runs oxlint (9 ms) instead of eslint (~700 ms); rest is stylelint + Node startup, unchanged.
  • Static Tests is jest in both cases; the small win comes from bun run having faster startup than npm run.

Stacked vs main: with PR #19953 already merging the uv work for −36% on the main CI workflow (7m21s → 4m42s), this PR's frontend work piles ~−1 minute onto Node CI specifically. Combined: PR-iteration cycles see a much smaller frontend feedback loop on every commit (54 s vs 110 s = back to your editor 56 s sooner per push).

@JacobCoffee JacobCoffee changed the title refactor(frontend): replace WebpackLocalisationPlugin with DefinePlugin (POC): Investigate webpack and frontend tooling alternatives Apr 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant