-
-
Notifications
You must be signed in to change notification settings - Fork 21
Description
Description
When rspack inlines re-exports during bundling, rs.mock() applied to a source module does not propagate to consumers that re-export from it. This breaks the common pattern of mocking at the package boundary and forces users to mock internal implementation modules instead.
Reproduction
Given this module structure:
src/
├── i18n.ts # original module
├── components/
│ ├── MyComponent.tsx # consumer (imports via package)
│ └── MyComponent.spec.tsx # test
node_modules/
└── my-i18n-lib/
└── index.ts # re-exports from src/i18n.ts
Source module
// src/i18n.ts
export function useTranslation() {
return { t: (key: string) => key };
}Re-exporting package
// node_modules/my-i18n-lib/index.ts
export { useTranslation } from '../../src/i18n';Consumer component
// src/components/MyComponent.tsx
import { useTranslation } from 'my-i18n-lib';
export function MyComponent() {
const { t } = useTranslation();
return <div>{t('hello')}</div>;
}Test — mocking the package boundary
// src/components/MyComponent.spec.tsx
rs.mock(import('my-i18n-lib'), () => ({
useTranslation: () => ({ t: (key: string) => `mock:${key}` }),
}));
// ❌ MyComponent still uses the REAL useTranslation!
// rspack inlined the re-export, so the bundled code directly
// references src/i18n.ts — not my-i18n-lib.
// The mock never matches.How it works in vitest
In vitest (backed by Vite), vi.mock('my-i18n-lib', ...) intercepts at the module resolution level before any bundling or inlining:
vi.mock('my-i18n-lib')registers a mock for the resolved path ofmy-i18n-lib- When
MyComponentimports frommy-i18n-lib, Vite's module graph resolves it — the mock is applied - Re-exports are not inlined during test bundling, so the module boundary is preserved
This means vi.mock() works at the package boundary regardless of how exports are structured internally.
// ✅ In vitest — this just works
vi.mock('my-i18n-lib', () => ({
useTranslation: () => ({ t: (key: string) => `mock:${key}` }),
}));
// MyComponent gets the mocked useTranslationWhat happens in rstest
rspack aggressively inlines re-exports for performance. After bundling:
MyComponent's import ofuseTranslationfrommy-i18n-libgets rewritten to directly reference the originalsrc/i18n.tsrs.mock('my-i18n-lib')registers a mock formy-i18n-lib, but the bundled code no longer references that module- The mock never matches
Current workaround
We have to mock the internal module that the consumer actually resolves to after inlining:
// ❌ What we want to write (doesn't work — re-export gets inlined away)
rs.mock(import('my-i18n-lib'), () => ({ useTranslation: mockFn }));
// ✅ What we have to write (fragile, couples tests to internal paths)
rs.mock(import('./hooks/useTranslation'), () => ({ useTranslation: mockFn }));Why this is painful
- Tests are coupled to internal module paths instead of public package APIs
- If the internal module is renamed/restructured, all mocks break
- It breaks the mental model of "mock at the boundary"
- It creates a divergence between vitest and rstest test code that can't be bridged by simple find-and-replace
Real-world example from our migration
In our vitest setup, we mocked react-i18next globally and all components that imported from it got the mock automatically. After migrating to rstest, rspack inlines the re-exports, so our global mock of the package has no effect.
We had to find the internal hook that the package resolved to and mock that instead:
// spec-setup.ts (global test setup)
// rspack inlines re-exports so mocking react-i18next
// doesn't propagate through the bundled module graph.
rs.mock(import('@/hooks/useTranslation'), () => ({
useTranslation:
(prefix: string) =>
(key: string, ...args: any[]) =>
getMockedTranslation(`${prefix}.${key}`, ...args),
useCommonTranslation:
() =>
(key: string, ...args: any[]) =>
getMockedTranslation(`common.${key}`, ...args),
}));Proposal
When rs.mock(modulePath) is called, rstest should account for rspack's re-export inlining so that the mock reaches the actual code that was inlined. Some possible approaches:
Option A: Disable re-export inlining for mocked modules
If a module is targeted by rs.mock(), preserve the module boundary during bundling so the mock can intercept imports at the original path.
Option B: Follow the re-export graph during mock registration
When rs.mock('my-i18n-lib') is called, resolve my-i18n-lib and also register the mock for all modules it re-exports from — so the mock matches even after inlining.
Option C: Match mocks on resolved module ID post-bundling
After bundling, apply mocks by matching on the resolved module ID rather than the import specifier, so inlined re-exports still get intercepted.
Related
- [Bug]:
rs.mock()doesn't load barrel files #613 —rs.mock()doesn't load barrel files (related path resolution issue) - [Bug]: Module mock not hoisted to top with factory #507 — Module mock not hoisted to top with factory