Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/test-main-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ on:
- 'website/**'
workflow_dispatch:

permissions:
contents: read

jobs:
test_main_matrix:
name: Tests [Node ${{ matrix.node_version }}]
Expand All @@ -23,5 +26,9 @@ jobs:
with:
node-version: ${{ matrix.node_version }}

# Rspack 2 requires Node ^20.19.0 || >=22.12.0, so the older Node
# lanes run the repack unit suite against Rspack 1
- name: Run tests
run: pnpm test
env:
RSPACK_MAJOR: ${{ contains(fromJSON('["18", "20"]'), matrix.node_version) && '1' || '2' }}
39 changes: 39 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ on:
- 'website/**'
workflow_dispatch:

permissions:
contents: read

jobs:
lint:
name: Lint
Expand Down Expand Up @@ -53,3 +56,39 @@ jobs:

- name: Run tests
run: pnpm test

test_pr_rspack1:
name: Tests [Rspack 1]
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2
- name: Setup workspace
uses: ./.github/actions/setup-pnpm-node
with:
node-version: '24'

- name: Run tests against Rspack 1
run: pnpm turbo run test:rspack1

test_pr_windows:
name: Tests [Windows]
if: github.event_name == 'pull_request'
runs-on: windows-latest

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2
- name: Setup workspace
uses: ./.github/actions/setup-pnpm-node
with:
node-version: '24'

# the require(esm) loading path of @rspack/core@2 and the version
# helpers' filesystem resolution are Windows-sensitive - run the
# repack unit suite under both majors
- name: Run repack tests
run: pnpm turbo run test --filter @callstack/repack

- name: Run repack tests against Rspack 1
run: pnpm turbo run test:rspack1
3 changes: 2 additions & 1 deletion packages/repack/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ module.exports = {
clearMocks: true,
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'^@rspack/core$': '<rootDir>/jest.rspack-core-bridge.js',
},
setupFiles: ['./jest.setup.js'],
testEnvironment: 'node',
testEnvironment: '<rootDir>/jest.environment.js',
testMatch: ['**/__tests__/**/*.ts?(x)'],
};
32 changes: 32 additions & 0 deletions packages/repack/jest.environment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const { TestEnvironment: NodeEnvironment } = require('jest-environment-node');

/**
* @rspack/core v2 is published as a pure ESM package, which Jest's sandboxed
* CJS module runtime cannot load (even `createRequire` inside the sandbox is
* wrapped by Jest and loops back into the module registry).
*
* Test environments are loaded outside the Jest sandbox with Node's real
* module system, where loading ESM through `require()`/`import()` is
* supported. Load @rspack/core here and expose it to the sandbox via a
* global - see jest.rspack-core-bridge.js for the consuming side.
*
* The environment is parameterized on RSPACK_MAJOR (default 2) so the suite
* can run against both supported Rspack majors:
* - v2: `await import('@rspack/core')` (ESM-only package),
* - v1: plain `require` of the aliased `@rspack/core-v1` devDependency - the
* package is CJS, and requiring it directly sidesteps any reliance on
* cjs-module-lexer named-export synthesis.
* `__RSPACK_MAJOR__` is exposed alongside so tests can gate major-specific
* assertions.
*/
class RspackCoreEnvironment extends NodeEnvironment {
async setup() {
await super.setup();
const major = Number(process.env.RSPACK_MAJOR ?? '2');
this.global.__RSPACK_CORE__ =
major === 1 ? require('@rspack/core-v1') : await import('@rspack/core');
this.global.__RSPACK_MAJOR__ = major;
}
}

module.exports = RspackCoreEnvironment;
18 changes: 18 additions & 0 deletions packages/repack/jest.rspack-core-bridge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// @rspack/core v2 is published as a pure ESM package, which Jest's sandboxed
// CJS module runtime cannot load. The real module is loaded natively by the
// custom test environment (jest.environment.js) and exposed via a global;
// this bridge maps `require('@rspack/core')` inside tests onto it.
//
// The `__esModule` marker keeps Babel's import interop treating this as an
// ESM-like namespace, so named imports (`import { rspack } from '@rspack/core'`)
// keep working.
const core = globalThis.__RSPACK_CORE__;

if (!core) {
throw new Error(
'@rspack/core was not preloaded by the test environment - ' +
'make sure jest.environment.js is set as the testEnvironment'
);
}

module.exports = { __esModule: true, ...core, default: core.default ?? core };
7 changes: 5 additions & 2 deletions packages/repack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"build:ts": "tsc -p tsconfig.build.json --emitDeclarationOnly",
"build": "pnpm run \"/^build:.*/\"",
"test": "jest",
"test:rspack1": "cross-env RSPACK_MAJOR=1 jest",
"typecheck": "tsc --noEmit",
"archive": "pnpm build && pnpm pack",
"clang-format": "pnpm clang-format:ios && pnpm clang-format:android",
Expand Down Expand Up @@ -113,8 +114,9 @@
"@babel/plugin-transform-modules-commonjs": "^7.23.2",
"@module-federation/enhanced": "0.8.9",
"@module-federation/sdk": "0.6.10",
"@rspack/core": "catalog:",
"@swc/helpers": "catalog:",
"@rspack/core": "^2.1.2",
"@rspack/core-v1": "npm:@rspack/core@^1.7.12",
"@swc/helpers": "^0.5.23",
"@types/babel__core": "^7.20.5",
"@types/dedent": "^0.7.0",
"@types/gradient-string": "^1.1.6",
Expand All @@ -127,6 +129,7 @@
"@types/shallowequal": "^1.1.1",
"babel-jest": "^29.7.0",
"clang-format": "^1.8.0",
"cross-env": "^7.0.3",
"jest": "^29.7.0",
"react": "catalog:",
"react-native": "catalog:",
Expand Down
10 changes: 10 additions & 0 deletions packages/repack/src/__tests__/rspackTestLane.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { rspackVersion } from '@rspack/core';

// Guards the dual-major test wiring itself: the custom Jest environment
// (jest.environment.js) loads @rspack/core v2 by default and the aliased
// @rspack/core-v1 under RSPACK_MAJOR=1. If the env var stops being honored,
// the "v1 lane" silently tests v2 twice - this catches that.
test('the loaded @rspack/core major matches the requested RSPACK_MAJOR lane', () => {
const requested = Number(process.env.RSPACK_MAJOR ?? '2');
expect(Number(rspackVersion.split('.')[0])).toBe(requested);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { getRspackCacheConfigs } from '../resetPersistentCache.js';

describe('getRspackCacheConfigs', () => {
it('should return the legacy experiments.cache config (Rspack 1)', () => {
const cache = { type: 'persistent' as const };
expect(getRspackCacheConfigs({ experiments: { cache } })).toEqual([cache]);
});

it('should return the top-level cache config (Rspack 2)', () => {
const cache = {
type: 'persistent' as const,
storage: { type: 'filesystem' as const, directory: '.cache/custom' },
};
expect(getRspackCacheConfigs({ cache })).toEqual([cache]);
});

it('should collect both locations when a config carries both keys', () => {
// e.g. a dual-major config or defaults merged with a user config -
// which location is active depends on the installed Rspack major,
// so both need to be considered when resetting the cache
const legacyCache = { type: 'persistent' as const };
const cache = {
type: 'persistent' as const,
storage: { type: 'filesystem' as const, directory: '.cache/custom' },
};
expect(
getRspackCacheConfigs({ cache, experiments: { cache: legacyCache } })
).toEqual([legacyCache, cache]);
});

it('should fall back to a single entry when no cache config is present', () => {
expect(getRspackCacheConfigs({})).toEqual([undefined]);
expect(getRspackCacheConfigs({ experiments: {} })).toEqual([undefined]);
});

it('should keep boolean cache configs', () => {
expect(getRspackCacheConfigs({ cache: true })).toEqual([true]);
expect(getRspackCacheConfigs({ cache: false })).toEqual([false]);
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from 'node:path';
import { resolveProjectPath } from '../resolveProjectPath.js';

describe('resolveProjectPath', () => {
Expand All @@ -6,7 +7,9 @@ describe('resolveProjectPath', () => {
expected: string,
root = '/project/root'
) => {
expect(resolveProjectPath(input, root)).toBe(expected);
// path.resolve makes the expectation platform-agnostic - on Windows the
// resolved paths carry a drive letter and win32 separators
expect(resolveProjectPath(input, root)).toBe(path.resolve(expected));
};

it('should resolve [projectRoot] prefix correctly', () => {
Expand Down Expand Up @@ -41,4 +44,30 @@ describe('resolveProjectPath', () => {
'/a/b/c/d/e/f'
);
});

it('should resolve correctly when up-level navigation reaches the filesystem root', () => {
// regression: string-replace + path.join composition produced UNC-like
// `\\...` paths on Windows once the `../` navigation collapsed rootDir
// to the bare filesystem root
expectResolved('[projectRoot^1]/src/index.js', '/src/index.js', '/project');
expectResolved('[projectRoot^2]/index.js', '/index.js', '/project/root');
expectResolved('[projectRoot^4]/index.js', '/index.js', '/a/b');
});

it('should stay inside the project root on duplicated separators', () => {
// regression: a leading separator in the remainder made path.resolve
// treat it as an absolute path, escaping rootDir and dropping the
// [projectRoot^N] up-segments entirely
expectResolved('[projectRoot]//src/index.js', '/project/root/src/index.js');
expectResolved(
'[projectRoot]/src//nested///index.js',
'/project/root/src/nested/index.js'
);
expectResolved('[projectRoot^1]//src/index.js', '/project/src/index.js');
expectResolved(
'[projectRoot^2]//shared/utils.js',
'/deep/shared/utils.js',
'/deep/nested/project'
);
});
});
33 changes: 31 additions & 2 deletions packages/repack/src/commands/common/resetPersistentCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,39 @@ import type { Configuration as RspackConfiguration } from '@rspack/core';
import * as colorette from 'colorette';
import type { Configuration as WebpackConfiguration } from 'webpack';

type RspackCacheOptions = NonNullable<
// Rspack 2 moved the persistent cache configuration from `experiments.cache`
// to the top-level `cache` option (same shape). The v2 types no longer
// declare the legacy location, so extend them with it to support configs
// written for either major.
type RspackCacheOptions = RspackConfiguration['cache'];
type RspackExperimentsWithLegacyCache = NonNullable<
RspackConfiguration['experiments']
>['cache'];
> & { cache?: RspackCacheOptions };

export interface RspackConfigurationWithLegacyCache {
cache?: RspackCacheOptions;
experiments?: RspackExperimentsWithLegacyCache;
}

type WebpackCacheOptions = WebpackConfiguration['cache'];

/**
* Collects the persistent cache configurations from a Rspack config,
* covering both the Rspack 1 (`experiments.cache`) and
* Rspack 2 (top-level `cache`) locations. A merged or dual-major config
* can carry both keys and which one is active depends on the installed
* Rspack major, so `--reset-cache` clears both locations.
*/
export function getRspackCacheConfigs(
config: RspackConfigurationWithLegacyCache
): RspackCacheOptions[] {
const cacheConfigs = [config.experiments?.cache, config.cache].filter(
(cacheConfig) => cacheConfig !== undefined
);
// without an explicit cache config, the default cache location still applies
return cacheConfigs.length > 0 ? cacheConfigs : [undefined];
}

function getDefaultCacheDirectory(
bundler: 'rspack' | 'webpack',
rootDir: string
Expand All @@ -31,6 +59,7 @@ function getRspackCachePaths(
for (const cacheConfig of cacheConfigs) {
if (
typeof cacheConfig === 'object' &&
cacheConfig !== null &&
'storage' in cacheConfig &&
cacheConfig.storage?.directory
) {
Expand Down
13 changes: 10 additions & 3 deletions packages/repack/src/commands/common/resolveProjectPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@ export function resolveProjectPath(filepath: string, rootDir: string) {
if (!match) return filepath;

const [prefix, upLevels] = match;
const upPath = '../'.repeat(Number(upLevels ?? 0));
const rootPath = path.join(rootDir, upPath);
return path.resolve(filepath.replace(prefix, rootPath));
const upSegments = Array.from({ length: Number(upLevels ?? 0) }, () => '..');
const restSegments = filepath
.slice(prefix.length + 1)
.split('/')
.filter(Boolean);
// resolve segment-by-segment instead of string-replace + join composition:
// on Windows, joining enough `../` segments to reach the filesystem root
// yields a bare root ending in a separator, and concatenating the rest of
// the path after it produced a double-separator (UNC-like `\\...`) prefix
return path.resolve(rootDir, ...upSegments, ...restSegments);
}
26 changes: 14 additions & 12 deletions packages/repack/src/commands/rspack/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CLIError } from '../../helpers/index.js';
import { makeCompilerConfig } from '../common/config/makeCompilerConfig.js';
import {
getMaxWorkers,
getRspackCacheConfigs,
normalizeStatsOptions,
resetPersistentCache,
setupEnvironment,
Expand All @@ -25,17 +26,15 @@ export async function bundle(
cliConfig: CliConfig,
args: BundleArguments
) {
const [config] = await makeCompilerConfig<Configuration>({
args: args,
bundler: 'rspack',
command: 'bundle',
rootDir: cliConfig.root,
platforms: [args.platform],
reactNativePath: cliConfig.reactNativePath,
});

// remove devServer configuration to avoid schema validation errors
delete config.devServer;
const [{ devServer: _devServer, ...config }] =
await makeCompilerConfig<Configuration>({
args: args,
bundler: 'rspack',
command: 'bundle',
rootDir: cliConfig.root,
platforms: [args.platform],
reactNativePath: cliConfig.reactNativePath,
});

// expose selected args as environment variables
setupEnvironment(args);
Expand All @@ -51,7 +50,7 @@ export async function bundle(
resetPersistentCache({
bundler: 'rspack',
rootDir: cliConfig.root,
cacheConfigs: [config.experiments?.cache],
cacheConfigs: getRspackCacheConfigs(config),
});
}

Expand Down Expand Up @@ -86,6 +85,9 @@ export async function bundle(
}
};

// `devServer` is split off above - it's not needed for bundling, and
// Re.Pack's own dev server options (augmented onto `Configuration`) are
// not assignable to Rspack's bundled `DevServer` type
const compiler = rspack(config);

return new Promise<void>((resolve) => {
Expand Down
Loading
Loading