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/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/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 810888178..2292885f0 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/start.ts b/packages/repack/src/commands/rspack/start.ts index c2117b695..936faff48 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);