From a758fb077a99be6bd098f5589d0db99b43710cd7 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Wed, 21 Jan 2026 23:39:44 +0100 Subject: [PATCH 01/19] feat: add lazy compilation support to Rspack --- .../repack/src/commands/rspack/Compiler.ts | 63 +++++++++++++++++-- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/repack/src/commands/rspack/Compiler.ts b/packages/repack/src/commands/rspack/Compiler.ts index 3dc6ce2cb..0f01698fc 100644 --- a/packages/repack/src/commands/rspack/Compiler.ts +++ b/packages/repack/src/commands/rspack/Compiler.ts @@ -13,7 +13,7 @@ import type { Reporter } from '../../logging/types.js'; import type { HMRMessage } from '../../types.js'; import { runAdbReverse } from '../common/index.js'; import { DEV_SERVER_ASSET_TYPES } from '../consts.js'; -import type { CompilerAsset } from './types.js'; +import type { CompilerAsset, MultiWatching } from './types.js'; export class Compiler { compiler: MultiCompiler; @@ -27,12 +27,20 @@ export class Compiler { // late-init devServerContext!: Server.DelegateContext; + // Lazy compilation state + private watching: MultiWatching | null = null; + private requestedPlatforms = new Set(); + private lazyEntries = new Map>(); + constructor( configs: MultiRspackOptions, private reporter: Reporter, private rootDir: string ) { const handler = (platform: string, value: number) => { + // Skip progress for platforms not yet requested (lazy compilation) + if (!this.requestedPlatforms.has(platform)) return; + const percentage = Math.floor(value * 100); this.progressSenders[platform]?.forEach((sendProgress) => { sendProgress({ completed: percentage, total: 100 }); @@ -97,6 +105,9 @@ export class Compiler { this.compiler.hooks.watchRun.tap('repack:watch', () => { this.isCompilationInProgress = true; this.platforms.forEach((platform) => { + // Skip notifications for platforms not yet requested (lazy compilation) + if (!this.requestedPlatforms.has(platform)) return; + if (platform === 'android') { void runAdbReverse({ port: this.devServerContext.options.port, @@ -114,6 +125,9 @@ export class Compiler { this.compiler.hooks.invalid.tap('repack:invalid', () => { this.isCompilationInProgress = true; this.platforms.forEach((platform) => { + // Skip notifications for platforms not yet requested (lazy compilation) + if (!this.requestedPlatforms.has(platform)) return; + this.devServerContext.notifyBuildStart(platform); this.devServerContext.broadcastToHmrClients({ action: 'compiling', @@ -137,10 +151,14 @@ export class Compiler { try { stats.children!.map((childStats) => { const platform = childStats.name!; - this.devServerContext.broadcastToHmrClients({ - action: 'hash', - body: { name: platform, hash: childStats.hash }, - }); + + // Only broadcast HMR hash for requested platforms (lazy compilation) + if (this.requestedPlatforms.has(platform)) { + this.devServerContext.broadcastToHmrClients({ + action: 'hash', + body: { name: platform, hash: childStats.hash }, + }); + } this.statsCache[platform] = childStats; const assets = childStats.assets!; @@ -202,6 +220,10 @@ export class Compiler { const platform = childStats.name!; const time = childStats.time!; this.callPendingResolvers(platform); + + // Skip notifications for platforms not yet requested (lazy compilation) + if (!this.requestedPlatforms.has(platform)) return; + this.devServerContext.notifyBuildEnd(platform); this.devServerContext.broadcastToHmrClients({ action: 'ok', @@ -215,10 +237,30 @@ export class Compiler { }); }); }); + + // Lazy compilation: intercept module resolution for platforms not yet requested + this.compiler.compilers.forEach((childCompiler) => { + const platform = childCompiler.name!; + + childCompiler.hooks.thisCompilation.tap( + 'repack:lazy', + (_, { normalModuleFactory }) => { + normalModuleFactory.hooks.afterResolve.tap('repack:lazy', (data) => { + if (!this.requestedPlatforms.has(platform)) { + const resolvedPath = path.resolve(data.context, data.request); + const entries = this.lazyEntries.get(platform) ?? new Set(); + entries.add(resolvedPath); + this.lazyEntries.set(platform, entries); + data.createData!.request = '/* lazy compilation placeholder */'; + } + }); + } + ); + }); } start() { - this.compiler.watch(this.watchOptions, (error) => { + this.watching = this.compiler.watch(this.watchOptions, (error) => { if (!error) return; this.platforms.forEach((platform) => { this.callPendingResolvers(platform, error); @@ -231,6 +273,15 @@ export class Compiler { platform: string, sendProgress?: SendProgress ): Promise { + // Trigger lazy compilation for platform on first request + if (!this.requestedPlatforms.has(platform)) { + this.requestedPlatforms.add(platform); + const entries = this.lazyEntries.get(platform); + if (entries && this.watching) { + this.watching.invalidateWithChangesAndRemovals(entries, undefined); + } + } + // Return file from assetsCache if exists const fileFromCache = this.assetsCache[platform]?.[filename]; if (fileFromCache) { From 083fd6471ddd6fc698b6ae65e0b1f0b14d4a2a22 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Wed, 21 Jan 2026 23:54:43 +0100 Subject: [PATCH 02/19] chore: update podfile lock --- apps/tester-app/ios/Podfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/tester-app/ios/Podfile.lock b/apps/tester-app/ios/Podfile.lock index b19517361..067bbdbc4 100644 --- a/apps/tester-app/ios/Podfile.lock +++ b/apps/tester-app/ios/Podfile.lock @@ -1,6 +1,6 @@ PODS: - boost (1.84.0) - - callstack-repack (5.2.2): + - callstack-repack (5.2.3): - boost - DoubleConversion - fast_float @@ -2930,7 +2930,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - callstack-repack: c874fe60c49dcf3067bca0627b7ace673589737c + callstack-repack: 15b29626cee2b659cd3f9afa4e8c33b1d42f5c59 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: a867936a67af0d09c37935a1b900a1a3c795b6d1 From 8158eeba860b7b021758772cf9e0c336928f492e Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Thu, 22 Jan 2026 00:04:54 +0100 Subject: [PATCH 03/19] fix: clear stale asset from asset cache --- packages/repack/src/commands/rspack/Compiler.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/repack/src/commands/rspack/Compiler.ts b/packages/repack/src/commands/rspack/Compiler.ts index 0f01698fc..459a28ab0 100644 --- a/packages/repack/src/commands/rspack/Compiler.ts +++ b/packages/repack/src/commands/rspack/Compiler.ts @@ -278,6 +278,9 @@ export class Compiler { this.requestedPlatforms.add(platform); const entries = this.lazyEntries.get(platform); if (entries && this.watching) { + // Clear stub assets and mark compilation in progress before invalidation + delete this.assetsCache[platform]; + this.isCompilationInProgress = true; this.watching.invalidateWithChangesAndRemovals(entries, undefined); } } From 63166bbef688abd6f31b3e683a101f1e48a8aeee Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Fri, 6 Feb 2026 14:32:26 +0100 Subject: [PATCH 04/19] feat: rework to support persistent cache --- .../repack/src/commands/rspack/Compiler.ts | 284 +++++++++--------- packages/repack/src/commands/rspack/start.ts | 1 + packages/repack/src/commands/rspack/types.ts | 4 +- 3 files changed, 149 insertions(+), 140 deletions(-) diff --git a/packages/repack/src/commands/rspack/Compiler.ts b/packages/repack/src/commands/rspack/Compiler.ts index 459a28ab0..288cce3ee 100644 --- a/packages/repack/src/commands/rspack/Compiler.ts +++ b/packages/repack/src/commands/rspack/Compiler.ts @@ -5,6 +5,7 @@ import { rspack } from '@rspack/core'; import type { MultiCompiler, MultiRspackOptions, + Compiler as RspackCompiler, StatsCompilation, } from '@rspack/core'; import memfs from 'memfs'; @@ -13,7 +14,7 @@ import type { Reporter } from '../../logging/types.js'; import type { HMRMessage } from '../../types.js'; import { runAdbReverse } from '../common/index.js'; import { DEV_SERVER_ASSET_TYPES } from '../consts.js'; -import type { CompilerAsset, MultiWatching } from './types.js'; +import type { CompilerAsset } from './types.js'; export class Compiler { compiler: MultiCompiler; @@ -23,14 +24,13 @@ export class Compiler { statsCache: Record = {}; resolvers: Record void>> = {}; progressSenders: Record = {}; - isCompilationInProgress = false; + isCompilationInProgress: Record = {}; // late-init devServerContext!: Server.DelegateContext; - // Lazy compilation state - private watching: MultiWatching | null = null; - private requestedPlatforms = new Set(); - private lazyEntries = new Map>(); + private watchRunGates: Map void> = new Map(); + private activePlatforms: Set = new Set(); + private buildStartTime: Record = {}; constructor( configs: MultiRspackOptions, @@ -38,8 +38,8 @@ export class Compiler { private rootDir: string ) { const handler = (platform: string, value: number) => { - // Skip progress for platforms not yet requested (lazy compilation) - if (!this.requestedPlatforms.has(platform)) return; + // Skip progress for platforms not yet activated + if (!this.activePlatforms.has(platform)) return; const percentage = Math.floor(value * 100); this.progressSenders[platform]?.forEach((sendProgress) => { @@ -68,7 +68,7 @@ export class Compiler { // @ts-expect-error memfs is compatible enough this.compiler.outputFileSystem = this.filesystem; - this.setupCompiler(); + this.setupCompilerHooks(); } get devServerOptions() { @@ -101,46 +101,74 @@ export class Compiler { this.devServerContext = ctx; } - private setupCompiler() { - this.compiler.hooks.watchRun.tap('repack:watch', () => { - this.isCompilationInProgress = true; - this.platforms.forEach((platform) => { - // Skip notifications for platforms not yet requested (lazy compilation) - if (!this.requestedPlatforms.has(platform)) return; - - if (platform === 'android') { - void runAdbReverse({ - port: this.devServerContext.options.port, - logger: this.devServerContext.log, - }); - } - this.devServerContext.notifyBuildStart(platform); - this.devServerContext.broadcastToHmrClients({ - action: 'compiling', - body: { name: platform }, + private setupCompilerHooks() { + for (const childCompiler of this.compiler.compilers) { + const platform = childCompiler.options.name!; + this.setupChildCompilerHooks(platform, childCompiler); + } + } + + private setupChildCompilerHooks( + platform: string, + childCompiler: RspackCompiler + ) { + // Gate: hold unrequested platforms at watchRun + childCompiler.hooks.watchRun.tapAsync('repack:gate', (_compiler, done) => { + if (this.activePlatforms.has(platform)) { + done(); + } else { + this.watchRunGates.set(platform, done); + } + }); + + // Notify build start only for active platforms + childCompiler.hooks.watchRun.tap('repack:watch', () => { + if (!this.activePlatforms.has(platform)) return; + + // Fix: #go() set startTime and lastWatcherStartTime at server startup + // (before the gate held). After gate release the stale values cause + // _done() to create a watcher that sees phantom file changes since + // server start, triggering a spurious rebuild. Resetting both here + // is safe for non-gated rebuilds too — #go() set them moments before + // watchRun fired. + if (childCompiler.watching) { + childCompiler.watching.startTime = Date.now(); + childCompiler.watching.lastWatcherStartTime = Date.now(); + } + + this.isCompilationInProgress[platform] = true; + this.buildStartTime[platform] = Date.now(); + + if (platform === 'android') { + void runAdbReverse({ + port: this.devServerContext.options.port, + logger: this.devServerContext.log, }); + } + + this.devServerContext.notifyBuildStart(platform); + this.devServerContext.broadcastToHmrClients({ + action: 'compiling', + body: { name: platform }, }); }); - this.compiler.hooks.invalid.tap('repack:invalid', () => { - this.isCompilationInProgress = true; - this.platforms.forEach((platform) => { - // Skip notifications for platforms not yet requested (lazy compilation) - if (!this.requestedPlatforms.has(platform)) return; + childCompiler.hooks.invalid.tap('repack:invalid', () => { + if (!this.activePlatforms.has(platform)) return; - this.devServerContext.notifyBuildStart(platform); - this.devServerContext.broadcastToHmrClients({ - action: 'compiling', - body: { name: platform }, - }); + this.isCompilationInProgress[platform] = true; + this.devServerContext.notifyBuildStart(platform); + this.devServerContext.broadcastToHmrClients({ + action: 'compiling', + body: { name: platform }, }); }); - this.compiler.hooks.done.tap('repack:done', (multiStats) => { - const stats = multiStats.toJson({ + childCompiler.hooks.done.tap('repack:done', (stats) => { + const buildEndTime = Date.now(); + const childStats = stats.toJson({ all: false, assets: true, - children: true, outputPath: true, timings: true, hash: true, @@ -148,60 +176,55 @@ export class Compiler { warnings: true, }); + const previousHash = this.statsCache[platform]?.hash; + try { - stats.children!.map((childStats) => { - const platform = childStats.name!; - - // Only broadcast HMR hash for requested platforms (lazy compilation) - if (this.requestedPlatforms.has(platform)) { - this.devServerContext.broadcastToHmrClients({ - action: 'hash', - body: { name: platform, hash: childStats.hash }, - }); - } + this.devServerContext.broadcastToHmrClients({ + action: 'hash', + body: { name: platform, hash: childStats.hash }, + }); - this.statsCache[platform] = childStats; - const assets = childStats.assets!; - - this.assetsCache[platform] = assets - .filter((asset) => asset.type === 'asset') - .reduce( - (acc, { name, info, size }) => { - const assetPath = path.join(childStats.outputPath!, name); - const data = this.filesystem.readFileSync(assetPath) as Buffer; - const asset = { data, info, size }; - - acc[adaptFilenameToPlatform(name)] = asset; - - if (info.related?.sourceMap) { - const sourceMapName = Array.isArray(info.related.sourceMap) - ? info.related.sourceMap[0] - : info.related.sourceMap; - const sourceMapPath = path.join( - childStats.outputPath!, - sourceMapName - ); - const sourceMapData = this.filesystem.readFileSync( - sourceMapPath - ) as Buffer; - const sourceMapAsset = { - data: sourceMapData, - info: { - hotModuleReplacement: info.hotModuleReplacement, - size: sourceMapData.length, - }, + this.statsCache[platform] = childStats; + const assets = childStats.assets!; + + this.assetsCache[platform] = assets + .filter((asset) => asset.type === 'asset') + .reduce( + (acc, { name, info, size }) => { + const assetPath = path.join(childStats.outputPath!, name); + const data = this.filesystem.readFileSync(assetPath) as Buffer; + const asset = { data, info, size }; + + acc[adaptFilenameToPlatform(name)] = asset; + + if (info.related?.sourceMap) { + const sourceMapName = Array.isArray(info.related.sourceMap) + ? info.related.sourceMap[0] + : info.related.sourceMap; + const sourceMapPath = path.join( + childStats.outputPath!, + sourceMapName + ); + const sourceMapData = this.filesystem.readFileSync( + sourceMapPath + ) as Buffer; + const sourceMapAsset = { + data: sourceMapData, + info: { + hotModuleReplacement: info.hotModuleReplacement, size: sourceMapData.length, - }; - - acc[adaptFilenameToPlatform(sourceMapName)] = sourceMapAsset; - } - - return acc; - }, - // keep old assets - this.assetsCache[platform] ?? {} - ); - }); + }, + size: sourceMapData.length, + }; + + acc[adaptFilenameToPlatform(sourceMapName)] = sourceMapAsset; + } + + return acc; + }, + // keep old assets + this.assetsCache[platform] ?? {} + ); } catch (error) { this.reporter.process({ type: 'error', @@ -214,53 +237,40 @@ export class Compiler { }); } - this.isCompilationInProgress = false; + this.isCompilationInProgress[platform] = false; + this.callPendingResolvers(platform); - stats.children?.forEach((childStats) => { - const platform = childStats.name!; - const time = childStats.time!; - this.callPendingResolvers(platform); - - // Skip notifications for platforms not yet requested (lazy compilation) - if (!this.requestedPlatforms.has(platform)) return; - - this.devServerContext.notifyBuildEnd(platform); - this.devServerContext.broadcastToHmrClients({ - action: 'ok', - body: { name: platform }, - }); + this.devServerContext.notifyBuildEnd(platform); + this.devServerContext.broadcastToHmrClients({ + action: 'ok', + body: { name: platform }, + }); + if (childStats.hash !== previousHash) { + const time = buildEndTime - this.buildStartTime[platform]; this.reporter.process({ issuer: 'DevServer', message: [{ progress: { platform, time } }], timestamp: Date.now(), type: 'progress', }); - }); + } }); + } - // Lazy compilation: intercept module resolution for platforms not yet requested - this.compiler.compilers.forEach((childCompiler) => { - const platform = childCompiler.name!; - - childCompiler.hooks.thisCompilation.tap( - 'repack:lazy', - (_, { normalModuleFactory }) => { - normalModuleFactory.hooks.afterResolve.tap('repack:lazy', (data) => { - if (!this.requestedPlatforms.has(platform)) { - const resolvedPath = path.resolve(data.context, data.request); - const entries = this.lazyEntries.get(platform) ?? new Set(); - entries.add(resolvedPath); - this.lazyEntries.set(platform, entries); - data.createData!.request = '/* lazy compilation placeholder */'; - } - }); - } - ); - }); + private activatePlatform(platform: string) { + if (this.activePlatforms.has(platform)) return; + this.activePlatforms.add(platform); + this.isCompilationInProgress[platform] = true; + + const gate = this.watchRunGates.get(platform); + if (gate) { + this.watchRunGates.delete(platform); + gate(); + } } start() { - this.watching = this.compiler.watch(this.watchOptions, (error) => { + this.compiler.watch(this.watchOptions, (error) => { if (!error) return; this.platforms.forEach((platform) => { this.callPendingResolvers(platform, error); @@ -268,22 +278,22 @@ export class Compiler { }); } + close(callback?: () => void) { + // Release all held gates so Watching instances can complete and close cleanly + for (const [, gate] of this.watchRunGates) { + gate(); + } + this.watchRunGates.clear(); + this.compiler.close(callback ?? (() => {})); + } + async getAsset( filename: string, platform: string, sendProgress?: SendProgress ): Promise { - // Trigger lazy compilation for platform on first request - if (!this.requestedPlatforms.has(platform)) { - this.requestedPlatforms.add(platform); - const entries = this.lazyEntries.get(platform); - if (entries && this.watching) { - // Clear stub assets and mark compilation in progress before invalidation - delete this.assetsCache[platform]; - this.isCompilationInProgress = true; - this.watching.invalidateWithChangesAndRemovals(entries, undefined); - } - } + // Activate compiler for this platform on first request + this.activatePlatform(platform); // Return file from assetsCache if exists const fileFromCache = this.assetsCache[platform]?.[filename]; @@ -293,7 +303,7 @@ export class Compiler { this.addProgressSender(platform, sendProgress); - if (!this.isCompilationInProgress) { + if (!this.isCompilationInProgress[platform]) { this.removeProgressSender(platform, sendProgress); return Promise.reject( new Error( diff --git a/packages/repack/src/commands/rspack/start.ts b/packages/repack/src/commands/rspack/start.ts index dc915ab4f..d33083fd4 100644 --- a/packages/repack/src/commands/rspack/start.ts +++ b/packages/repack/src/commands/rspack/start.ts @@ -211,6 +211,7 @@ export async function start( return { stop: async () => { reporter.stop(); + await new Promise((resolve) => compiler.close(resolve)); await stop(); }, }; diff --git a/packages/repack/src/commands/rspack/types.ts b/packages/repack/src/commands/rspack/types.ts index 7d8485e4d..381f87696 100644 --- a/packages/repack/src/commands/rspack/types.ts +++ b/packages/repack/src/commands/rspack/types.ts @@ -1,4 +1,4 @@ -import type { MultiCompiler, StatsAsset } from '@rspack/core'; +import type { StatsAsset } from '@rspack/core'; import type { RemoveRecord } from '../types.js'; type RspackStatsAsset = RemoveRecord; @@ -8,5 +8,3 @@ export interface CompilerAsset { info: RspackStatsAsset['info']; size: number; } - -export type MultiWatching = ReturnType; From b7df44e2e1a2661f16be7dd09d0776b145ef46f3 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Fri, 6 Feb 2026 17:55:42 +0100 Subject: [PATCH 05/19] test: add tests for lazy compilation --- .../__tests__/lazy-compilation.test.ts | 105 ++++++++++++++ .../rspack/__tests__/Compiler.test.ts | 129 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 apps/tester-app/__tests__/lazy-compilation.test.ts create mode 100644 packages/repack/src/commands/rspack/__tests__/Compiler.test.ts diff --git a/apps/tester-app/__tests__/lazy-compilation.test.ts b/apps/tester-app/__tests__/lazy-compilation.test.ts new file mode 100644 index 000000000..c9f48f027 --- /dev/null +++ b/apps/tester-app/__tests__/lazy-compilation.test.ts @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import rspackCommands from '@callstack/repack/commands/rspack'; +import getPort from 'get-port'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const TMP_DIR = path.join(__dirname, 'out/lazy-compilation'); + +let port: number; +let stopServer: () => Promise; + +describe('lazy compilation', () => { + const startCommand = rspackCommands.find( + (command) => command.name === 'start' + ); + if (!startCommand) throw new Error('start command not found'); + + beforeAll(async () => { + await fs.promises.rm(TMP_DIR, { recursive: true, force: true }); + + port = await getPort(); + + const config = { + root: path.join(__dirname, '..'), + platforms: { ios: {}, android: {} }, + reactNativePath: path.join(__dirname, '../node_modules/react-native'), + }; + + const args = { + port, + // No `platform` arg — both ios and android are configured, + // which enables the lazy compilation watchRun gate mechanism. + logFile: path.join(TMP_DIR, 'server.log'), + webpackConfig: path.join(__dirname, 'configs', './rspack.config.mjs'), + }; + + // @ts-ignore + const { stop } = await startCommand.func([], config, args); + stopServer = stop; + }); + + afterAll(async () => { + await stopServer(); + }); + + it( + 'stats API returns null for both platforms before any bundle is requested', + async () => { + const iosStats = await fetch( + `http://localhost:${port}/api/ios/stats` + ).then((r) => r.json()); + const androidStats = await fetch( + `http://localhost:${port}/api/android/stats` + ).then((r) => r.json()); + + expect(iosStats.data).toBeNull(); + expect(androidStats.data).toBeNull(); + }, + 60 * 1000 + ); + + it( + 'GET /index.bundle?platform=ios produces ios stats but android stats remain null', + async () => { + const response = await fetch( + `http://localhost:${port}/index.bundle?platform=ios` + ); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(body.length).toBeGreaterThan(100000); + + const iosStats = await fetch( + `http://localhost:${port}/api/ios/stats` + ).then((r) => r.json()); + const androidStats = await fetch( + `http://localhost:${port}/api/android/stats` + ).then((r) => r.json()); + + expect(iosStats.data).not.toBeNull(); + expect(androidStats.data).toBeNull(); + }, + 60 * 1000 + ); + + it( + 'GET /index.bundle?platform=android produces android stats after ios is already compiled', + async () => { + const response = await fetch( + `http://localhost:${port}/index.bundle?platform=android` + ); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(body.length).toBeGreaterThan(100000); + + const androidStats = await fetch( + `http://localhost:${port}/api/android/stats` + ).then((r) => r.json()); + + expect(androidStats.data).not.toBeNull(); + }, + 60 * 1000 + ); +}); diff --git a/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts new file mode 100644 index 000000000..b54191537 --- /dev/null +++ b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts @@ -0,0 +1,129 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { Server } from '@callstack/repack-dev-server'; +import type { MultiRspackOptions } from '@rspack/core'; +import type { Reporter } from '../../../logging/types.js'; +import { Compiler } from '../Compiler.js'; + +// Mock adb reverse to avoid calling adb during tests +jest.mock('../../common/runAdbReverse.js', () => ({ + runAdbReverse: jest.fn().mockResolvedValue(undefined), +})); + +describe('Compiler – lazy compilation', () => { + let tmpDir: string; + let entryPath: string; + + const reporter: Reporter = { + process: jest.fn(), + flush: jest.fn(), + stop: jest.fn(), + }; + + const mockDevServerContext: Server.DelegateContext = { + options: { port: 8081 } as Server.DelegateContext['options'], + log: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + } as unknown as Server.DelegateContext['log'], + notifyBuildStart: jest.fn(), + notifyBuildEnd: jest.fn(), + broadcastToHmrClients: jest.fn(), + broadcastToMessageClients: jest.fn(), + }; + + beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-compiler-test-')); + entryPath = path.join(tmpDir, 'entry.js'); + fs.writeFileSync(entryPath, 'module.exports = {};'); + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function createConfigs(): MultiRspackOptions { + return [ + { + name: 'ios', + mode: 'development', + entry: entryPath, + output: { filename: 'main.js', path: path.join(tmpDir, 'out-ios') }, + plugins: [], + }, + { + name: 'android', + mode: 'development', + entry: entryPath, + output: { + filename: 'main.js', + path: path.join(tmpDir, 'out-android'), + }, + plugins: [], + }, + ]; + } + + describe('watchRun gate', () => { + let compiler: Compiler; + + beforeAll(() => { + compiler = new Compiler(createConfigs(), reporter, tmpDir); + compiler.setDevServerContext(mockDevServerContext); + compiler.start(); + }); + + afterAll(async () => { + await new Promise((resolve) => compiler.close(resolve)); + }); + + it('after start(), statsCache and isCompilationInProgress remain empty for both platforms', async () => { + // Give the watcher a moment to initialize — the gate should hold + await new Promise((resolve) => setTimeout(resolve, 2000)); + + expect(compiler.statsCache.ios).toBeUndefined(); + expect(compiler.statsCache.android).toBeUndefined(); + expect(compiler.isCompilationInProgress.ios).toBeFalsy(); + expect(compiler.isCompilationInProgress.android).toBeFalsy(); + }); + + it('getAsset("main.js", "ios") produces ios stats but leaves android stats undefined', async () => { + const asset = await compiler.getAsset('main.js', 'ios'); + + expect(asset).toBeDefined(); + expect(asset.data).toBeInstanceOf(Buffer); + expect(compiler.statsCache.ios).toBeDefined(); + expect(compiler.statsCache.android).toBeUndefined(); + }); + + it('getAsset("main.js", "android") produces android stats independently of ios', async () => { + const asset = await compiler.getAsset('main.js', 'android'); + + expect(asset).toBeDefined(); + expect(asset.data).toBeInstanceOf(Buffer); + expect(compiler.statsCache.android).toBeDefined(); + }); + + it('getAsset for an already-compiled platform resolves from cache without recompilation', async () => { + // Both platforms are already compiled from previous tests + const asset = await compiler.getAsset('main.js', 'ios'); + + expect(asset).toBeDefined(); + expect(asset.data).toBeInstanceOf(Buffer); + }); + }); + + describe('close()', () => { + it('resolves when both platform gates are still held (no getAsset calls)', async () => { + const compiler = new Compiler(createConfigs(), reporter, tmpDir); + compiler.setDevServerContext(mockDevServerContext); + compiler.start(); + + // Gates are held for both platforms — close() should release them + await new Promise((resolve) => compiler.close(resolve)); + }); + }); +}); From eca466ed2a0c08bdac8944421438cb5203a66e30 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Sat, 7 Feb 2026 11:23:56 +0100 Subject: [PATCH 06/19] test: remove redundant case --- .../src/commands/rspack/__tests__/Compiler.test.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts index b54191537..9bf093bd5 100644 --- a/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts +++ b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts @@ -80,16 +80,6 @@ describe('Compiler – lazy compilation', () => { await new Promise((resolve) => compiler.close(resolve)); }); - it('after start(), statsCache and isCompilationInProgress remain empty for both platforms', async () => { - // Give the watcher a moment to initialize — the gate should hold - await new Promise((resolve) => setTimeout(resolve, 2000)); - - expect(compiler.statsCache.ios).toBeUndefined(); - expect(compiler.statsCache.android).toBeUndefined(); - expect(compiler.isCompilationInProgress.ios).toBeFalsy(); - expect(compiler.isCompilationInProgress.android).toBeFalsy(); - }); - it('getAsset("main.js", "ios") produces ios stats but leaves android stats undefined', async () => { const asset = await compiler.getAsset('main.js', 'ios'); From dc5f3e28eff99015d6df552a172e1d871331e684 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Wed, 29 Jul 2026 22:23:13 +0200 Subject: [PATCH 07/19] chore: refresh Podfile locks --- apps/tester-app/ios/Podfile.lock | 2 +- apps/tester-federation-v2/ios/Podfile.lock | 2 +- apps/tester-federation/ios/Podfile.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/tester-app/ios/Podfile.lock b/apps/tester-app/ios/Podfile.lock index 0e2160858..418dbfb39 100644 --- a/apps/tester-app/ios/Podfile.lock +++ b/apps/tester-app/ios/Podfile.lock @@ -2454,7 +2454,7 @@ SPEC CHECKSUMS: AsyncStorage: 163ab23f6aa37a58b7f2379d0b4ee7ac84d6655c callstack-repack: 2d0e744d37013ccc33451e4c797d5dd67eae6e56 FBLazyVector: b3e7ad108f0d882e30445c5527d774e3fd432f3d - hermes-engine: 399db9aa65675d12edcd906c1caab4a41e98f4e1 + hermes-engine: e43b2312391cd7fd1d3c54c63ccbcc69cf3b477e JWTDecode: 2eed97c2fa46ccaf3049a787004eedf0be474a87 RCTDeprecation: 2a74a2c57675e64419bd89078efde81f7c1de90b RCTRequired: 30451112e6fef4e6f31b4e7eee0845156e35e4b0 diff --git a/apps/tester-federation-v2/ios/Podfile.lock b/apps/tester-federation-v2/ios/Podfile.lock index 75fe285f1..08ea18697 100644 --- a/apps/tester-federation-v2/ios/Podfile.lock +++ b/apps/tester-federation-v2/ios/Podfile.lock @@ -2276,7 +2276,7 @@ SPEC CHECKSUMS: AsyncStorage: 163ab23f6aa37a58b7f2379d0b4ee7ac84d6655c callstack-repack: 2d0e744d37013ccc33451e4c797d5dd67eae6e56 FBLazyVector: b3e7ad108f0d882e30445c5527d774e3fd432f3d - hermes-engine: 399db9aa65675d12edcd906c1caab4a41e98f4e1 + hermes-engine: e43b2312391cd7fd1d3c54c63ccbcc69cf3b477e JWTDecode: 2eed97c2fa46ccaf3049a787004eedf0be474a87 RCTDeprecation: 2a74a2c57675e64419bd89078efde81f7c1de90b RCTRequired: 30451112e6fef4e6f31b4e7eee0845156e35e4b0 diff --git a/apps/tester-federation/ios/Podfile.lock b/apps/tester-federation/ios/Podfile.lock index a8d1006e7..0315a69c1 100644 --- a/apps/tester-federation/ios/Podfile.lock +++ b/apps/tester-federation/ios/Podfile.lock @@ -2276,7 +2276,7 @@ SPEC CHECKSUMS: AsyncStorage: 163ab23f6aa37a58b7f2379d0b4ee7ac84d6655c callstack-repack: 2d0e744d37013ccc33451e4c797d5dd67eae6e56 FBLazyVector: b3e7ad108f0d882e30445c5527d774e3fd432f3d - hermes-engine: 399db9aa65675d12edcd906c1caab4a41e98f4e1 + hermes-engine: e43b2312391cd7fd1d3c54c63ccbcc69cf3b477e JWTDecode: 2eed97c2fa46ccaf3049a787004eedf0be474a87 RCTDeprecation: 2a74a2c57675e64419bd89078efde81f7c1de90b RCTRequired: 30451112e6fef4e6f31b4e7eee0845156e35e4b0 From f04e61f223efa6fb04914121a4686f113a07af94 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Wed, 29 Jul 2026 23:21:06 +0200 Subject: [PATCH 08/19] fix: propagate Rspack shutdown errors --- .../repack/src/commands/rspack/Compiler.ts | 4 ++-- .../rspack/__tests__/Compiler.test.ts | 22 +++++++++++++++++-- packages/repack/src/commands/rspack/start.ts | 4 +++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/repack/src/commands/rspack/Compiler.ts b/packages/repack/src/commands/rspack/Compiler.ts index 288cce3ee..d70d7d4a7 100644 --- a/packages/repack/src/commands/rspack/Compiler.ts +++ b/packages/repack/src/commands/rspack/Compiler.ts @@ -278,13 +278,13 @@ export class Compiler { }); } - close(callback?: () => void) { + close(callback: (error?: Error | null) => void = () => {}) { // Release all held gates so Watching instances can complete and close cleanly for (const [, gate] of this.watchRunGates) { gate(); } this.watchRunGates.clear(); - this.compiler.close(callback ?? (() => {})); + this.compiler.close(callback); } async getAsset( diff --git a/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts index 9bf093bd5..8accf2f67 100644 --- a/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts +++ b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts @@ -77,7 +77,9 @@ describe('Compiler – lazy compilation', () => { }); afterAll(async () => { - await new Promise((resolve) => compiler.close(resolve)); + await new Promise((resolve, reject) => { + compiler.close((error) => (error ? reject(error) : resolve())); + }); }); it('getAsset("main.js", "ios") produces ios stats but leaves android stats undefined', async () => { @@ -113,7 +115,23 @@ describe('Compiler – lazy compilation', () => { compiler.start(); // Gates are held for both platforms — close() should release them - await new Promise((resolve) => compiler.close(resolve)); + await new Promise((resolve, reject) => { + compiler.close((error) => (error ? reject(error) : resolve())); + }); + }); + + it('forwards compiler close errors to the caller', async () => { + const compiler = new Compiler(createConfigs(), reporter, tmpDir); + const closeError = new Error('close failed'); + jest + .spyOn(compiler.compiler, 'close') + .mockImplementation((callback) => callback(closeError)); + + await expect( + new Promise((resolve, reject) => { + compiler.close((error) => (error ? reject(error) : resolve())); + }) + ).rejects.toBe(closeError); }); }); }); diff --git a/packages/repack/src/commands/rspack/start.ts b/packages/repack/src/commands/rspack/start.ts index d33083fd4..6345e3506 100644 --- a/packages/repack/src/commands/rspack/start.ts +++ b/packages/repack/src/commands/rspack/start.ts @@ -211,7 +211,9 @@ export async function start( return { stop: async () => { reporter.stop(); - await new Promise((resolve) => compiler.close(resolve)); + await new Promise((resolve, reject) => { + compiler.close((error) => (error ? reject(error) : resolve())); + }); await stop(); }, }; From a0fc330aad00e8ba7d2cff13c61bfdc92221318e Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Wed, 29 Jul 2026 23:24:23 +0200 Subject: [PATCH 09/19] test: assert lazy compilation build counts --- .../rspack/__tests__/Compiler.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts index 8accf2f67..43b350e25 100644 --- a/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts +++ b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts @@ -14,6 +14,7 @@ jest.mock('../../common/runAdbReverse.js', () => ({ describe('Compiler – lazy compilation', () => { let tmpDir: string; let entryPath: string; + const compilationCounts = { ios: 0, android: 0 }; const reporter: Reporter = { process: jest.fn(), @@ -53,6 +54,7 @@ describe('Compiler – lazy compilation', () => { entry: entryPath, output: { filename: 'main.js', path: path.join(tmpDir, 'out-ios') }, plugins: [], + watchOptions: { poll: 10 }, }, { name: 'android', @@ -63,6 +65,7 @@ describe('Compiler – lazy compilation', () => { path: path.join(tmpDir, 'out-android'), }, plugins: [], + watchOptions: { poll: 10 }, }, ]; } @@ -73,6 +76,13 @@ describe('Compiler – lazy compilation', () => { beforeAll(() => { compiler = new Compiler(createConfigs(), reporter, tmpDir); compiler.setDevServerContext(mockDevServerContext); + for (const childCompiler of compiler.compiler.compilers) { + const platform = childCompiler.options + .name as keyof typeof compilationCounts; + childCompiler.hooks.done.tap('test:count-builds', () => { + compilationCounts[platform]++; + }); + } compiler.start(); }); @@ -83,12 +93,19 @@ describe('Compiler – lazy compilation', () => { }); it('getAsset("main.js", "ios") produces ios stats but leaves android stats undefined', async () => { + // Change source after both watchers are gated, then let polling observe it. + await new Promise((resolve) => setTimeout(resolve, 100)); + fs.writeFileSync(entryPath, 'module.exports = { updated: true };'); + await new Promise((resolve) => setTimeout(resolve, 100)); const asset = await compiler.getAsset('main.js', 'ios'); + // Give polling time to trigger any stale-timestamp rebuild. + await new Promise((resolve) => setTimeout(resolve, 200)); expect(asset).toBeDefined(); expect(asset.data).toBeInstanceOf(Buffer); expect(compiler.statsCache.ios).toBeDefined(); expect(compiler.statsCache.android).toBeUndefined(); + expect(compilationCounts).toEqual({ ios: 1, android: 0 }); }); it('getAsset("main.js", "android") produces android stats independently of ios', async () => { @@ -97,14 +114,18 @@ describe('Compiler – lazy compilation', () => { expect(asset).toBeDefined(); expect(asset.data).toBeInstanceOf(Buffer); expect(compiler.statsCache.android).toBeDefined(); + expect(compilationCounts).toEqual({ ios: 1, android: 1 }); }); it('getAsset for an already-compiled platform resolves from cache without recompilation', async () => { // Both platforms are already compiled from previous tests + const countsBeforeRequest = { ...compilationCounts }; const asset = await compiler.getAsset('main.js', 'ios'); + await new Promise((resolve) => setTimeout(resolve, 100)); expect(asset).toBeDefined(); expect(asset.data).toBeInstanceOf(Buffer); + expect(compilationCounts).toEqual(countsBeforeRequest); }); }); From 71463477840d19f645e8abad3596ebcb8ff6186e Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Wed, 29 Jul 2026 23:43:27 +0200 Subject: [PATCH 10/19] fix: reject unsupported lazy compilation platforms --- packages/repack/src/commands/rspack/Compiler.ts | 3 +++ .../repack/src/commands/rspack/__tests__/Compiler.test.ts | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/packages/repack/src/commands/rspack/Compiler.ts b/packages/repack/src/commands/rspack/Compiler.ts index d70d7d4a7..b3928437b 100644 --- a/packages/repack/src/commands/rspack/Compiler.ts +++ b/packages/repack/src/commands/rspack/Compiler.ts @@ -258,6 +258,9 @@ export class Compiler { } private activatePlatform(platform: string) { + if (!this.platforms.includes(platform)) { + throw new CLIError(`Unrecognized platform: ${platform}`); + } if (this.activePlatforms.has(platform)) return; this.activePlatforms.add(platform); this.isCompilationInProgress[platform] = true; diff --git a/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts index 43b350e25..9803ea339 100644 --- a/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts +++ b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts @@ -92,6 +92,13 @@ describe('Compiler – lazy compilation', () => { }); }); + it('rejects unconfigured platforms', async () => { + await expect( + compiler.getAsset('main.js', 'windows') + ).rejects.toThrowError('Unrecognized platform: windows'); + expect(compilationCounts).toEqual({ ios: 0, android: 0 }); + }); + it('getAsset("main.js", "ios") produces ios stats but leaves android stats undefined', async () => { // Change source after both watchers are gated, then let polling observe it. await new Promise((resolve) => setTimeout(resolve, 100)); From ef152d2314b30639c74833791d568890a4efdac4 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Fri, 31 Jul 2026 14:50:56 +0200 Subject: [PATCH 11/19] fix: reject pending bundles during shutdown --- .../repack/src/commands/rspack/Compiler.ts | 5 +++++ .../rspack/__tests__/Compiler.test.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/repack/src/commands/rspack/Compiler.ts b/packages/repack/src/commands/rspack/Compiler.ts index b3928437b..1eec4183a 100644 --- a/packages/repack/src/commands/rspack/Compiler.ts +++ b/packages/repack/src/commands/rspack/Compiler.ts @@ -282,6 +282,11 @@ export class Compiler { } close(callback: (error?: Error | null) => void = () => {}) { + const error = new Error('Compiler closed before compilation completed'); + this.platforms.forEach((platform) => { + this.callPendingResolvers(platform, error); + }); + // Release all held gates so Watching instances can complete and close cleanly for (const [, gate] of this.watchRunGates) { gate(); diff --git a/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts index 9803ea339..8e8b4dece 100644 --- a/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts +++ b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts @@ -137,6 +137,25 @@ describe('Compiler – lazy compilation', () => { }); describe('close()', () => { + it('rejects pending asset requests', async () => { + const compiler = new Compiler(createConfigs(), reporter, tmpDir); + compiler.setDevServerContext(mockDevServerContext); + compiler.compiler.compilers[0].hooks.make.tapAsync( + 'test:hold-compilation', + (_compilation, done) => setTimeout(done, 100) + ); + compiler.start(); + + const assetRequest = expect( + compiler.getAsset('main.js', 'ios') + ).rejects.toThrow('Compiler closed before compilation completed'); + await new Promise((resolve, reject) => { + compiler.close((error) => (error ? reject(error) : resolve())); + }); + + await assetRequest; + }); + it('resolves when both platform gates are still held (no getAsset calls)', async () => { const compiler = new Compiler(createConfigs(), reporter, tmpDir); compiler.setDevServerContext(mockDevServerContext); From 61378cfcf0907b22c197dad6b5cf220579f60145 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Fri, 31 Jul 2026 14:53:25 +0200 Subject: [PATCH 12/19] fix: always stop dev server after compiler errors --- .../__tests__/lazy-compilation.test.ts | 21 +++++++++++++++++-- packages/repack/src/commands/rspack/start.ts | 11 ++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/tester-app/__tests__/lazy-compilation.test.ts b/apps/tester-app/__tests__/lazy-compilation.test.ts index c9f48f027..547f179b9 100644 --- a/apps/tester-app/__tests__/lazy-compilation.test.ts +++ b/apps/tester-app/__tests__/lazy-compilation.test.ts @@ -1,13 +1,14 @@ import fs from 'node:fs'; import path from 'node:path'; import rspackCommands from '@callstack/repack/commands/rspack'; +import { MultiCompiler } from '@rspack/core'; import getPort from 'get-port'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; const TMP_DIR = path.join(__dirname, 'out/lazy-compilation'); let port: number; -let stopServer: () => Promise; +let stopServer: (() => Promise) | undefined; describe('lazy compilation', () => { const startCommand = rspackCommands.find( @@ -40,7 +41,7 @@ describe('lazy compilation', () => { }); afterAll(async () => { - await stopServer(); + await stopServer?.(); }); it( @@ -102,4 +103,20 @@ describe('lazy compilation', () => { }, 60 * 1000 ); + + it('stops the dev server when compiler shutdown fails', async () => { + const close = MultiCompiler.prototype.close; + const closeError = new Error('close failed'); + MultiCompiler.prototype.close = function (callback) { + close.call(this, () => callback(closeError)); + }; + + try { + await expect(stopServer!()).rejects.toBe(closeError); + await expect(fetch(`http://localhost:${port}/status`)).rejects.toThrow(); + stopServer = undefined; + } finally { + MultiCompiler.prototype.close = close; + } + }); }); diff --git a/packages/repack/src/commands/rspack/start.ts b/packages/repack/src/commands/rspack/start.ts index 6345e3506..e34c79994 100644 --- a/packages/repack/src/commands/rspack/start.ts +++ b/packages/repack/src/commands/rspack/start.ts @@ -211,10 +211,13 @@ export async function start( return { stop: async () => { reporter.stop(); - await new Promise((resolve, reject) => { - compiler.close((error) => (error ? reject(error) : resolve())); - }); - await stop(); + try { + await new Promise((resolve, reject) => { + compiler.close((error) => (error ? reject(error) : resolve())); + }); + } finally { + await stop(); + } }, }; } From 341e05ec9dc73f0ccc79cbb74fab603c7612af6a Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Fri, 31 Jul 2026 14:54:02 +0200 Subject: [PATCH 13/19] chore: add lazy compilation changeset --- .changeset/lazy-platform-compilation.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lazy-platform-compilation.md diff --git a/.changeset/lazy-platform-compilation.md b/.changeset/lazy-platform-compilation.md new file mode 100644 index 000000000..6565cfab2 --- /dev/null +++ b/.changeset/lazy-platform-compilation.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack": minor +--- + +Compile each configured Rspack platform when its first bundle is requested instead of compiling every platform when the development server starts. From f73a1cceea72338132c6249586e77e03eead1410 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Fri, 31 Jul 2026 14:56:03 +0200 Subject: [PATCH 14/19] fix: reject bundles after shutdown starts --- packages/repack/src/commands/rspack/Compiler.ts | 6 ++++++ .../repack/src/commands/rspack/__tests__/Compiler.test.ts | 3 +++ 2 files changed, 9 insertions(+) diff --git a/packages/repack/src/commands/rspack/Compiler.ts b/packages/repack/src/commands/rspack/Compiler.ts index 1eec4183a..892039193 100644 --- a/packages/repack/src/commands/rspack/Compiler.ts +++ b/packages/repack/src/commands/rspack/Compiler.ts @@ -31,6 +31,7 @@ export class Compiler { private watchRunGates: Map void> = new Map(); private activePlatforms: Set = new Set(); private buildStartTime: Record = {}; + private isClosed = false; constructor( configs: MultiRspackOptions, @@ -282,6 +283,7 @@ export class Compiler { } close(callback: (error?: Error | null) => void = () => {}) { + this.isClosed = true; const error = new Error('Compiler closed before compilation completed'); this.platforms.forEach((platform) => { this.callPendingResolvers(platform, error); @@ -300,6 +302,10 @@ export class Compiler { platform: string, sendProgress?: SendProgress ): Promise { + if (this.isClosed) { + throw new Error('Compiler closed before compilation completed'); + } + // Activate compiler for this platform on first request this.activatePlatform(platform); diff --git a/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts index 8e8b4dece..0480c4f07 100644 --- a/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts +++ b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts @@ -154,6 +154,9 @@ describe('Compiler – lazy compilation', () => { }); await assetRequest; + await expect(compiler.getAsset('main.js', 'android')).rejects.toThrow( + 'Compiler closed before compilation completed' + ); }); it('resolves when both platform gates are still held (no getAsset calls)', async () => { From c176eeb1065d8d574899394cd64e91664cac1b70 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Fri, 31 Jul 2026 14:58:45 +0200 Subject: [PATCH 15/19] test: make server teardown explicit --- apps/tester-app/__tests__/lazy-compilation.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/tester-app/__tests__/lazy-compilation.test.ts b/apps/tester-app/__tests__/lazy-compilation.test.ts index 547f179b9..ae1a7a3f0 100644 --- a/apps/tester-app/__tests__/lazy-compilation.test.ts +++ b/apps/tester-app/__tests__/lazy-compilation.test.ts @@ -41,7 +41,9 @@ describe('lazy compilation', () => { }); afterAll(async () => { - await stopServer?.(); + if (stopServer) { + await stopServer(); + } }); it( @@ -105,6 +107,9 @@ describe('lazy compilation', () => { ); it('stops the dev server when compiler shutdown fails', async () => { + const stop = stopServer; + if (!stop) throw new Error('Dev server was not started'); + const close = MultiCompiler.prototype.close; const closeError = new Error('close failed'); MultiCompiler.prototype.close = function (callback) { @@ -112,7 +117,7 @@ describe('lazy compilation', () => { }; try { - await expect(stopServer!()).rejects.toBe(closeError); + await expect(stop()).rejects.toBe(closeError); await expect(fetch(`http://localhost:${port}/status`)).rejects.toThrow(); stopServer = undefined; } finally { From ed34985d617855fc837bb6378b6d6d1bdf88d02d Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Fri, 31 Jul 2026 15:11:51 +0200 Subject: [PATCH 16/19] docs: clarify lazy compilation changeset --- .changeset/lazy-platform-compilation.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.changeset/lazy-platform-compilation.md b/.changeset/lazy-platform-compilation.md index 6565cfab2..5da2d2068 100644 --- a/.changeset/lazy-platform-compilation.md +++ b/.changeset/lazy-platform-compilation.md @@ -2,4 +2,7 @@ "@callstack/repack": minor --- -Compile each configured Rspack platform when its first bundle is requested instead of compiling every platform when the development server starts. +Bring the Rspack development experience in line with Webpack by compiling each +platform only when its bundle is first requested. Multi-platform development +servers no longer eagerly build unused platforms, so launching an iOS app does +not wait for Android to compile, and vice versa. From e84526c36f089be725bf07bb07eff8ad385dfae8 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Fri, 31 Jul 2026 15:29:00 +0200 Subject: [PATCH 17/19] refactor: simplify Rspack hook setup --- packages/repack/src/commands/rspack/Compiler.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/repack/src/commands/rspack/Compiler.ts b/packages/repack/src/commands/rspack/Compiler.ts index 892039193..448324dd1 100644 --- a/packages/repack/src/commands/rspack/Compiler.ts +++ b/packages/repack/src/commands/rspack/Compiler.ts @@ -69,7 +69,9 @@ export class Compiler { // @ts-expect-error memfs is compatible enough this.compiler.outputFileSystem = this.filesystem; - this.setupCompilerHooks(); + for (const childCompiler of this.compiler.compilers) { + this.setupChildCompilerHooks(childCompiler); + } } get devServerOptions() { @@ -102,17 +104,9 @@ export class Compiler { this.devServerContext = ctx; } - private setupCompilerHooks() { - for (const childCompiler of this.compiler.compilers) { - const platform = childCompiler.options.name!; - this.setupChildCompilerHooks(platform, childCompiler); - } - } + private setupChildCompilerHooks(childCompiler: RspackCompiler) { + const platform = childCompiler.options.name!; - private setupChildCompilerHooks( - platform: string, - childCompiler: RspackCompiler - ) { // Gate: hold unrequested platforms at watchRun childCompiler.hooks.watchRun.tapAsync('repack:gate', (_compiler, done) => { if (this.activePlatforms.has(platform)) { From b7468347e91d00beabf848b801512fc9e309a6d1 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Fri, 31 Jul 2026 15:31:06 +0200 Subject: [PATCH 18/19] test: consolidate lazy compilation scenarios --- .../__tests__/lazy-compilation.test.ts | 71 +++++++------------ .../rspack/__tests__/Compiler.test.ts | 25 +++---- 2 files changed, 33 insertions(+), 63 deletions(-) diff --git a/apps/tester-app/__tests__/lazy-compilation.test.ts b/apps/tester-app/__tests__/lazy-compilation.test.ts index ae1a7a3f0..c7a513a33 100644 --- a/apps/tester-app/__tests__/lazy-compilation.test.ts +++ b/apps/tester-app/__tests__/lazy-compilation.test.ts @@ -16,6 +16,11 @@ describe('lazy compilation', () => { ); if (!startCommand) throw new Error('start command not found'); + const getStats = (platform: string) => + fetch(`http://localhost:${port}/api/${platform}/stats`).then((response) => + response.json() + ); + beforeAll(async () => { await fs.promises.rm(TMP_DIR, { recursive: true, force: true }); @@ -47,61 +52,35 @@ describe('lazy compilation', () => { }); it( - 'stats API returns null for both platforms before any bundle is requested', + 'compiles each platform when its bundle is first requested', async () => { - const iosStats = await fetch( - `http://localhost:${port}/api/ios/stats` - ).then((r) => r.json()); - const androidStats = await fetch( - `http://localhost:${port}/api/android/stats` - ).then((r) => r.json()); - - expect(iosStats.data).toBeNull(); - expect(androidStats.data).toBeNull(); - }, - 60 * 1000 - ); - - it( - 'GET /index.bundle?platform=ios produces ios stats but android stats remain null', - async () => { - const response = await fetch( + const [initialIosStats, initialAndroidStats] = await Promise.all([ + getStats('ios'), + getStats('android'), + ]); + expect(initialIosStats.data).toBeNull(); + expect(initialAndroidStats.data).toBeNull(); + + const iosResponse = await fetch( `http://localhost:${port}/index.bundle?platform=ios` ); - const body = await response.text(); - - expect(response.status).toBe(200); - expect(body.length).toBeGreaterThan(100000); - - const iosStats = await fetch( - `http://localhost:${port}/api/ios/stats` - ).then((r) => r.json()); - const androidStats = await fetch( - `http://localhost:${port}/api/android/stats` - ).then((r) => r.json()); + await iosResponse.text(); + expect(iosResponse.status).toBe(200); + const [iosStats, androidStats] = await Promise.all([ + getStats('ios'), + getStats('android'), + ]); expect(iosStats.data).not.toBeNull(); expect(androidStats.data).toBeNull(); - }, - 60 * 1000 - ); - it( - 'GET /index.bundle?platform=android produces android stats after ios is already compiled', - async () => { - const response = await fetch( + const androidResponse = await fetch( `http://localhost:${port}/index.bundle?platform=android` ); - const body = await response.text(); - - expect(response.status).toBe(200); - expect(body.length).toBeGreaterThan(100000); - - const androidStats = await fetch( - `http://localhost:${port}/api/android/stats` - ).then((r) => r.json()); - - expect(androidStats.data).not.toBeNull(); + await androidResponse.text(); + expect(androidResponse.status).toBe(200); + const finalAndroidStats = await getStats('android'); + expect(finalAndroidStats.data).not.toBeNull(); }, 60 * 1000 ); diff --git a/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts index 0480c4f07..33413c171 100644 --- a/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts +++ b/packages/repack/src/commands/rspack/__tests__/Compiler.test.ts @@ -99,40 +99,31 @@ describe('Compiler – lazy compilation', () => { expect(compilationCounts).toEqual({ ios: 0, android: 0 }); }); - it('getAsset("main.js", "ios") produces ios stats but leaves android stats undefined', async () => { + it('compiles each platform on demand and reuses cached assets', async () => { // Change source after both watchers are gated, then let polling observe it. await new Promise((resolve) => setTimeout(resolve, 100)); fs.writeFileSync(entryPath, 'module.exports = { updated: true };'); await new Promise((resolve) => setTimeout(resolve, 100)); - const asset = await compiler.getAsset('main.js', 'ios'); + const iosAsset = await compiler.getAsset('main.js', 'ios'); // Give polling time to trigger any stale-timestamp rebuild. await new Promise((resolve) => setTimeout(resolve, 200)); - expect(asset).toBeDefined(); - expect(asset.data).toBeInstanceOf(Buffer); + expect(iosAsset.data).toBeInstanceOf(Buffer); expect(compiler.statsCache.ios).toBeDefined(); expect(compiler.statsCache.android).toBeUndefined(); expect(compilationCounts).toEqual({ ios: 1, android: 0 }); - }); - it('getAsset("main.js", "android") produces android stats independently of ios', async () => { - const asset = await compiler.getAsset('main.js', 'android'); + const androidAsset = await compiler.getAsset('main.js', 'android'); - expect(asset).toBeDefined(); - expect(asset.data).toBeInstanceOf(Buffer); + expect(androidAsset.data).toBeInstanceOf(Buffer); expect(compiler.statsCache.android).toBeDefined(); expect(compilationCounts).toEqual({ ios: 1, android: 1 }); - }); - it('getAsset for an already-compiled platform resolves from cache without recompilation', async () => { - // Both platforms are already compiled from previous tests - const countsBeforeRequest = { ...compilationCounts }; - const asset = await compiler.getAsset('main.js', 'ios'); + const cachedAsset = await compiler.getAsset('main.js', 'ios'); await new Promise((resolve) => setTimeout(resolve, 100)); - expect(asset).toBeDefined(); - expect(asset.data).toBeInstanceOf(Buffer); - expect(compilationCounts).toEqual(countsBeforeRequest); + expect(cachedAsset.data).toBeInstanceOf(Buffer); + expect(compilationCounts).toEqual({ ios: 1, android: 1 }); }); }); From b5e84c552cb2f05dcd1f43b614b1165053afac9b Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Fri, 31 Jul 2026 15:31:40 +0200 Subject: [PATCH 19/19] chore: drop unrelated Podfile lock churn --- apps/tester-app/ios/Podfile.lock | 2 +- apps/tester-federation-v2/ios/Podfile.lock | 2 +- apps/tester-federation/ios/Podfile.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/tester-app/ios/Podfile.lock b/apps/tester-app/ios/Podfile.lock index 418dbfb39..0e2160858 100644 --- a/apps/tester-app/ios/Podfile.lock +++ b/apps/tester-app/ios/Podfile.lock @@ -2454,7 +2454,7 @@ SPEC CHECKSUMS: AsyncStorage: 163ab23f6aa37a58b7f2379d0b4ee7ac84d6655c callstack-repack: 2d0e744d37013ccc33451e4c797d5dd67eae6e56 FBLazyVector: b3e7ad108f0d882e30445c5527d774e3fd432f3d - hermes-engine: e43b2312391cd7fd1d3c54c63ccbcc69cf3b477e + hermes-engine: 399db9aa65675d12edcd906c1caab4a41e98f4e1 JWTDecode: 2eed97c2fa46ccaf3049a787004eedf0be474a87 RCTDeprecation: 2a74a2c57675e64419bd89078efde81f7c1de90b RCTRequired: 30451112e6fef4e6f31b4e7eee0845156e35e4b0 diff --git a/apps/tester-federation-v2/ios/Podfile.lock b/apps/tester-federation-v2/ios/Podfile.lock index 08ea18697..75fe285f1 100644 --- a/apps/tester-federation-v2/ios/Podfile.lock +++ b/apps/tester-federation-v2/ios/Podfile.lock @@ -2276,7 +2276,7 @@ SPEC CHECKSUMS: AsyncStorage: 163ab23f6aa37a58b7f2379d0b4ee7ac84d6655c callstack-repack: 2d0e744d37013ccc33451e4c797d5dd67eae6e56 FBLazyVector: b3e7ad108f0d882e30445c5527d774e3fd432f3d - hermes-engine: e43b2312391cd7fd1d3c54c63ccbcc69cf3b477e + hermes-engine: 399db9aa65675d12edcd906c1caab4a41e98f4e1 JWTDecode: 2eed97c2fa46ccaf3049a787004eedf0be474a87 RCTDeprecation: 2a74a2c57675e64419bd89078efde81f7c1de90b RCTRequired: 30451112e6fef4e6f31b4e7eee0845156e35e4b0 diff --git a/apps/tester-federation/ios/Podfile.lock b/apps/tester-federation/ios/Podfile.lock index 0315a69c1..a8d1006e7 100644 --- a/apps/tester-federation/ios/Podfile.lock +++ b/apps/tester-federation/ios/Podfile.lock @@ -2276,7 +2276,7 @@ SPEC CHECKSUMS: AsyncStorage: 163ab23f6aa37a58b7f2379d0b4ee7ac84d6655c callstack-repack: 2d0e744d37013ccc33451e4c797d5dd67eae6e56 FBLazyVector: b3e7ad108f0d882e30445c5527d774e3fd432f3d - hermes-engine: e43b2312391cd7fd1d3c54c63ccbcc69cf3b477e + hermes-engine: 399db9aa65675d12edcd906c1caab4a41e98f4e1 JWTDecode: 2eed97c2fa46ccaf3049a787004eedf0be474a87 RCTDeprecation: 2a74a2c57675e64419bd89078efde81f7c1de90b RCTRequired: 30451112e6fef4e6f31b4e7eee0845156e35e4b0