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
9 changes: 9 additions & 0 deletions .changeset/rspack-2-config-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@callstack/repack": patch
---

Route the default Rspack configuration by the installed `@rspack/core` major version:

- `experiments.parallelLoader` is only set under Rspack 1 - Rspack 2 removed the flag (parallel loading is stable and opt-in per rule via `use[].parallel`), so it is no longer injected there
- `module.parser.javascript.exportsPresence` defaults to `'auto'` under Rspack 2 to keep Metro-like tolerance for invalid imports inside node_modules (Rspack 2 changed the default to `'error'`, which breaks builds on imports Metro tolerates; overridable in your project config)
- `RSPACK_PROFILE` profiling defaults to the `logger` trace layer under Rspack 2 (published Rspack 2 binaries do not include the perfetto layer; `RSPACK_TRACE_LAYER=perfetto` is still accepted for custom builds that enable it)
5 changes: 5 additions & 0 deletions packages/repack/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ module.exports = {
// Transform everything in `test` environment, so unit test can pass.
test: {
presets: [['@babel/preset-env', { targets: { node: 18 } }]],
// Jest's sandboxed CJS runtime cannot execute native `import()` without
// --experimental-vm-modules (which breaks other tests), so transform
// dynamic imports to `require` in tests - this also makes jest module
// mocks apply to lazily-imported modules.
plugins: ['@babel/plugin-transform-dynamic-import'],
},
},
};
1 change: 1 addition & 0 deletions packages/repack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
"devDependencies": {
"@babel/cli": "^7.25.2",
"@babel/core": "^7.25.2",
"@babel/plugin-transform-dynamic-import": "^7.24.7",
"@babel/plugin-transform-export-namespace-from": "^7.24.6",
"@babel/plugin-transform-modules-commonjs": "^7.23.2",
"@module-federation/enhanced": "0.8.9",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { getRspackMajorVersion } from '../../../../helpers/index.js';
import { getRepackConfig } from '../getRepackConfig.js';

jest.mock('../getMinimizerConfig.js');
jest.mock('../../../../helpers/index.js', () => ({
...jest.requireActual('../../../../helpers/index.js'),
getRspackMajorVersion: jest.fn(),
}));

const getRspackMajorVersionMock = jest.mocked(getRspackMajorVersion);

describe('getRepackConfig', () => {
describe('rspack 1', () => {
beforeEach(() => {
getRspackMajorVersionMock.mockReturnValue(1);
});

it('enables experiments.parallelLoader', async () => {
const config = await getRepackConfig('rspack', '/project/root');
expect(config.experiments).toEqual({ parallelLoader: true });
});

it('does not override module parser defaults', async () => {
const config = await getRepackConfig('rspack', '/project/root');
expect(config.module).toBeUndefined();
});
});

describe('rspack 2', () => {
beforeEach(() => {
getRspackMajorVersionMock.mockReturnValue(2);
});

it('omits experiments.parallelLoader (removed in Rspack 2)', async () => {
const config = await getRepackConfig('rspack', '/project/root');
expect(config.experiments).toBeUndefined();
});

it('relaxes exportsPresence to auto to keep Metro-like tolerance', async () => {
const config = await getRepackConfig('rspack', '/project/root');
expect(config.module).toEqual({
parser: { javascript: { exportsPresence: 'auto' } },
});
});
});

it('falls back to rspack 1 behavior when the rspack version is unresolvable', async () => {
getRspackMajorVersionMock.mockReturnValue(null);
const config = await getRepackConfig('rspack', '/project/root');
expect(config.experiments).toEqual({ parallelLoader: true });
expect(config.module).toBeUndefined();
});

describe('webpack', () => {
it('sets no rspack-specific experiments or module overrides', async () => {
const config = await getRepackConfig('webpack', '/project/root');
expect(config.experiments).toBeUndefined();
expect(config.module).toBeUndefined();
});

it('never probes the installed rspack version', async () => {
await getRepackConfig('webpack', '/project/root');
expect(getRspackMajorVersionMock).not.toHaveBeenCalled();
});
});
});
30 changes: 27 additions & 3 deletions packages/repack/src/commands/common/config/getRepackConfig.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,45 @@
import { getRspackMajorVersion } from '../../../helpers/index.js';
import { getMinimizerConfig } from './getMinimizerConfig.js';

function getExperimentsConfig(bundler: 'rspack' | 'webpack') {
function getExperimentsConfig(bundler: 'rspack' | 'webpack', rootDir: string) {
if (bundler === 'rspack') {
return { parallelLoader: true };
const rspackMajor = getRspackMajorVersion(rootDir) ?? 1;
if (rspackMajor < 2) {
return { parallelLoader: true };
}
// Rspack 2 removed `experiments.parallelLoader` (parallel loading is
// stable and opt-in per rule via `use[].parallel`) - nothing to set.
}
}

function getModuleConfig(bundler: 'rspack' | 'webpack', rootDir: string) {
if (bundler === 'rspack') {
const rspackMajor = getRspackMajorVersion(rootDir) ?? 1;
if (rspackMajor >= 2) {
// Rspack 2 changed the `exportsPresence` default from 'warn' to 'error',
// which breaks builds on invalid imports inside node_modules - a common
// occurrence in the React Native ecosystem that Metro tolerates.
// Restore Metro-like leniency by default; users can override this
// in their project config.
return {
parser: { javascript: { exportsPresence: 'auto' as const } },
};
}
}
}

export async function getRepackConfig(
bundler: 'rspack' | 'webpack',
rootDir: string
) {
const experiments = getExperimentsConfig(bundler);
const experiments = getExperimentsConfig(bundler, rootDir);
const moduleConfiguration = getModuleConfig(bundler, rootDir);
const minimizerConfiguration = await getMinimizerConfig(bundler, rootDir);

return {
devtool: 'source-map',
experiments,
module: moduleConfiguration,
output: {
clean: true,
hashFunction: 'xxhash64',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import fs from 'node:fs';
import { rspack } from '@rspack/core';
import { asyncExitHook } from 'exit-hook';
import { applyProfile } from '../profile-2.js';

jest.mock('@rspack/core', () => ({
rspack: {
experiments: {
globalTrace: { register: jest.fn(), cleanup: jest.fn() },
},
},
}));
jest.mock('exit-hook', () => ({ asyncExitHook: jest.fn() }));

const registerMock = jest.mocked(rspack.experiments.globalTrace.register);
const asyncExitHookMock = jest.mocked(asyncExitHook);

describe('applyProfile (rspack 2)', () => {
beforeEach(() => {
// avoid creating profile output directories on disk
jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
});

it('defaults to the logger trace layer (perfetto is unavailable in published rspack 2 binaries)', async () => {
await applyProfile('OVERVIEW');
expect(registerMock).toHaveBeenCalledWith('OVERVIEW', 'logger', 'stdout');
});

it('still accepts an explicit perfetto trace layer for custom builds', async () => {
await applyProfile('OVERVIEW', 'perfetto');
expect(registerMock).toHaveBeenCalledWith(
'OVERVIEW',
'perfetto',
expect.stringMatching(/rspack\.pftrace$/)
);
});

it('rejects unsupported trace layers', async () => {
await expect(applyProfile('OVERVIEW', 'chrome')).rejects.toThrow(
'unsupported trace layer: chrome'
);
expect(registerMock).not.toHaveBeenCalled();
});

it('registers trace cleanup to run on exit', async () => {
await applyProfile('OVERVIEW');
expect(asyncExitHookMock).toHaveBeenCalledWith(
rspack.experiments.globalTrace.cleanup,
{ wait: 500 }
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { getRspackVersion } from '../../../../helpers/index.js';
import { applyProfile } from '../index.js';
import { applyProfile as applyProfileV1_4 } from '../profile-1.4.js';
import { applyProfile as applyProfileV2 } from '../profile-2.js';
import { applyProfile as applyProfileLegacy } from '../profile-legacy.js';

jest.mock('../../../../helpers/index.js', () => ({
...jest.requireActual('../../../../helpers/index.js'),
getRspackVersion: jest.fn(),
}));
jest.mock('../profile-2.js', () => ({ applyProfile: jest.fn() }));
jest.mock('../profile-1.4.js', () => ({ applyProfile: jest.fn() }));
jest.mock('../profile-legacy.js', () => ({ applyProfile: jest.fn() }));

const getRspackVersionMock = jest.mocked(getRspackVersion);
const applyProfileV2Mock = jest.mocked(applyProfileV2);
const applyProfileV1_4Mock = jest.mocked(applyProfileV1_4);
const applyProfileLegacyMock = jest.mocked(applyProfileLegacy);

describe('profiling handler routing', () => {
it('routes to the rspack 2 handler for rspack 2', async () => {
getRspackVersionMock.mockReturnValue('2.0.0');
await applyProfile('OVERVIEW');
expect(applyProfileV2Mock).toHaveBeenCalledTimes(1);
expect(applyProfileV1_4Mock).not.toHaveBeenCalled();
expect(applyProfileLegacyMock).not.toHaveBeenCalled();
});

it('routes to the rspack 2 handler for rspack 2 prereleases', async () => {
getRspackVersionMock.mockReturnValue('2.0.0-beta.3');
await applyProfile('OVERVIEW');
expect(applyProfileV2Mock).toHaveBeenCalledTimes(1);
});

it('routes to the 1.4 handler for rspack >= 1.4', async () => {
getRspackVersionMock.mockReturnValue('1.4.11');
await applyProfile('OVERVIEW');
expect(applyProfileV1_4Mock).toHaveBeenCalledTimes(1);
expect(applyProfileV2Mock).not.toHaveBeenCalled();
expect(applyProfileLegacyMock).not.toHaveBeenCalled();
});

it('routes to the legacy handler for rspack < 1.4', async () => {
getRspackVersionMock.mockReturnValue('1.3.9');
await applyProfile('OVERVIEW');
expect(applyProfileLegacyMock).toHaveBeenCalledTimes(1);
expect(applyProfileV2Mock).not.toHaveBeenCalled();
expect(applyProfileV1_4Mock).not.toHaveBeenCalled();
});

it('routes to the legacy handler when the rspack version is unresolvable', async () => {
getRspackVersionMock.mockReturnValue(null);
await applyProfile('OVERVIEW');
expect(applyProfileLegacyMock).toHaveBeenCalledTimes(1);
expect(applyProfileV2Mock).not.toHaveBeenCalled();
expect(applyProfileV1_4Mock).not.toHaveBeenCalled();
});

it('forwards trace layer and output to the selected handler', async () => {
getRspackVersionMock.mockReturnValue('2.1.0');
await applyProfile('OVERVIEW', 'logger', 'stderr');
expect(applyProfileV2Mock).toHaveBeenCalledWith(
'OVERVIEW',
'logger',
'stderr'
);
});
});
7 changes: 6 additions & 1 deletion packages/repack/src/commands/rspack/profile/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ function getInstalledRspackVersion() {

async function getProfilingHandler() {
const { major, minor } = getInstalledRspackVersion();
if (major > 1 || (major === 1 && minor >= 4)) {
if (major >= 2) {
// Rspack 2 publishes binaries without the perfetto trace layer,
// so tracing defaults differ - see profile-2.ts
return await import('./profile-2.js');
}
if (major === 1 && minor >= 4) {
return await import('./profile-1.4.js');
}
return await import('./profile-legacy.js');
Expand Down
68 changes: 68 additions & 0 deletions packages/repack/src/commands/rspack/profile/profile-2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Reference: https://github.com/web-infra-dev/rspack/blob/bdfe548f4e5fd09c1ccb4d43919426819fb9a34f/packages/rspack-cli/src/utils/profile.ts
*/

/**
* `RSPACK_PROFILE=ALL` // all trace events
* `RSPACK_PROFILE=OVERVIEW` // overview trace events
* `RSPACK_PROFILE=warn,tokio::net=info` // trace filter from https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#example-syntax
*/
import fs from 'node:fs';
import path from 'node:path';
import { rspack } from '@rspack/core';

// Rspack 2 publishes binaries built without the `perfetto` trace layer -
// requesting it throws "Perfetto trace layer is not enabled in this build".
// Default to the `logger` layer instead; `perfetto` is still accepted
// explicitly in case a custom Rspack build enables it.
const defaultRustTraceLayer = 'logger';

export async function applyProfile(
filterValue: string,
traceLayer: string = defaultRustTraceLayer,
traceOutput?: string
) {
const { asyncExitHook } = await import('exit-hook');

if (traceLayer !== 'logger' && traceLayer !== 'perfetto') {
throw new Error(`unsupported trace layer: ${traceLayer}`);
}
const timestamp = Date.now();
const defaultOutputDir = path.resolve(
`.rspack-profile-${timestamp}-${process.pid}`
);
if (!traceOutput) {
const defaultRustTracePerfettoOutput = path.resolve(
defaultOutputDir,
'rspack.pftrace'
);
const defaultRustTraceLoggerOutput = 'stdout';

const defaultTraceOutput =
traceLayer === 'perfetto'
? defaultRustTracePerfettoOutput
: defaultRustTraceLoggerOutput;

// biome-ignore lint/style/noParameterAssign: setting default value makes sense
traceOutput = defaultTraceOutput;
} else if (traceOutput !== 'stdout' && traceOutput !== 'stderr') {
// if traceOutput is not stdout or stderr, we need to ensure the directory exists
// biome-ignore lint/style/noParameterAssign: setting default value makes sense
traceOutput = path.resolve(defaultOutputDir, traceOutput);
}

await ensureFileDir(traceOutput);
await rspack.experiments.globalTrace.register(
filterValue,
traceLayer,
traceOutput
);
asyncExitHook(rspack.experiments.globalTrace.cleanup, {
wait: 500,
});
}

async function ensureFileDir(outputFilePath: string) {
const dir = path.dirname(outputFilePath);
await fs.promises.mkdir(dir, { recursive: true });
}
Loading
Loading