Skip to content

test(react-icons-atomic-webpack-loader): verify fixtures against rspack - #1224

Draft
Martin Hochel (Hotell) wants to merge 6 commits into
microsoft:mainfrom
Hotell:feat/atomic-loader-rspack-harness
Draft

test(react-icons-atomic-webpack-loader): verify fixtures against rspack#1224
Martin Hochel (Hotell) wants to merge 6 commits into
microsoft:mainfrom
Hotell:feat/atomic-loader-rspack-harness

Conversation

@Hotell

Copy link
Copy Markdown
Collaborator

Stacked on #1219. That PR adds @rspack/core to the root manifest and establishes the
dual-bundler harness pattern this one reuses. Until it merges, the diff here also shows its
12 files — review only packages/react-icons-atomic-webpack-loader/**.

Why

@fluentui/react-icons-atomic-webpack-loader was only ever exercised against webpack. Since we're
now claiming rspack support across the icon tooling, that claim should be tested rather than assumed.

Change

The fixture entries and their assertions move into a shared test/make-configs.js, and
test/run.js (--bundler webpack|rspack|all) drives both bundlers over the same 17 entries.
test/webpack.config.js becomes a thin wrapper, so the existing webpack behaviour is unchanged.

The rspack config deliberately keeps the same ts-loader setup as webpack rather than switching
to builtin:swc-loader, so that any difference in emitted output comes from the bundler rather than
from a different TypeScript toolchain.

Result: no per-bundler overrides were needed

All 17 entries pass under rspack unmodified, including the three things I expected to be risky:

Expected risk Outcome
ts-loader under rspack (.jsx / .tsx fixtures) works
experiments.outputModule + library: { type: 'module' } works; every mustInclude/mustExclude string assertion holds
Emitted-warning assertions (dynamic-barrel-imports) works after the fix below

make-configs.js still supports overrides.<bundler> so a future divergence has somewhere to go,
but nothing uses it today.

One real bug found

The assertion plugin read warnings as:

compilation.warnings.map((w) => (w instanceof Error ? w.message : String(w)))

rspack surfaces RspackError objects rather than Error instances, so this would have fallen into
the String(w) branch and produced [object Object] — the mustWarn assertions would have failed
confusingly rather than reporting the real warning. Now reads message directly, which is correct
for both bundlers.

Type leak fixed

lib/index.d.ts carried import type { LoaderContext } from 'webpack'. With both bundlers now
declared as optional peers, an rspack-only consumer would hit Cannot find module 'webpack'
while type-checking — the same defect fixed in #1219 for the font plugin.

Replaced with a hand-maintained AtomicLoaderContext covering the four members the loader actually
uses (resourcePath, getOptions, callback, emitWarning), plus
test/types.conformance.ts asserting that both bundlers' real LoaderContext still satisfies
it. Verified the check is not vacuous: adding a member no bundler has fails compilation for both.

Verification

  • nx run …:test — vitest + type conformance + webpack 17/17 + rspack 17/17
  • Negative test: a deliberately unsatisfiable mustInclude fails under rspack with
    [rspack/svg-imports] Expected output to contain "THIS-SHOULD-NOT-EXIST", proving the rspack path
    actually evaluates assertions rather than passing vacuously
  • Conformance negative test: adding a bogus required member fails for both bundlers
  • Warnings confirmed genuinely captured under both bundlers (2 each on dynamic-barrel-imports)
  • nx run …:lint, yarn deps:check, and yarn install --immutable all pass

The plugin crashed under rspack with "Compilation.hooks.optimizeAssets is not
supported in rspack", and would have silently subset nothing even with the hook
fixed. Six distinct incompatibilities are addressed:

- static `webpack` runtime import -> type-only import + `compiler.webpack`
- `hooks.optimizeAssets` -> `hooks.processAssets` at PROCESS_ASSETS_STAGE_OPTIMIZE
- `instanceof NormalModule` -> duck-typing on `resource` (rspack modules are
  napi proxies and never satisfy the webpack class)
- `getUsedExports(m, undefined)` -> explicit runtime, since rspack's binding
  requires `string | string[]`
- `buildInfo.filename` -> `AssetInfo.sourceFilename`, as rspack leaves
  `buildInfo` empty for asset modules
- direct `compilation.assets` mutation -> `updateAsset`, as rspack's assets
  record is a read-only proxy

A warning is now emitted when font modules are found but no asset can be mapped,
so a future incompatibility surfaces instead of silently shipping a full font.

Tests run the same five fixture entries against both bundlers via a shared
config factory; emitted font sizes are identical across webpack and rspack.
`webpack` and `@rspack/core` are both optional peer dependencies.
…ut of public API

Review feedback: `import type * as webpack from 'webpack'` was preserved in the
generated `lib/index.d.ts`, so a consumer who installed only `@rspack/core` — which
the optional peerDependencies explicitly allow — would hit `Cannot find module
'webpack'` when type-checking.

Replaces the webpack namespace types with a minimal structural description of the
handful of APIs the plugin actually uses. These stay module-private, so nothing
reaches the emitted declaration file.

A fully structural public signature turned out to break assignability to webpack's
`plugins` array (its `Module` shares no properties with a structural stand-in, and
`hooks.compilation.tap` has a different signature), so `apply()` takes `unknown` and
narrows once. That keeps the plugin assignable to both bundlers while the internals
stay precisely typed.

Also drops the `as never` cast on `getUsedExports`: the local `ModuleGraph` type now
declares the runtime parameter as `string[] | undefined`, covering both bundlers
without disabling type-checking at the callsite.

`webpack` joins `@rspack/core` in the dependency-checks ignore list, since neither is
imported any more, not even for types.
… plugin contract

Replaces `apply(compiler: unknown)` plus an internal assertion with
`implements BundlerPlugin` and a typed `apply(compiler: BundlerCompiler)`.

The earlier `unknown` was a workaround for the maintained interfaces not being
supertypes of the real bundler types. `apply` is a property on both bundlers'
plugin interfaces, so `strictFunctionTypes` requires contravariance: every real
Compiler must be assignable to ours. Two things blocked that:

- `Module` declared only optional members, so webpack's `Module` tripped weak
  type detection ("no properties in common"). Adding `type` fixes it, since both
  bundlers expose it.
- `getUsedExports`/`getProvidedExports` were narrower than webpack's `RuntimeSpec`
  and `readonly string[]` returns.

`updateAsset` keeps an `any` parameter, documented inline: both bundlers accept a
full webpack-sources `Source` there and only `any` is assignable to it.

Adds `test/types.conformance.ts`, checked by tsc via the `test` script, asserting
that both bundlers' real `Compiler` types still satisfy the maintained interfaces
and that the plugin still satisfies `WebpackPluginInstance` and
`RspackPluginInstance`. This is the drift protection those hand-written types
otherwise lacked; narrowing any of them fails the check.
… types to own module

Moves the hand-maintained bundler interfaces out of `index.ts` into
`src/bundler-api.ts`, so the plugin module contains only subsetting logic and the
bundler surface is encapsulated in one place alongside the rationale for keeping
it hand-written.

`index.ts` re-exports the module (`export type * from './bundler-api'`), so the
package's public type surface is unchanged for consumers. `NormalModule` becomes
`BundlerNormalModule` now that it is exported, and the inline
`Compiler['webpack']['sources']['RawSource']` lookup becomes a named
`BundlerRawSource` alias.

`test/types.conformance.ts` now imports the interfaces from the new module, so the
drift check targets it directly.
Mirrors the harness added for the font subsetting plugin: the fixture entries and
their assertions move into a shared `test/make-configs.js`, and `test/run.js`
(`--bundler webpack|rspack|all`) drives both bundlers over the same 17 entries.

All 17 pass under rspack with no per-bundler assertion overrides, including the
`ts-loader` rule, `experiments.outputModule` with `library: { type: 'module' }`,
and the emitted-warning assertions. The rspack config deliberately keeps the same
ts-loader setup as webpack so any difference would come from the bundler rather
than the TypeScript toolchain. `make-configs.js` supports `overrides.<bundler>`
should a future divergence need it.

One behavioural fix was required: the assertion plugin read warnings via
`w instanceof Error ? w.message : String(w)`, and rspack surfaces RspackError
objects rather than Error instances, which would have stringified to garbage
instead of failing. It now reads `message` directly.

Also drops `import type { LoaderContext } from 'webpack'`, which was preserved in
`lib/index.d.ts` and would break type-checking for consumers who install only
`@rspack/core` now that both are optional peers. Replaced by a hand-maintained
`AtomicLoaderContext` covering the four members the loader uses, with
`test/types.conformance.ts` asserting both bundlers' real loader contexts still
satisfy it.
@github-actions

Copy link
Copy Markdown

📋 PR Validation Summary

Check the Build react library job summary for detailed reports:

  • 📦 Bundle Size — size comparison against the base branch
  • 📖 Docsite Preview — build artifact with local preview instructions

To view: click the link above → select the Build react library job → open the Summary tab.

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