diff --git a/.changeset/rspack-2-cache-warning.md b/.changeset/rspack-2-cache-warning.md new file mode 100644 index 000000000..5fa740cc3 --- /dev/null +++ b/.changeset/rspack-2-cache-warning.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack": patch +--- + +Show a one-time warning when a legacy `experiments.cache` value is detected under Rspack 2. Rspack 2 moved persistent cache configuration to the top-level `cache` option and silently ignores the legacy key, so persistent caching would be lost without any signal. The config is left untouched - migrate the value to the top-level `cache` option in your Rspack config. diff --git a/.changeset/rspack-2-config-routing.md b/.changeset/rspack-2-config-routing.md new file mode 100644 index 000000000..99b694e91 --- /dev/null +++ b/.changeset/rspack-2-config-routing.md @@ -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) diff --git a/packages/repack/src/commands/common/__tests__/warnLegacyRspackCacheConfig.test.ts b/packages/repack/src/commands/common/__tests__/warnLegacyRspackCacheConfig.test.ts new file mode 100644 index 000000000..ada707008 --- /dev/null +++ b/packages/repack/src/commands/common/__tests__/warnLegacyRspackCacheConfig.test.ts @@ -0,0 +1,203 @@ +describe('warnLegacyRspackCacheConfig', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + // the helper keeps module-level once-only state - re-evaluate it per test + jest.resetModules(); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + const loadHelper = () => { + // require instead of import so jest.resetModules() re-evaluates the module + const module: typeof import( + '../warnLegacyRspackCacheConfig.js' + ) = require('../warnLegacyRspackCacheConfig.js'); + return module.warnLegacyRspackCacheConfig; + }; + + it('does not warn when no config uses the legacy experiments.cache key', () => { + const warnLegacyRspackCacheConfig = loadHelper(); + + warnLegacyRspackCacheConfig([ + {}, + { cache: true }, + { cache: { type: 'persistent' } }, + { experiments: {} }, + ]); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('warns when a config has experiments.cache and no top-level cache', () => { + const warnLegacyRspackCacheConfig = loadHelper(); + + warnLegacyRspackCacheConfig([ + { experiments: { cache: { type: 'persistent' } } }, + ]); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch(/experiments\.cache/); + expect(warnSpy.mock.calls[0][0]).toMatch(/top-level 'cache'/); + }); + + it('warns for the typical Rspack 1 shape (cache: true + experiments.cache)', () => { + const warnLegacyRspackCacheConfig = loadHelper(); + + // under Rspack 2 `cache: true` enables the memory cache only, + // so persistent caching is still not in effect + warnLegacyRspackCacheConfig([ + { cache: true, experiments: { cache: { type: 'persistent' } } }, + ]); + + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('warns when top-level cache is memory-only next to the legacy key', () => { + const warnLegacyRspackCacheConfig = loadHelper(); + + warnLegacyRspackCacheConfig([ + { + cache: { type: 'memory' }, + experiments: { cache: { type: 'persistent' } }, + }, + ]); + + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('does not warn for a migrated config that only left the inert legacy key behind', () => { + const warnLegacyRspackCacheConfig = loadHelper(); + + // persistent caching IS enabled and the legacy key carries no options + // beyond `type` - there is nothing Rspack 2 could drop + warnLegacyRspackCacheConfig([ + { + cache: { + type: 'persistent', + storage: { type: 'filesystem', directory: '/custom' }, + }, + experiments: { cache: { type: 'persistent' } }, + }, + ]); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not warn when the legacy key is deep-equal to the top-level cache', () => { + const warnLegacyRspackCacheConfig = loadHelper(); + + // every option under the legacy key is mirrored at the top level, + // so ignoring the legacy key changes nothing + warnLegacyRspackCacheConfig([ + { + cache: { + type: 'persistent', + storage: { type: 'filesystem', directory: '/custom' }, + buildDependencies: ['/project/rspack.config.mjs'], + }, + experiments: { + cache: { + type: 'persistent', + storage: { type: 'filesystem', directory: '/custom' }, + buildDependencies: ['/project/rspack.config.mjs'], + }, + }, + }, + ]); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('warns softly for a partial migration that leaves options under the legacy key', () => { + const warnLegacyRspackCacheConfig = loadHelper(); + + // persistent caching IS enabled, but the storage directory only lives + // under the legacy key - Rspack 2 drops it and uses the default location + warnLegacyRspackCacheConfig([ + { + cache: { type: 'persistent' }, + experiments: { + cache: { + type: 'persistent', + storage: { type: 'filesystem', directory: '/custom' }, + }, + }, + }, + ]); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch(/experiments\.cache/); + expect(warnSpy.mock.calls[0][0]).toMatch(/not applied/); + // caching IS enabled - the soft warning must not claim otherwise + expect(warnSpy.mock.calls[0][0]).not.toMatch(/NOT enabled/); + }); + + it('warns strongly when legacy options come without a top-level persistent cache', () => { + const warnLegacyRspackCacheConfig = loadHelper(); + + warnLegacyRspackCacheConfig([ + { + experiments: { + cache: { + type: 'persistent', + storage: { type: 'filesystem', directory: '/custom' }, + }, + }, + }, + ]); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch(/NOT enabled/); + }); + + it('prefers the strong warning when configs trigger both tiers', () => { + const warnLegacyRspackCacheConfig = loadHelper(); + + warnLegacyRspackCacheConfig([ + { + cache: { type: 'persistent' }, + experiments: { + cache: { + type: 'persistent', + storage: { type: 'filesystem', directory: '/custom' }, + }, + }, + }, + { experiments: { cache: { type: 'persistent' } } }, + ]); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toMatch(/NOT enabled/); + }); + + it('warns when any config of a multi-config array is misconfigured', () => { + const warnLegacyRspackCacheConfig = loadHelper(); + + warnLegacyRspackCacheConfig([ + { + cache: { type: 'persistent' }, + experiments: { cache: { type: 'persistent' } }, + }, + { experiments: { cache: { type: 'persistent' } } }, + ]); + + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('warns only once across repeated calls', () => { + const warnLegacyRspackCacheConfig = loadHelper(); + + warnLegacyRspackCacheConfig([ + { experiments: { cache: { type: 'persistent' } } }, + ]); + warnLegacyRspackCacheConfig([ + { experiments: { cache: { type: 'persistent' } } }, + ]); + + expect(warnSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/repack/src/commands/common/config/__tests__/getRepackConfig.test.ts b/packages/repack/src/commands/common/config/__tests__/getRepackConfig.test.ts new file mode 100644 index 000000000..25c77ffd8 --- /dev/null +++ b/packages/repack/src/commands/common/config/__tests__/getRepackConfig.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/repack/src/commands/common/config/getRepackConfig.ts b/packages/repack/src/commands/common/config/getRepackConfig.ts index cc4269174..d0c306c08 100644 --- a/packages/repack/src/commands/common/config/getRepackConfig.ts +++ b/packages/repack/src/commands/common/config/getRepackConfig.ts @@ -1,8 +1,30 @@ +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 } }, + }; + } } } @@ -10,12 +32,14 @@ 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', diff --git a/packages/repack/src/commands/common/index.ts b/packages/repack/src/commands/common/index.ts index ce61289b7..bdae6df17 100644 --- a/packages/repack/src/commands/common/index.ts +++ b/packages/repack/src/commands/common/index.ts @@ -8,5 +8,6 @@ export * from './runAdbReverse.js'; export * from './setupEnvironment.js'; export * from './setupInteractions.js'; export * from './setupStatsWriter.js'; +export * from './warnLegacyRspackCacheConfig.js'; export * from './config/makeCompilerConfig.js'; diff --git a/packages/repack/src/commands/common/warnLegacyRspackCacheConfig.ts b/packages/repack/src/commands/common/warnLegacyRspackCacheConfig.ts new file mode 100644 index 000000000..ce5d9df6f --- /dev/null +++ b/packages/repack/src/commands/common/warnLegacyRspackCacheConfig.ts @@ -0,0 +1,101 @@ +import * as colorette from 'colorette'; +import type { RspackConfigurationWithLegacyCache } from './resetPersistentCache.js'; + +let warningDisplayed = false; + +function hasPersistentCacheEnabled(config: RspackConfigurationWithLegacyCache) { + return typeof config.cache === 'object' && config.cache.type === 'persistent'; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isDeepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) { + return ( + a.length === b.length && a.every((item, i) => isDeepEqual(item, b[i])) + ); + } + if (isPlainObject(a) && isPlainObject(b)) { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + return ( + aKeys.length === bKeys.length && + aKeys.every((key) => key in b && isDeepEqual(a[key], b[key])) + ); + } + return false; +} + +/** + * A legacy `experiments.cache` value carries extra information when it is an + * object with options beyond `type` that are not mirrored by the top-level + * `cache` value - Rspack 2 drops those options and runs with defaults. + * Booleans, a bare `{ type: 'persistent' }` and a value deep-equal to the + * top-level `cache` are inert leftovers of a completed migration. + */ +function legacyCacheCarriesExtraOptions( + config: RspackConfigurationWithLegacyCache +) { + const legacyCache = config.experiments?.cache; + if (!isPlainObject(legacyCache)) return false; + const extraKeys = Object.keys(legacyCache).filter((key) => key !== 'type'); + if (extraKeys.length === 0) return false; + return !isDeepEqual(legacyCache, config.cache); +} + +/** + * Rspack 2 moved the persistent cache configuration from `experiments.cache` + * to the top-level `cache` option and silently ignores the legacy key + * (validation is loose) - users migrating a Rspack 1 config would lose + * persistent caching without any signal. + * + * Warn (once) when running Rspack 2 with a legacy `experiments.cache` value: + * - without a top-level persistent `cache` option, persistent caching is NOT + * in effect - warn that it is disabled. + * - with a top-level persistent `cache` option, caching IS enabled, but any + * options that only live under the legacy key are silently dropped - warn + * (more softly) that they are not applied. A truly inert leftover (a + * boolean, a bare `{ type: 'persistent' }` or a value deep-equal to the + * top-level `cache`) stays silent. + * + * The config is left untouched - migrating it is the user's move, and + * mutating it here would make Re.Pack behave differently from bare Rspack + * given the same config. + */ +export function warnLegacyRspackCacheConfig( + configs: RspackConfigurationWithLegacyCache[] +) { + if (warningDisplayed) return; + const cachingDisabled = configs.some( + (config) => + config.experiments?.cache !== undefined && + !hasPersistentCacheEnabled(config) + ); + const optionsDropped = configs.some( + (config) => + hasPersistentCacheEnabled(config) && + legacyCacheCarriesExtraOptions(config) + ); + if (!cachingDisabled && !optionsDropped) return; + warningDisplayed = true; + if (cachingDisabled) { + console.warn( + colorette.yellow( + "Rspack 2 ignores the legacy 'experiments.cache' option, so persistent " + + "caching is NOT enabled. Move the value to the top-level 'cache' " + + 'option in your Rspack config.\n' + ) + ); + } else { + console.warn( + colorette.yellow( + "Rspack 2 ignores the legacy 'experiments.cache' option - options set " + + "there are not applied. Move them to the top-level 'cache' option " + + 'in your Rspack config or remove the key.\n' + ) + ); + } +} diff --git a/packages/repack/src/commands/rspack/bundle.ts b/packages/repack/src/commands/rspack/bundle.ts index 2b89a1cef..f133a1ef3 100644 --- a/packages/repack/src/commands/rspack/bundle.ts +++ b/packages/repack/src/commands/rspack/bundle.ts @@ -1,6 +1,6 @@ import { type Configuration, rspack } from '@rspack/core'; import type { Stats } from '@rspack/core'; -import { CLIError } from '../../helpers/index.js'; +import { CLIError, isRspack2 } from '../../helpers/index.js'; import { makeCompilerConfig } from '../common/config/makeCompilerConfig.js'; import { getMaxWorkers, @@ -9,6 +9,7 @@ import { resetPersistentCache, setupEnvironment, setupRspackEnvironment, + warnLegacyRspackCacheConfig, writeStats, } from '../common/index.js'; import type { BundleArguments, CliConfig } from '../types.js'; @@ -36,6 +37,12 @@ export async function bundle( reactNativePath: cliConfig.reactNativePath, }); + // Rspack 2 silently ignores the legacy `experiments.cache` option - + // warn the user so they migrate it to the top-level `cache` option + if (isRspack2(cliConfig.root)) { + warnLegacyRspackCacheConfig([config]); + } + // expose selected args as environment variables setupEnvironment(args); diff --git a/packages/repack/src/commands/rspack/profile/__tests__/profile-2.test.ts b/packages/repack/src/commands/rspack/profile/__tests__/profile-2.test.ts new file mode 100644 index 000000000..006f7d503 --- /dev/null +++ b/packages/repack/src/commands/rspack/profile/__tests__/profile-2.test.ts @@ -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 } + ); + }); +}); diff --git a/packages/repack/src/commands/rspack/profile/__tests__/profileRouting.test.ts b/packages/repack/src/commands/rspack/profile/__tests__/profileRouting.test.ts new file mode 100644 index 000000000..9ea85ceb7 --- /dev/null +++ b/packages/repack/src/commands/rspack/profile/__tests__/profileRouting.test.ts @@ -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' + ); + }); +}); diff --git a/packages/repack/src/commands/rspack/profile/index.ts b/packages/repack/src/commands/rspack/profile/index.ts index d75220b5a..3cf7dd513 100644 --- a/packages/repack/src/commands/rspack/profile/index.ts +++ b/packages/repack/src/commands/rspack/profile/index.ts @@ -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'); diff --git a/packages/repack/src/commands/rspack/profile/profile-2.ts b/packages/repack/src/commands/rspack/profile/profile-2.ts new file mode 100644 index 000000000..0a0effae3 --- /dev/null +++ b/packages/repack/src/commands/rspack/profile/profile-2.ts @@ -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 }); +} diff --git a/packages/repack/src/commands/rspack/start.ts b/packages/repack/src/commands/rspack/start.ts index 9e786f53d..a858a353e 100644 --- a/packages/repack/src/commands/rspack/start.ts +++ b/packages/repack/src/commands/rspack/start.ts @@ -1,7 +1,7 @@ import type { Configuration, MultiRspackOptions } from '@rspack/core'; import packageJson from '../../../package.json'; import { VERBOSE_ENV_KEY } from '../../env.js'; -import { CLIError, isTruthyEnv } from '../../helpers/index.js'; +import { CLIError, isRspack2, isTruthyEnv } from '../../helpers/index.js'; import { ConsoleReporter, FileReporter, @@ -22,6 +22,7 @@ import { setupEnvironment, setupInteractions, setupRspackEnvironment, + warnLegacyRspackCacheConfig, } from '../common/index.js'; import logo from '../common/logo.js'; import type { CliConfig, StartArguments } from '../types.js'; @@ -58,6 +59,12 @@ export async function start( reactNativePath: cliConfig.reactNativePath, }); + // Rspack 2 silently ignores the legacy `experiments.cache` option - + // warn the user so they migrate it to the top-level `cache` option + if (isRspack2(cliConfig.root)) { + warnLegacyRspackCacheConfig(configs); + } + // expose selected args as environment variables setupEnvironment(args); diff --git a/packages/repack/src/loaders/babelSwcLoader/__tests__/checkParallelModeAvailable.test.ts b/packages/repack/src/loaders/babelSwcLoader/__tests__/checkParallelModeAvailable.test.ts new file mode 100644 index 000000000..ef8626935 --- /dev/null +++ b/packages/repack/src/loaders/babelSwcLoader/__tests__/checkParallelModeAvailable.test.ts @@ -0,0 +1,97 @@ +// `checkParallelModeAvailable` keeps a module-level "warning already shown" +// flag, so each test re-requires a fresh copy of the module. The untyped +// `require` also lets tests pass minimal loader-context fakes without casting +// to the full Rspack `LoaderContext` interface. +type CheckParallelModeAvailable = ( + loaderContext: unknown, + logger: unknown +) => void; + +const loadCheckParallelModeAvailable = (): CheckParallelModeAvailable => { + let checkParallelModeAvailable: CheckParallelModeAvailable | undefined; + jest.isolateModules(() => { + ({ checkParallelModeAvailable } = require('../utils.js')); + }); + if (!checkParallelModeAvailable) { + throw new Error('failed to load checkParallelModeAvailable'); + } + return checkParallelModeAvailable; +}; + +const createLogger = () => ({ warn: jest.fn() }); + +const createRspackContext = ( + rspackVersion: string, + experiments: Record | undefined +) => ({ + _compiler: { + webpack: { version: '5.75.0', rspackVersion }, + options: { experiments }, + }, +}); + +describe('checkParallelModeAvailable', () => { + it('warns under rspack 1 when parallelLoader is enabled globally but not for this loader', () => { + const check = loadCheckParallelModeAvailable(); + const logger = createLogger(); + check(createRspackContext('1.5.0', { parallelLoader: true }), logger); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('experiments.parallelLoader') + ); + }); + + it('warns only once', () => { + const check = loadCheckParallelModeAvailable(); + const logger = createLogger(); + check(createRspackContext('1.5.0', { parallelLoader: true }), logger); + check(createRspackContext('1.5.0', { parallelLoader: true }), logger); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + + it('does not warn under rspack 1 when parallelLoader is not enabled', () => { + const check = loadCheckParallelModeAvailable(); + const logger = createLogger(); + check(createRspackContext('1.5.0', {}), logger); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('does not warn under rspack 2 (parallelLoader global flag no longer exists)', () => { + const check = loadCheckParallelModeAvailable(); + const logger = createLogger(); + // even with a leftover parallelLoader flag in the config, the version + // gate skips the check - running non-parallel is valid under rspack 2 + check(createRspackContext('2.0.0', { parallelLoader: true }), logger); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('does not warn under rspack 2 prereleases', () => { + const check = loadCheckParallelModeAvailable(); + const logger = createLogger(); + check( + createRspackContext('2.0.0-beta.1', { parallelLoader: true }), + logger + ); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('does not warn on webpack', () => { + const check = loadCheckParallelModeAvailable(); + const logger = createLogger(); + const webpackContext = { + _compiler: { + webpack: { version: '5.75.0' }, + options: { experiments: { parallelLoader: true } }, + }, + }; + check(webpackContext, logger); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('does not warn in parallel mode where the compiler is mocked', () => { + const check = loadCheckParallelModeAvailable(); + const logger = createLogger(); + // in parallel mode the compiler object is mocked without most props + check({ _compiler: { webpack: {} } }, logger); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/repack/src/loaders/babelSwcLoader/utils.ts b/packages/repack/src/loaders/babelSwcLoader/utils.ts index bde4e504b..cf0410611 100644 --- a/packages/repack/src/loaders/babelSwcLoader/utils.ts +++ b/packages/repack/src/loaders/babelSwcLoader/utils.ts @@ -98,6 +98,13 @@ export function checkParallelModeAvailable( if (parallelModeWarningDisplayed || isWebpackBackend(loaderContext)) { return; } + // Rspack 2 removed `experiments.parallelLoader` (parallel loading is stable + // and opt-in per rule only), so there is no global flag to check against - + // running non-parallel is a valid choice there, not a misconfiguration + const rspackVersion = loaderContext._compiler?.webpack?.rspackVersion; + if (rspackVersion && Number(rspackVersion.split('.')[0]) >= 2) { + return; + } // in parallel mode compiler.options.experiments are not available // but since we're already running in parallel mode, we can ignore this check // ('in' narrowing: `parallelLoader` no longer exists in Rspack 2 types)