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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/rspack-2-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@callstack/repack": minor
---

Add Rspack 2 support. Re.Pack now works with both Rspack 1.x and 2.x from a single release - it detects the installed `@rspack/core` major and adjusts its behavior automatically. Rspack 1 and webpack setups are unaffected: nothing changes unless you upgrade.

To move a project to Rspack 2:

- Use Node.js `^20.19.0 || >=22.12.0` - required by Rspack 2 itself; Re.Pack raises a clear error on older versions instead of failing with `ERR_REQUIRE_ESM`
- Install `@rspack/plugin-react-refresh@^2` and `react-refresh` - under Rspack 2, development/HMR support is wired through the official React Refresh plugin
- Move any `experiments.cache` configuration to the top-level `cache` option - Rspack 2 ignores the legacy location, and Re.Pack warns when it detects it
18 changes: 18 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ jobs:
- name: Run tests against Rspack 1
run: pnpm turbo run test:rspack1

test_pr_rspack_compat:
name: Tests [Rspack compat smoke]
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2
- name: Setup workspace
uses: ./.github/actions/setup-pnpm-node
with:
node-version: '24'

# built-dist smoke tests: pack repack into a tarball, install it into
# standalone fixtures pinning each @rspack/core major, and assert the
# dual-major behavior matrix against the real dist
- name: Run dual-major smoke suite
run: pnpm --filter rspack-compat-test test:smoke

test_pr_windows:
name: Tests [Windows]
if: github.event_name == 'pull_request'
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ Re.Pack is a toolkit for building and developing React Native applications with
- `tests/integration/`: Integration-level automated coverage.
- `tests/metro-compat/`: Metro compatibility behavior coverage.
- `tests/resolver-cases/`: Resolver behavior and edge-case coverage.
- `tests/rspack-compat/`: Built-dist smoke coverage across both supported `@rspack/core` majors (standalone, tarball-installed fixtures).
2 changes: 2 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions tests/rspack-compat/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# per-run artifacts - the fixtures are standalone projects that get a fresh
# repack tarball installed on every run
.pack/
fixtures/*/node_modules/
fixtures/*/pnpm-lock.yaml
fixtures/*/callstack-repack.tgz
fixtures/*/dist/
73 changes: 73 additions & 0 deletions tests/rspack-compat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# rspack-compat

Built-dist smoke tests for `@callstack/repack` against both supported
`@rspack/core` majors (1.x and 2.x).

Unit tests exercise sources through the Jest/Babel pipeline; the failure
modes this suite covers — `require(esm)` interop, loader resolution, React
Refresh runtime source selection — only reproduce against the **built dist**
installed in a real project layout. The runner therefore always builds
`packages/repack`, packs it into a tarball, installs the tarball into each
fixture, and runs the assertions there.

## Running

```sh
pnpm --filter rspack-compat-test test:smoke # both fixtures
pnpm --filter rspack-compat-test test:smoke rspack-2 # a single fixture
```

The suite performs network installs and full repack builds, so it is
deliberately **not** part of the default `test` pipeline — CI runs it in a
dedicated lane.

## What is asserted (per major)

1. `getRepackConfig` routing — `experiments.parallelLoader` kept under
Rspack 1 / dropped under Rspack 2; `exportsPresence: 'auto'` parser
override under Rspack 2 only.
2. Legacy `experiments.cache` handling, composed with the `isRspack2` gate
the commands use — the gate reads the fixture's installed major, Rspack 1
sees no warning, Rspack 2 warns exactly once under the warn-only policy,
the config is never mutated, and the cache accessor reads the legacy
location.
3. `ensureNodeSupportsRspack` passes on a supported Node version.
4. The compiled command modules (`dist/commands/rspack/{bundle,start,Compiler}.js`)
load from the tarball-installed package — the eager
`require('@rspack/core')` they compile to (`require(esm)` under
Rspack 2), the `rspack` named export they call, and the lazy
`@callstack/repack/commands/rspack` entry.
5. A full dev build through `DevelopmentPlugin` — HMR client and React
Refresh wiring present in the bundle, with the refresh runtime coming
from the correct source per major: the vendored client files
(`packages/repack/vendor/react-refresh/`) under Rspack 1 with zero
official-plugin references, the official `@rspack/plugin-react-refresh`
under Rspack 2 with zero vendored references.

## Why the fixtures are standalone (tarball install)

Inside the pnpm workspace, repack is linked as a symlink, so its own
`@rspack/core@^2` devDependency shadows any fixture's v1 pin — bare
`import '@rspack/core'` from `packages/repack/dist` resolves repack's copy
before the project's. An in-workspace v1 fixture would therefore silently
test v2 twice. Published installs don't have this problem (`@rspack/core`
is a peer dependency there), and shipped code deliberately carries no
monorepo-aware resolution workarounds, so the fixtures replicate the
published layout instead: each one is its own pnpm workspace root
(`pnpm-workspace.yaml` with `packages: []`) with its own `node_modules`,
installing repack from a packed tarball — the same pattern as
`apps/tester-app-rspack1`.

`smoke.cjs` enforces this as a **lane guard** before running any behavior
assertions: the fixture's installed `@rspack/core` major must match the
major declared in its manifest, the `@rspack/core` that *repack itself*
resolves must be that same copy, and the resolved repack package must live
inside the fixture's `node_modules` (never a workspace link back to
`packages/repack`). Any violation aborts the run with a non-zero exit.

`react-native` is stubbed (`fixtures/shared/rn-stub`) with just the modules
Re.Pack's development runtime imports — real RN sources need the full
flow/babel loader chain, which is out of scope for wiring-level smoke tests.

See `agent_context/rspackv2-jul2026/design.md` for the overall dual-major
support design.
11 changes: 11 additions & 0 deletions tests/rspack-compat/fixtures/rspack-1/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "rspack-compat-fixture-rspack-1",
"version": "0.0.1",
"description": "Rspack 1 smoke fixture - installs @callstack/repack from a packed tarball like a real user project",
"private": true,
"devDependencies": {
"@callstack/repack": "file:./callstack-repack.tgz",
"@rspack/core": "^1.7.12",
"react-native": "file:../shared/rn-stub"
}
}
4 changes: 4 additions & 0 deletions tests/rspack-compat/fixtures/rspack-1/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Marks this directory as its own workspace root, so pnpm treats it as a
# standalone project instead of a member of the repository workspace.
# See ../../README.md for why the fixtures must stay outside the workspace.
packages: []
3 changes: 3 additions & 0 deletions tests/rspack-compat/fixtures/rspack-1/src/App.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function App() {
return null;
}
14 changes: 14 additions & 0 deletions tests/rspack-compat/fixtures/rspack-2/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "rspack-compat-fixture-rspack-2",
"version": "0.0.1",
"description": "Rspack 2 smoke fixture - installs @callstack/repack from a packed tarball like a real user project",
"private": true,
"devDependencies": {
"@callstack/repack": "file:./callstack-repack.tgz",
"@rspack/core": "^2.1.2",
"@rspack/plugin-react-refresh": "^2.0.2",
"@swc/helpers": "^0.5.23",
"react-native": "file:../shared/rn-stub",
"react-refresh": "^0.18.0"
}
}
4 changes: 4 additions & 0 deletions tests/rspack-compat/fixtures/rspack-2/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Marks this directory as its own workspace root, so pnpm treats it as a
# standalone project instead of a member of the repository workspace.
# See ../../README.md for why the fixtures must stay outside the workspace.
packages: []

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

codex review: These standalone fixture workspaces do not carry over the repo’s autoInstallPeers: false policy, so generated fixture installs default to autoInstallPeers: true. That makes the smoke less strict than this repo’s package-manager policy and can mask accidental imports of optional peers: in my run, Re.Pack resolved auto-installed peers like webpack / @babel/core from the fixture virtual store even though the fixture manifests do not declare them. The current smoke still passes if I reinstall with --config.auto-install-peers=false, so please set that policy in both fixture pnpm-workspace.yaml files or pass it during install to keep future regressions from being hidden.

3 changes: 3 additions & 0 deletions tests/rspack-compat/fixtures/rspack-2/src/App.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function App() {
return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
hide() {},
showMessage() {},
dismissRedbox() {},
reportException() {},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
hide() {},
showMessage() {},
dismissRedbox() {},
reportException() {},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
hide() {},
showMessage() {},
dismissRedbox() {},
reportException() {},
};
2 changes: 2 additions & 0 deletions tests/rspack-compat/fixtures/shared/rn-stub/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const DevSettings = { reload() {} };
export const LogBox = { clearAllLogs() {} };
7 changes: 7 additions & 0 deletions tests/rspack-compat/fixtures/shared/rn-stub/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "react-native",
"version": "0.84.1",
"description": "Stub of react-native for the rspack-compat smoke fixtures - covers the modules imported by Re.Pack's development runtime (real RN sources need the full flow/babel loader chain, which is out of scope for wiring-level smoke tests)",
"private": true,
"main": "index.js"
}
13 changes: 13 additions & 0 deletions tests/rspack-compat/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "rspack-compat-test",
"version": "0.0.1",
"description": "Built-dist smoke tests for @callstack/repack against both @rspack/core majors",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"test:smoke": "node run.mjs"
}
}
101 changes: 101 additions & 0 deletions tests/rspack-compat/run.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Drives the dual-major smoke fixtures: builds @callstack/repack, packs it
// into a tarball, installs that tarball into each standalone fixture and
// runs smoke.cjs against the result.
//
// Usage: node run.mjs [fixture...] (default: all fixtures)
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const suiteDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(suiteDir, '../..');
const repackDir = path.join(repoRoot, 'packages/repack');
const packDir = path.join(suiteDir, '.pack');

const allFixtures = ['rspack-1', 'rspack-2'];
const requested = process.argv.slice(2);
const fixtures = requested.length > 0 ? requested : allFixtures;

for (const fixture of fixtures) {
if (!allFixtures.includes(fixture)) {
console.error(
`Unknown fixture '${fixture}' - expected one of: ${allFixtures.join(', ')}`
);
process.exit(1);
}
}

function run(command, args, options = {}) {
console.log(`\n$ ${command} ${args.join(' ')}`);
execFileSync(command, args, { stdio: 'inherit', ...options });
}

// The lane guard in smoke.cjs verifies the *installed* major against the
// fixture's declared intent - derive that intent from the fixture manifest
// so the two can never drift apart silently.
function expectedMajorOf(fixtureDir) {
const manifest = JSON.parse(
fs.readFileSync(path.join(fixtureDir, 'package.json'), 'utf8')
);
const range = manifest.devDependencies['@rspack/core'];
const major = Number.parseInt(range.replace(/^\D*/, ''), 10);
if (!Number.isInteger(major)) {
throw new Error(
`Cannot derive the expected @rspack/core major from range '${range}'`
);
}
return major;
}

// 1. Build and pack repack. The suite exists to test the *built dist* - the
// failure modes it covers (require(esm) interop, loader resolution, refresh
// runtime source selection) don't reproduce through the Jest/Babel pipeline,
// so it must never run against stale sources. Built through turbo so that
// workspace dependencies (repack-dev-server) are built first.
run('pnpm', ['turbo', 'run', 'build', '--filter', '@callstack/repack'], {
cwd: repoRoot,
});
fs.rmSync(packDir, { recursive: true, force: true });
fs.mkdirSync(packDir, { recursive: true });
run('pnpm', ['pack', '--pack-destination', packDir], { cwd: repackDir });

const tarball = fs.readdirSync(packDir).find((file) => file.endsWith('.tgz'));
if (!tarball) {
throw new Error('pnpm pack produced no tarball');
}

// 2. Install the tarball into each fixture and run the assertions.
const failed = [];

for (const fixture of fixtures) {
const fixtureDir = path.join(suiteDir, 'fixtures', fixture);
console.log(`\n=== fixture: ${fixture} ===`);

fs.copyFileSync(
path.join(packDir, tarball),
path.join(fixtureDir, 'callstack-repack.tgz')
);

try {
run('pnpm', ['install', '--no-frozen-lockfile'], { cwd: fixtureDir });

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

codex review: This runner shells out to the first pnpm on PATH, so local runs are not guaranteed to use the repository package manager version. In this worktree, running the documented command through corepack pnpm 10.32.1 still made these child installs run with global pnpm 11.7.0; under that pnpm, replacing a same-name/same-version file:./pkg.tgz and rerunning pnpm install --no-frozen-lockfile left the old installed package in place. Because this suite copies a fresh tarball to the same callstack-repack.tgz path on every run, the stale-tarball false positive can still happen outside CI. Please invoke the same package manager executable that launched the script, force reinstall/delete fixture install state, or make the tarball reference content-unique per run.

run('node', [path.join(suiteDir, 'smoke.cjs')], {
cwd: fixtureDir,
env: {
...process.env,
RSPACK_COMPAT_EXPECTED_MAJOR: String(expectedMajorOf(fixtureDir)),
},
});
console.log(`=== fixture ${fixture}: PASS ===`);
} catch {
failed.push(fixture);
console.error(`=== fixture ${fixture}: FAIL ===`);
}
}

if (failed.length > 0) {
console.error(`\nSmoke suite failed for: ${failed.join(', ')}`);
process.exit(1);
}

console.log(`\nSmoke suite passed for: ${fixtures.join(', ')}`);
Loading
Loading