Skip to content

[Feature]: rs.mock() should propagate through re-exported / inlined modules #972

@omarshibli

Description

@omarshibli

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:

  1. vi.mock('my-i18n-lib') registers a mock for the resolved path of my-i18n-lib
  2. When MyComponent imports from my-i18n-lib, Vite's module graph resolves it — the mock is applied
  3. 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 useTranslation

What happens in rstest

rspack aggressively inlines re-exports for performance. After bundling:

  1. MyComponent's import of useTranslation from my-i18n-lib gets rewritten to directly reference the original src/i18n.ts
  2. rs.mock('my-i18n-lib') registers a mock for my-i18n-lib, but the bundled code no longer references that module
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions