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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bundle-fail-on-compiler-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@callstack/repack": patch
---

A failed Hermes bytecode compilation now fails the build instead of exiting 0. `bundle` rejected inside the compiler's `close` callback, so compiler errors (such as a failing hermesc in `HermesBytecodePlugin`) became unhandled rejections; `LoggerPlugin`'s `uncaughtException` listener then logged them and the process exited 0, leaving plain JavaScript chunks in place. `bundle` now rejects (in both run and `--watch` mode) and `LoggerPlugin` sets `process.exitCode = 1` for uncaught exceptions.
5 changes: 5 additions & 0 deletions .changeset/hermes-bytecode-hermesc-lookup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@callstack/repack": patch
---

Fix `HermesBytecodePlugin` not finding `hermesc` under pnpm (and other symlinked or nested layouts) on React Native 0.82+. The existing locations are still checked first and resolve to the same path. Only when neither exists does the plugin follow the `react-native` symlink and search its `node_modules` hierarchy for `hermes-compiler`. The lookup now runs when the first matching asset is transformed. If no binary is found, the build fails with an error listing every checked path instead of spawning a missing binary.
57 changes: 57 additions & 0 deletions packages/repack/src/commands/__tests__/bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { type BundleCompiler, runCompiler } from '../bundle.js';

type Callback = (error: Error | null) => void;

function createCompilerMock(error: Error | null) {
return {
run: jest.fn((callback: Callback) => callback(error)),
watch: jest.fn((_: unknown, callback: Callback) => callback(error)),
close: jest.fn((callback: Callback) => callback(null)),
hooks: { watchClose: { tap: jest.fn() } },
options: { stats: undefined },
context: '/project',
} satisfies BundleCompiler;
}

// mirrors `bundle`'s handler, which throws a CLIError for compiler errors
const onResult = async (error: Error | null) => {
if (error) throw error;
};

describe('runCompiler', () => {
it('resolves after closing the compiler when the build succeeds', async () => {
const compiler = createCompilerMock(null);

await expect(runCompiler(compiler, { onResult })).resolves.toBeUndefined();
expect(compiler.close).toHaveBeenCalledTimes(1);
});

it('rejects when the build fails', async () => {
const compiler = createCompilerMock(new Error('hermesc failed'));

await expect(runCompiler(compiler, { onResult })).rejects.toThrow(
'hermesc failed'
);
expect(compiler.close).toHaveBeenCalledTimes(1);
});

it('rejects when a build fails in watch mode', async () => {
const compiler = createCompilerMock(new Error('hermesc failed'));

await expect(
runCompiler(compiler, { watch: true, onResult })
).rejects.toThrow('hermesc failed');
expect(compiler.run).not.toHaveBeenCalled();
});

it('resolves when watching stops', async () => {
const compiler = createCompilerMock(null);
compiler.hooks.watchClose.tap.mockImplementation(
(_: string, onClose: () => void) => onClose()
);

await expect(
runCompiler(compiler, { watch: true, onResult })
).resolves.toBeUndefined();
});
});
95 changes: 60 additions & 35 deletions packages/repack/src/commands/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type {
* Both rspack() and webpack() return objects satisfying this shape.
* Defined here because both bundlers are optional peer dependencies.
*/
interface BundleCompiler {
export interface BundleCompiler {
run(callback: (error: Error | null, stats?: BundleStats) => void): void;
watch(
options: Record<string, unknown>,
Expand All @@ -37,7 +37,7 @@ interface BundleCompiler {
* Minimal stats interface for the bundle command.
* Both rspack and webpack Stats objects satisfy this shape.
*/
interface BundleStats {
export interface BundleStats {
hasErrors(): boolean;
compilation?: { errors?: unknown[] };
toJson(options: unknown): Record<string, unknown>;
Expand Down Expand Up @@ -118,48 +118,73 @@ export async function bundle(
compiler = webpack(config) as BundleCompiler;
}

return new Promise<void>((resolve) => {
const errorHandler = async (error: Error | null, stats?: BundleStats) => {
if (error) {
throw new CLIError(error.message);
}
const errorHandler = async (error: Error | null, stats?: BundleStats) => {
if (error) {
throw new CLIError(error.message);
}

if (stats?.hasErrors()) {
stats.compilation?.errors?.forEach((e) => {
console.error(e);
});
process.exit(2);
}

if (args.json && stats !== undefined) {
const statsOptions = normalizeStatsOptions(
compiler.options.stats,
args.stats
);

if (stats?.hasErrors()) {
stats.compilation?.errors?.forEach((e) => {
console.error(e);
const statsJson = stats.toJson(statsOptions);

try {
await writeStats(statsJson, {
filepath: args.json,
rootDir: compiler.context,
});
process.exit(2);
} catch (e) {
throw new CLIError(String(e));
}
}
};

if (args.json && stats !== undefined) {
const statsOptions = normalizeStatsOptions(
compiler.options.stats,
args.stats
);

const statsJson = stats.toJson(statsOptions);

try {
await writeStats(statsJson, {
filepath: args.json,
rootDir: compiler.context,
});
} catch (e) {
throw new CLIError(String(e));
}
}
};
return runCompiler(compiler, {
watch: args.watch,
watchOptions: config.watchOptions,
onResult: errorHandler,
});
}

if (args.watch) {
/**
* Runs (or watches) the compiler. The returned promise settles with `onResult`
* of the run, so a failed build rejects and the CLI exits with a non-zero code.
* In watch mode it resolves when watching stops, or rejects on the first failure.
*/
export function runCompiler(
compiler: BundleCompiler,
{
watch,
watchOptions,
onResult,
}: {
watch?: boolean;
watchOptions?: Record<string, unknown>;
onResult: (error: Error | null, stats?: BundleStats) => Promise<void>;
}
) {
return new Promise<void>((resolve, reject) => {
if (watch) {
compiler.hooks.watchClose.tap('bundle', resolve);
compiler.watch(config.watchOptions ?? {}, errorHandler);
compiler.watch(watchOptions ?? {}, (error, stats) => {
onResult(error, stats).catch(reject);
});
} else {
compiler.run((error: Error | null, stats?: BundleStats) => {
compiler.run((error, stats) => {
// make cache work: https://webpack.js.org/api/node/#run
compiler.close(async (closeErr: Error | null) => {
compiler.close((closeErr) => {
if (closeErr) console.error(closeErr);
await errorHandler(error, stats);
resolve();
onResult(error, stats).then(resolve, reject);
});
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,19 @@ export class HermesBytecodePlugin {
this.config.reactNativePath ||
path.join(compiler.context, 'node_modules', 'react-native');

const hermesCLIPath =
this.config.hermesCLIPath || getHermesCLIPath(reactNativePath);
// Resolved on the first transformed asset, so that builds which emit no matching
// assets don't fail when hermesc can't be found.
let resolvedHermesCLIPath = this.config.hermesCLIPath;
const resolveHermesCLIPath = () => {
resolvedHermesCLIPath ||= getHermesCLIPath(reactNativePath, {
// `compiler.context/node_modules/react-native` may not exist, e.g. when
// react-native is hoisted to the monorepo root.
resolveReactNativeFrom: this.config.reactNativePath
? undefined
: compiler.context,
});
return resolvedHermesCLIPath;
};

const emittedAssets = new Map<string, string>();

Expand Down Expand Up @@ -136,6 +147,12 @@ export class HermesBytecodePlugin {
const assets = Array.from(emittedAssets);
emittedAssets.clear();

if (assets.length === 0) {
return;
}

const hermesCLIPath = resolveHermesCLIPath();

await Promise.all(
assets.map(async ([file, outputPath]) => {
const bundlePath = path.join(outputPath, file);
Expand Down
Loading
Loading