diff --git a/.github/workflows/e2e-expo.yml b/.github/workflows/e2e-expo.yml
new file mode 100644
index 000000000..14dd815d4
--- /dev/null
+++ b/.github/workflows/e2e-expo.yml
@@ -0,0 +1,270 @@
+name: e2e (Expo)
+
+on:
+ pull_request:
+ paths-ignore:
+ - 'website/**'
+ - '**/*.md'
+ push:
+ branches: [main]
+ paths-ignore:
+ - 'website/**'
+ - '**/*.md'
+ workflow_dispatch:
+
+# one run per PR; a new push cancels the older one
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name != 'push' }}
+
+# keep it read-only
+permissions:
+ contents: read
+
+env:
+ E2E_TELEMETRY_DISABLED: '1'
+
+jobs:
+ android:
+ name: e2e-expo (Android)
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2
+
+ - name: Setup workspace
+ uses: ./.github/actions/setup-pnpm-node
+ with:
+ node-version: '24.22.1'
+
+ - name: Setup Java
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # tag=v6.0.1
+ with:
+ distribution: temurin
+ java-version: '17'
+
+ - name: Enable KVM
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+
+ - name: Build workspace packages
+ run: pnpm --filter tester-expo... --filter tester-expo-widget... --filter tester-federation-v2... build
+
+ # android/ is generated, never committed
+ - name: Generate native project
+ run: pnpm --filter tester-expo exec expo prebuild --platform android --no-install
+
+ # Gradle dependencies come from the Actions cache, not Maven Central (it sometimes answers 404
+ # for artifacts that exist). After prebuild: the key hashes files in android/
+ - name: Restore Gradle cache
+ id: gradle-cache
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # tag=v6.1.0
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: gradle-expo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'apps/tester-expo/android/**/*.gradle', 'apps/tester-expo/android/gradle.properties', 'apps/tester-expo/android/gradle/wrapper/gradle-wrapper.properties') }}
+ restore-keys: gradle-expo-${{ runner.os }}-
+
+ # nohup/disown: the runner kills step processes at the end
+ - name: Start dev servers
+ run: |
+ nohup pnpm --filter tester-expo repack:start > /tmp/host.log 2>&1 &
+ disown
+ nohup pnpm --filter tester-expo-widget repack:start:remote > /tmp/expo-widget.log 2>&1 &
+ disown
+ nohup pnpm --filter tester-federation-v2 start:expo-host-widget > /tmp/ordinary-widget.log 2>&1 &
+ disown
+ timeout 180 sh -c 'until [ "$(curl -sf localhost:8081/status)" = "packager-status:running" ] && [ "$(curl -sf localhost:8082/status)" = "packager-status:running" ] && [ "$(curl -sf localhost:8083/status)" = "packager-status:running" ]; do sleep 2; done'
+
+ # ask for the bundles now so the servers compile them — they only build on first request
+ - name: Warm all compilers
+ run: |
+ curl -sf --max-time 300 -o /dev/null 'http://localhost:8081/index.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.callstack.repackexpo&modulesOnly=false&runModule=true'
+ curl -sf --max-time 300 -o /dev/null 'http://localhost:8082/android/mf-manifest.json'
+ curl -sf --max-time 300 -o /dev/null 'http://localhost:8083/android/mf-manifest.json'
+ echo 'All three served; compilers are warm.'
+
+ - name: Check shared versions are real semver
+ run: |
+ for p in 8081 8082; do
+ M=$(curl -sf --retry 3 --retry-delay 2 "http://localhost:$p/android/mf-manifest.json") || { echo "::error::port $p: mf-manifest.json not served"; exit 1; }
+ [ "$(jq '.shared | length' <<<"$M")" -gt 0 ] || { echo "::error::port $p: manifest has no shared entries"; exit 1; }
+ BAD=$(jq -r '.shared[] | select((.version // "") | test("^[0-9]+\\.[0-9]+\\.[0-9]+") | not) | "\(.name): \(.version)"' <<<"$M")
+ [ -z "$BAD" ] || { echo "::error::port $p has non-semver shared versions: $BAD"; exit 1; }
+ done
+
+ - name: Build Android app
+ working-directory: apps/tester-expo/android
+ run: ./gradlew :app:assembleDebug -PreactNativeArchitectures=x86_64
+
+ # stop the daemon and drop lock files so the saved Gradle User Home is consistent
+ - name: Stop Gradle before caching
+ if: github.ref == 'refs/heads/main' && steps.gradle-cache.outputs.cache-hit != 'true'
+ working-directory: apps/tester-expo/android
+ run: |
+ ./gradlew --stop
+ rm -f ~/.gradle/caches/modules-2/modules-2.lock ~/.gradle/caches/modules-2/gc.properties
+
+ - name: Save Gradle cache
+ if: github.ref == 'refs/heads/main' && steps.gradle-cache.outputs.cache-hit != 'true'
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # tag=v6.1.0
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ steps.gradle-cache.outputs.cache-primary-key }}
+
+ # expose dev servers to the emulator, install the APK, run the tests
+ - name: Run e2e on an emulator
+ uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # tag=v2.38.0
+ with:
+ api-level: 35
+ target: google_apis
+ arch: x86_64
+ profile: pixel_7
+ avd-name: e2e
+ disable-animations: false
+ working-directory: apps/tester-expo
+ script: |
+ adb shell settings put global hide_error_dialogs 1
+ for i in 1 2 3; do adb shell "uiautomator dump /sdcard/d.xml >/dev/null && grep -q aerr_ /sdcard/d.xml" && { echo 'ANR dialog on screen, clearing it'; adb shell am force-stop com.google.android.apps.nexuslauncher; }; sleep 2; done
+ adb reverse tcp:8081 tcp:8081
+ adb reverse tcp:8082 tcp:8082
+ adb reverse tcp:8083 tcp:8083
+ adb reverse --list
+ adb reverse --list | grep -q 'tcp:8081' || { echo '::error::adb reverse missing for tcp:8081'; exit 1; }
+ adb reverse --list | grep -q 'tcp:8082' || { echo '::error::adb reverse missing for tcp:8082'; exit 1; }
+ adb reverse --list | grep -q 'tcp:8083' || { echo '::error::adb reverse missing for tcp:8083'; exit 1; }
+ adb install -r -g android/app/build/outputs/apk/debug/app-debug.apk || { echo '::error::adb install failed'; exit 1; }
+ E2E_DEVICE="${ANDROID_SERIAL:-$(adb get-serialno)}" pnpm exec e2e run --target android --reporter list,junit; ec=$?; adb logcat -d -v time > .e2e/logcat.txt 2>&1; exit $ec
+
+ - name: Collect dev server logs
+ if: ${{ !cancelled() }}
+ working-directory: apps/tester-expo
+ run: |
+ mkdir -p .e2e
+ cp /tmp/host.log /tmp/expo-widget.log /tmp/ordinary-widget.log .e2e/
+
+ - name: Upload e2e artifacts
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # tag=v7.0.1
+ with:
+ name: e2e-expo-android
+ path: apps/tester-expo/.e2e
+ include-hidden-files: true
+ if-no-files-found: warn
+ retention-days: 7
+
+ - name: Show dev server logs
+ if: ${{ !cancelled() }}
+ run: tail -n 80 /tmp/host.log /tmp/expo-widget.log /tmp/ordinary-widget.log
+
+ # main + dispatch only
+ ios:
+ name: e2e-expo (iOS)
+ if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
+ runs-on: macos-15
+ timeout-minutes: 45
+ # expo-modules-jsi needs Swift 6.2; the image's default Xcode 16.4 has 6.1
+ env:
+ DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2
+
+ - name: Setup workspace
+ uses: ./.github/actions/setup-pnpm-node
+ with:
+ node-version: '24.22.1'
+
+ - name: Build workspace packages
+ run: pnpm --filter tester-expo... --filter tester-expo-widget... --filter tester-federation-v2... build
+
+ - name: Start dev servers
+ run: |
+ nohup pnpm --filter tester-expo repack:start > /tmp/host.log 2>&1 &
+ disown
+ nohup pnpm --filter tester-expo-widget repack:start:remote > /tmp/expo-widget.log 2>&1 &
+ disown
+ nohup pnpm --filter tester-federation-v2 start:expo-host-widget > /tmp/ordinary-widget.log 2>&1 &
+ disown
+ for p in 8081 8082 8083; do curl -sf --retry 90 --retry-delay 2 --retry-all-errors --retry-connrefused -o /dev/null "http://localhost:$p/status"; done
+
+ - name: Warm all compilers
+ run: |
+ curl -sf --max-time 300 -o /dev/null 'http://localhost:8081/index.bundle?platform=ios&dev=true&lazy=true&minify=false&app=com.callstack.repackexpo&modulesOnly=false&runModule=true'
+ curl -sf --max-time 300 -o /dev/null 'http://localhost:8082/ios/mf-manifest.json'
+ curl -sf --max-time 300 -o /dev/null 'http://localhost:8083/ios/mf-manifest.json'
+ echo 'All three served; compilers are warm.'
+
+ - name: Check shared versions are real semver
+ run: |
+ for p in 8081 8082; do
+ M=$(curl -sf --retry 3 --retry-delay 2 "http://localhost:$p/ios/mf-manifest.json") || { echo "::error::port $p: mf-manifest.json not served"; exit 1; }
+ [ "$(jq '.shared | length' <<<"$M")" -gt 0 ] || { echo "::error::port $p: manifest has no shared entries"; exit 1; }
+ BAD=$(jq -r '.shared[] | select((.version // "") | test("^[0-9]+\\.[0-9]+\\.[0-9]+") | not) | "\(.name): \(.version)"' <<<"$M")
+ [ -z "$BAD" ] || { echo "::error::port $p has non-semver shared versions: $BAD"; exit 1; }
+ done
+
+ # SIM_RUNTIME_PREFIX is tied to the Xcode picked in DEVELOPER_DIR
+ - name: Boot simulator
+ env:
+ SIM_RUNTIME_PREFIX: com.apple.CoreSimulator.SimRuntime.iOS-26-
+ SIM_DEVICE: iPhone 17 Pro
+ run: |
+ RT=$(xcrun simctl list runtimes -j | jq -r --arg prefix "$SIM_RUNTIME_PREFIX" '.runtimes | map(select(.isAvailable and (.identifier | startswith($prefix)))) | sort_by(.version) | last | .identifier')
+ echo "using $RT"
+ [ "$RT" != "null" ] && [ -n "$RT" ]
+ xcrun simctl create e2e "$SIM_DEVICE" "$RT"
+ xcrun simctl boot e2e
+ xcrun simctl bootstatus e2e -b
+
+ # ios/ is generated, never committed
+ - name: Generate native project
+ run: pnpm --filter tester-expo exec expo prebuild --platform ios --no-install
+
+ # CocoaPods needs a UTF-8 locale
+ - name: Build iOS app
+ working-directory: apps/tester-expo/ios
+ env:
+ LANG: en_US.UTF-8
+ LC_ALL: en_US.UTF-8
+ run: pod install && xcodebuild -workspace RepackExpoTester.xcworkspace -scheme RepackExpoTester -configuration Debug -destination 'generic/platform=iOS Simulator' -derivedDataPath build CODE_SIGNING_ALLOWED=NO ARCHS=arm64 build
+
+ - name: Run e2e
+ working-directory: apps/tester-expo
+ env:
+ E2E_DEVICE: e2e
+ run: |
+ xcrun simctl install booted ios/build/Build/Products/Debug-iphonesimulator/*.app
+ pnpm exec e2e run --target ios --reporter list,junit
+
+ - name: Collect dev server logs
+ if: ${{ !cancelled() }}
+ working-directory: apps/tester-expo
+ run: |
+ mkdir -p .e2e
+ cp /tmp/host.log /tmp/expo-widget.log /tmp/ordinary-widget.log .e2e/
+ xcrun simctl spawn booted log show --style compact --last 30m \
+ --predicate 'process == "RepackExpoTester"' \
+ > .e2e/ios-syslog.txt 2>&1 || true
+
+ - name: Upload e2e artifacts
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # tag=v7.0.1
+ with:
+ name: e2e-expo-ios
+ path: apps/tester-expo/.e2e
+ include-hidden-files: true
+ if-no-files-found: warn
+ retention-days: 7
+
+ - name: Show dev server logs
+ if: ${{ !cancelled() }}
+ run: tail -n 80 /tmp/host.log /tmp/expo-widget.log /tmp/ordinary-widget.log
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 85561c188..49d17917b 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -2,18 +2,14 @@ name: e2e
on:
pull_request:
- paths:
- - 'apps/tester-federation-v2/**'
- - 'packages/**'
- - 'pnpm-lock.yaml'
- - '.github/workflows/e2e.yml'
+ paths-ignore:
+ - 'website/**'
+ - '**/*.md'
push:
branches: [main]
- paths:
- - 'apps/tester-federation-v2/**'
- - 'packages/**'
- - 'pnpm-lock.yaml'
- - '.github/workflows/e2e.yml'
+ paths-ignore:
+ - 'website/**'
+ - '**/*.md'
workflow_dispatch:
# one run per PR; a new push cancels the older one
@@ -129,6 +125,7 @@ jobs:
arch: x86_64
profile: pixel_7
avd-name: e2e
+ disable-animations: false
working-directory: apps/tester-federation-v2
script: |
adb shell settings put global hide_error_dialogs 1
diff --git a/agent_context/README.md b/agent_context/README.md
index 35109711a..73f7e5bee 100644
--- a/agent_context/README.md
+++ b/agent_context/README.md
@@ -9,3 +9,4 @@ works, and is kept in sync with the implementation as it evolves.
| Folder | Topic |
| --- | --- |
| [rspackv2-jul2026](./rspackv2-jul2026/design.md) | Dual Rspack 1.x/2.x support |
+| [e2e-oct2026](./e2e-oct2026/design.md) | e2e tests on an emulator, on CI |
diff --git a/agent_context/e2e-oct2026/design.md b/agent_context/e2e-oct2026/design.md
new file mode 100644
index 000000000..0977a5554
--- /dev/null
+++ b/agent_context/e2e-oct2026/design.md
@@ -0,0 +1,410 @@
+# e2e tests on an emulator
+
+End-to-end tests that run the Re.Pack tester apps on a real emulator or
+simulator, so a Re.Pack bundling or runtime bug is caught before merge instead
+of inside a consumer app. What the current form optimizes for: a red run means
+a real problem rather than a flake. That goal shows up below as concrete rules,
+not intentions.
+
+Two lanes, one app each. They share the tooling, the test conventions and the
+trigger rule; the rest is per lane.
+
+| Lane | App | Workflow |
+| --- | --- | --- |
+| [MF2](#mf2-lane) | `apps/tester-federation-v2` | `.github/workflows/e2e.yml` |
+| [Expo](#expo-lane) | `apps/tester-expo` | `.github/workflows/e2e-expo.yml` |
+
+Both lanes use ports 8081 and 8082, so they never run on one machine at the
+same time.
+
+Tooling: [`tester-army/e2e`](https://e2e.tester.army/docs) with
+`@e2e-dev/mobile`, built on
+[agent-device](https://github.com/callstack/agent-device). Chosen because
+deterministic tests need no model credential, which is what lets a fork PR run
+the suite at all. Both packages are pinned exact — pre-1.0, APIs move between
+minors. Both are pinned without a caret in the `testers` catalog of
+`pnpm-workspace.yaml`, so the app manifest alone does not show the pin.
+
+## Shared by both lanes
+
+### Test conventions
+
+- **Screens report values, tests hold the expectation.** `` renders `mode: Inlined` on one `Text` that carries the
+ testID; the test compares it against what the build should have produced.
+ Never print `ok`, and never hardcode a label the feature could contradict.
+- **No global-counter asserts.** Any background load bumps a global counter, so
+ `≥ 1` passes whatever the feature under test does. Counters live only on the
+ Runtime Events screen, where they are the subject; elsewhere the proof of a
+ load is the loaded content appearing.
+- **No branching on app state inside tests.** A loop over a fixed list of named
+ rows (the event reports in `runtime-events`) is fine; an `if` on what the app
+ currently shows is not — it hides which branch actually ran.
+- **Timeouts: defaults first.** Test 120 s, assertion 5 s, `scrollUntilVisible`
+ 30 s. An extension needs a named physical cause and belongs at the assert — a
+ test-level number hides which step stalled. The overrides:
+ **30 s on the boot anchor (`home-screen` after `app.open()`) in every file** —
+ `app.open()` returns when the window draws, not when the first frame renders;
+ after that the app still downloads and evaluates the whole dev bundle, which
+ on a cold process takes over 10 s on the CI Android emulator (an emulated
+ device sharing a small runner with the dev servers) and only a few seconds on
+ the iOS simulator, which runs natively on the host CPU. A test that follows a
+ warm run can pass at 5 s, so the default hides the problem until a run starts
+ cold; 30 s covers the cold boot at twice the measured worst case. 45 s on
+ `miniapp-gallery` in `gallery` and `runtime-events`, and in the Expo lane on
+ `expo-widget-ready` and `ordinary-repack-widget-ready` (each file pays the
+ manifest + container + chunk chain itself); 30 s on `pipeline: settled` (the
+ load chain's own ceiling is `Script.DEFAULT_TIMEOUT`). Note rspack compiles
+ the whole graph in one run: lazy chunks are memory files the moment the main
+ bundle is served, so chunk asserts are network + execute, never "compile
+ waits".
+- **testIDs:** `home--button`, `-screen`,
+ `--report`, `--button`.
+- **Assert app state, never the absence of a warning.**
+- **Break each test once and watch it go red.** Say so in the PR. A test nobody
+ saw fail proves nothing.
+
+### Triggers
+
+Android gates `pull_request`; iOS runs on `push` to `main` and
+`workflow_dispatch`. Plain `pull_request` rather than `pull_request_target`: the
+job reads no secrets, needs only `contents: read`, and the default merge-ref
+checkout means a PR tests its own workflow file. The gate is GitHub's own fork
+approval, which is per push. Triggers skip a run only when every changed file
+is `website/**` or `**/*.md` (`paths-ignore`) — never an allow-list of folders
+that "can" affect the app: root files like `pnpm-workspace.yaml` (`allowBuilds`),
+root `package.json` (`packageManager`), `tsconfig.base.json` and
+`.github/actions/setup-pnpm-node` change what the app runs without touching the
+lockfile, and an allow-list silently skips them.
+Fork runs get a read-only cache token from the platform, so they can restore
+`main`'s caches but not poison them.
+
+---
+
+## MF2 lane
+
+### Where things live
+
+| Path | Role |
+| --- | --- |
+| `apps/tester-federation-v2/e2e/*.e2e.ts` | One file per feature |
+| `apps/tester-federation-v2/e2e.config.ts` | Targets, `retries: 0`, `workers: 1` |
+| `apps/tester-federation-v2/src/host/FEATURES.ts` | Registry Home renders; one entry = one button. Routes stay hand-listed in `MainNavigator.tsx`; `screen` is typed against them |
+| `apps/tester-federation-v2/src/host/ui/` | `Report.tsx` (the one widget screens use to report a value), `ActionButton.tsx` (the button screens share) |
+| `apps/tester-federation-v2/src/host/setup.ts` | The chunk resolver; registers the event counters |
+| `apps/tester-federation-v2/src/host/features/runtime/events.ts` | The event counters the tests read |
+| `.github/workflows/e2e.yml` | Android job (PRs) + iOS job (main, dispatch) |
+| `apps/tester-federation-v2/config.expo-host-widget.mts` | Not part of this lane: builds `OrdinaryWidget` (port 8083) for the Expo lane |
+
+`e2e`'s default glob is `tests/**/*.e2e.ts`; this app keeps specs under `e2e/`,
+so the config sets `tests` explicitly. Output goes to `.e2e/`, gitignored.
+
+#### Running it locally
+
+No orchestration script by design — the framework and the developer each already
+own booting devices and building apps. Three terminals:
+
+```bash
+# 1. boot an emulator/simulator first — webpack-start only reverses ports for
+# devices attached at its start
+pnpm start:hostapp # 8081
+pnpm start:miniapp # 8082
+E2E_DEVICE= pnpm exec e2e run --target ios
+```
+
+`e2e run` without `--target` runs both targets; a file path narrows to one test.
+Pin `E2E_DEVICE` — unset, the engine takes every booted device of the platform.
+Needs Node `^22.22.3 || >=24.8.0` (CI pins 24.22.1). The app must be installed
+(`pnpm android` / `pnpm ios`).
+
+**Warm the platform bundle before the first run**, on a fresh server:
+`curl 'localhost:8081/index.bundle?platform=android&dev=true&lazy=true&…'` (the
+iOS job's shape is in the workflow). A cold first launch compiles before it can
+boot, that compile eats the `home-screen` anchor's budget, and the failure looks
+like a broken app. This bites locally and in CI equally.
+
+### How the app gets under test
+
+The app is a Debug build that loads its JavaScript from the two dev servers —
+host on 8081, MiniApp remote on 8082 — not from an embedded bundle. `app.json`
+points `resources.android` at a directory this app never emits, so there is no
+fallback bundle. Three consequences are load-bearing:
+
+- **Both servers must answer before the app starts.** react-native-test-app
+ chooses its bundle source once at startup and never retries. If 8081 is down,
+ Android raises a modal "Unable to find JavaScript bundle" that blocks every
+ later tap; iOS silently falls back and keeps going. Both read as a test failure
+ that is really an environment failure — the uploaded `.e2e/` artifacts tell
+ them apart.
+- **Workspace packages build first.** `pnpm --filter tester-federation-v2...
+ build` is how a PR's Repack change reaches the servers under test.
+- **8082 must be reachable as `localhost` from the device.** The remote URL is
+ baked into the bundle as `http://localhost:8082/…` and Repack's
+ `__PUBLIC_HOST__` rewrite covers asset public paths and HMR only, never Module
+ Federation remote URLs. The device needs `adb reverse tcp:8081 tcp:8082`; locally
+ the servers do this at startup, CI does it explicitly.
+
+Re.Pack builds a platform lazily, on that platform's first asset request, so
+"`/status` answers" has never meant "bundle is built". That is why the workflow
+has an explicit warm step: without it the emulator waits on a cold compile after
+a tap.
+
+### What is covered
+
+One file per feature; every test starts from a fresh app process (`app.open()`
+restarts the app), so nothing accumulates between tests.
+
+| File | What a red run means | Re.Pack surface |
+| --- | --- | --- |
+| `gallery` | the MiniApp remote fails to install/load/render | MF2 container load over `localhost:8082` |
+| `runtime-events` | the resolve/load chain or the hook chain is broken; one test drives it via Gallery, then reads counters + a computed `pipeline: settled` row | all 6 `ScriptManager` events + the 4 non-owning hooks, counted in `features/runtime/events.ts`; each of the 8 driving rows asserts `≥ 1`, `error: 0` is asserted, and `prefetching`/`error` feed the pipeline row |
+| `async-chunk` | a host-owned async chunk no longer loads through `ScriptManager` | `import()` + the allowlist resolver. Not `extraChunks`: `OutputPlugin` is off under a dev server and the resolver's `__DEV__` branch sends every chunk to it, so the `local` rule never runs in this Debug lane |
+| `remote-chunk` | the prefetch → load cycle broke, or `invalidateScripts` threw | `ScriptManager.prefetchScript` / `invalidateScripts` on host remote chunks. The asserts after invalidate read UI state the screen sets itself, so an invalidate that resolves but clears nothing stays green |
+| `miniapp-install` | install / show / hide / uninstall of a host mini-app chunk broke | same APIs, second lifecycle; same invalidate gap as `remote-chunk` |
+| `host-assets` | asset rules no longer produce what they claim: inline gate (pic-1 87 KB in, pic-2 119 KB out), folder rules (`localAssets` emitted despite being small, `inlineAssets` inlined), the emitted URI is servable (fetch → 200, port and query string taken from the reported URI, never hardcoded). The `.svg` row asserts nothing, and that is honest: svgr compiles at build time, so a broken rule reddens the whole app at boot, before any assert could run | `assets-loader` (inline gate, per-folder rules), served asset route, `@svgr/webpack` rule |
+| `nativewind` | CSS stopped computing: `padding-applied: 16` and `calc-width: 248` are layout numbers, not text checks. The dark-mode round trip reads NativeWind's `useColorScheme()` state, not a resolved `dark:` style, and assumes the device starts in light appearance | `NativeWindPlugin` + the host-scoped JSX retag |
+| `reanimated` | the worklet pipeline broke — the width row (`100 → 150 → 200 → 100`) is a shared value driven through `useAnimatedStyle` + `useAnimatedReaction` + `scheduleOnRN` (from `react-native-worklets` — reanimated's `runOnJS` re-export is deprecated), so it only moves if user worklets execute; an untransformed worklet throws at boot | the babel `react-native-worklets/plugin` transform; `ReanimatedPlugin`'s `unstable_disableTransform` only suppresses its own rules, which the new babel-swc-loader makes obsolete |
+
+Every row on a screen must own a distinct way to fail. That rule deleted the
+third host bitmap fixture (`pic_3.jpg`, 58 KB): under the same 90 KB gate as
+`pic_1`, so it could only ever fail where `pic_1` already fails. The same rule is
+the filter for any future row.
+
+Navigation is not a subject of its own. The navigator is react-native-screens —
+upstream code with upstream tests — and a broken one reddens every test at its
+prologue tap anyway. The back-navigation round trip still gets exercised, implied
+where a test needs it: `runtime-events` calls `app.back()` and its next step can
+only succeed if Home came back.
+
+### Adding a test
+
+1. `FEATURES.ts`: one entry. `screen` is typed against the navigator, so a
+ missing route is a typecheck error.
+2. `MainNavigator.tsx`: param-list key + ``.
+3. The screen: root `testID="-screen"`, and one `` row per
+ value, `testID="--report"`, `name` the bare noun.
+4. `e2e/.e2e.ts`: copy the prologue verbatim — `app.open()`, assert
+ `home-screen`, `scrollUntilVisible(entry)` (Home is a ScrollView; a bare tap
+ on an off-screen row silently misses), `tap`, assert the screen — then only
+ your asserts. No helpers, deliberately: each file reads standalone.
+5. Break it once: invert the feature in the config or the screen, watch exactly
+ your assert go red, revert.
+
+### CI
+
+Steps: setup → build packages → start servers + wait `/status` → warm both
+compilers → check manifest shared versions are real semver → build the app →
+device { reverse ×2, install, run } → collect logs → artifacts. Lines worth
+knowing about:
+
+| Line | Defends against |
+| --- | --- |
+| `timeout 180 … until /status` | app launching mid-server-boot → the never-retry bundle alert |
+| the two warm `curl`s | a cold compile landing on the device's critical path |
+| the semver manifest check | `catalog:` specifiers leaking into manifest shared versions, at the source |
+| `adb reverse` ×2, asserted | CI starts servers before the emulator exists, and the server's own reverse does not self-heal |
+| `nohup` + `disown` | the runner kills the step's process group at step end |
+| install + run inside the emulator action's `script` | the action kills the emulator when the step ends |
+| ANR-dialog clear loop before the run | a launcher "isn't responding" dialog swallows every tap; `hide_error_dialogs` does not suppress it |
+| `disable-animations: false` | the flag writes `transition_animation_scale` 0.0, which RN and Reanimated both read as "reduce motion on"; the dev warning's LogBox container then eats taps on Home's last row |
+| `include-hidden-files: true` on artifacts | `upload-artifact` matching zero files for the dot-dir `.e2e` and still reporting success |
+| `if: ${{ !cancelled() }}` on artifacts | losing evidence from a test that failed then passed |
+| always tail the server logs | they live outside `.e2e` and are the only evidence that survives a broken upload |
+
+### Traps
+
+- **Tapping `ScriptManager.hooks.resolve`/`hooks.load` replaces the built-in
+ loop**, not decorates it — a counter tap that returns `undefined` makes the
+ manager resolve nothing. Counters only tap the 4 non-owning hooks; the events
+ cover what `resolve`/`load` would have.
+- **`extraChunks.include` is matched against chunk names and the numeric chunk
+ id** (`OutputPlugin` flattens both). An unanchored pattern can catch other
+ chunks whose names contain it, and a near-miss like `/.+local.+/` does not
+ match `host-async-local` at all, so the chunk silently falls to the catch-all
+ remote rule. The rule is `/^host-async-local$/`. It only applies to builds
+ without a dev server; the e2e lane does not exercise it.
+- **The ignored LogBox string is load-bearing**: NativeWind's runtime reads RN's
+ deprecated `ImageBackground` getter at boot
+ (`react-native-css-interop/components.js`) though no screen uses it, and the
+ toast covers Home's last row and steals taps. A NativeWind upgrade can change
+ the wording; the toast reappearing is the symptom.
+- **The shared `babel.config.js` serves all three compilations** (host, mini,
+ expo widget — all set `env.context` to the app root). A host-only JSX retag
+ goes in `overrides` with a regex (string `test` resolves against the CLI cwd);
+ adding it at top level retags `src/mini` too and breaks the remote that
+ `gallery` and `runtime-events` load (`miniapp-install` is a host-owned chunk,
+ not that remote). The worklets transform must stay top level:
+ `react-native-worklets` ships compiled code that still carries `'worklet'`
+ directives, and host-scoping it leaves the boot red.
+- **`NativeWindPlugin`'s JSX half is a silent no-op** with
+ `@callstack/repack/babel-swc-loader` (it only matches rules named
+ `builtin:swc-loader`); the operative path is babel detecting
+ `transform-react-jsx` and feeding `importSource` to swc.
+- **iOS layout numbers are device-dependent** (393 pt vs 402 pt). Assert a
+ difference, not a width: `padding-applied: 16` holds everywhere, a raw box
+ width does not. Exact pixel quality is out of scope entirely — no assertion
+ worth maintaining.
+- **iOS CI pods need Ruby ≥ 3.4** via `ruby/setup-ruby` with `working-directory`
+ (`gemfile:` is not an input — the action warns, then silently caches nothing).
+ The floor exists because of a CocoaPods null-byte crash on pnpm-symlinked
+ `node_modules`; CocoaPods remains intermittent there, `bundle update
+ cocoapods` is the candidate fix.
+- **Native module changes need a rebuild on both platforms**, and iOS may need
+ `pod install` twice — the first pass can symlink headers before codegen emits
+ `rnworklets/rnworklets.h`. Same trap as the emulator snapshot: the JS bundle
+ looks correct while the native side is old.
+- **`app.open()` restarts the app** (terminate + relaunch), so module-scope
+ counters reset per test — event assertions avoid exact totals only because
+ background loads inside one test also bump them.
+- Actions are pinned to SHAs verified against the GitHub API (note
+ `android-emulator-runner`'s tag is annotated — dereference it). Node matches
+ `release.yml`. `.github/actions/setup-pnpm-node` is reused, not re-derived.
+
+---
+
+## Expo lane
+
+Runs `apps/tester-expo`, a real Expo SDK app (expo-router, the
+`@callstack/repack-expo` config plugin, `ExpoPlugin`). Only `tester-expo` is
+installed on the device; its two Module Federation remotes are dev servers:
+
+| Port | Server | Built from |
+| --- | --- | --- |
+| 8081 | `tester-expo repack:start` | the Expo host |
+| 8082 | `tester-expo-widget repack:start:remote` | `ExpoWidget`, an Expo-built remote |
+| 8083 | `tester-federation-v2 start:expo-host-widget` | `OrdinaryWidget`, a plain Re.Pack remote (no `ExpoPlugin`) |
+
+### Where things live
+
+| Path | Role |
+| --- | --- |
+| `apps/tester-expo/e2e/*.e2e.ts` | One file per feature |
+| `apps/tester-expo/e2e.config.ts` | Targets, `retries: 0`, `workers: 1` |
+| `apps/tester-expo/FEATURES.ts` | Registry Home renders, in MF2's shape. `href` is a plain string: typed routes are off (they need files only the Expo CLI generates) |
+| `apps/tester-expo/ui/` | `Report.tsx`, `ActionButton.tsx` (copies of MF2's), `Screen.tsx` (page layout + root testID), `LoadState.ts` |
+| `apps/tester-expo/babel.config.cjs` | Turns off the deep-import warning (see Traps) |
+| `.github/workflows/e2e-expo.yml` | Android job (PRs) + iOS job (main, dispatch) |
+
+#### Running it locally
+
+```bash
+# boot an emulator/simulator first, as for MF2
+pnpm --filter tester-expo repack:start # 8081
+pnpm --filter tester-expo-widget repack:start:remote # 8082
+pnpm --filter tester-federation-v2 start:expo-host-widget # 8083
+E2E_DEVICE= pnpm exec e2e run --target ios # in apps/tester-expo
+```
+
+The app must be installed (`pnpm repack:android` / `pnpm repack:ios`; `expo
+run` generates the native folders, which are not in git). Warm all three
+servers first, as in the workflow.
+
+### What is covered
+
+| File | What a red run means | Re.Pack surface |
+| --- | --- | --- |
+| `expo-module` | an Expo native module can't answer a Re.Pack bundle. The row shows `Constants.expoConfig.name`, which only the native build embeds (from `app.json`); `expoVersion` would pass with the module missing | config plugin (prebuild), `ExpoModulesPlugin` |
+| `image` | Expo asset rules, the asset registry or monorepo resolution broke; rows are set in `onLoad`, so the bytes were served. The second image lives in `tests/integration/…`, outside the app | `configureExpoAssets`, `configureExpoModuleResolution` |
+| `density-image` | the wrong density variant is picked. Both CI devices resolve to 3, so a bug that always picks `@3x` stays green | `configureExpoAssets` (`platform` option) |
+| `font` | a bundled font no longer reaches `expo-font` | `configureExpoAssets` (font branch; `configureAndroidFontAssets` is production-only) |
+| `lazy` | the host's `.local` async chunk does not load, proven by the chunk's own content | `configureExpoChunkFilename`, `localChunkResolver` (dev-server path) |
+| `expo-widget` | the Expo remote: shared `expo-constants`, its own `EXPO_PUBLIC_*` values vs the host's, `expo-asset` metadata, remote asset scale, its nested chunk | MF2 + `ExpoPlugin` remote side, `inlineExpoPublicEnvironment` |
+| `ordinary-widget` | an Expo host can no longer load a remote built without `ExpoPlugin` | MF2 interop |
+| `fast-refresh` | an edit no longer reaches the app, or it reloads fully (the counter drops to 0). The test edits `components/FastRefreshLeaf.tsx` and restores it in `finally` | dev server HMR, `WebpackHMRClient`, `react-refresh` |
+| `symbolication` | a stack frame no longer maps back to `components/SymbolicationProbe.tsx:`; the test reads `` from the file | source maps, `applyRepackSourceMapFix`, the dev server's `/symbolicate` |
+
+Expected values come from source: the tests read `app.json` and both apps'
+`.env.development`. The widgets keep their own testIDs; only the host's screens
+use `Report`. Not covered on purpose: Release paths (`repack-expo build`,
+packaged `.local` chunks, EAS), the widget as its own app.
+
+### Adding a test
+
+As for MF2, with three differences: the route is a file, `app/.tsx`
+(expo-router: file path = URL); its title goes in `app/_layout.tsx`; a wrong
+`href` in `FEATURES.ts` is not a type error — the prologue's screen assert
+catches it.
+
+### Break-once record
+
+| File | Break | Red at |
+| --- | --- | --- |
+| `expo-module` | rename the app in `app.json` without a native rebuild | the app-name row (shows the built-in name) |
+| `image` | user rule `{ test: /\.png$/, type: 'asset/resource' }` | `local: .png` instead of `local: repack.png` |
+| `density-image` | remove `density@3x.png` | `scale: 2` instead of 3 |
+| `font` | user rule `{ test: /\.ttf$/, type: 'asset/resource' }` | `font-load-report` |
+| `lazy` | the chunk throws on load | `lazy-component-ready` |
+| `expo-widget` | wrong `remote.publicPath` in the widget's asset rules | `expo-widget-ready` |
+| `ordinary-widget` | rename `'./Widget'` in `configs/rspack.expo-host-widget.mts` | `ordinary-repack-widget-ready` |
+| `fast-refresh` | `devServer: { ...devServer, hot: false }` in the host config | `fast-refresh-leaf-report` keeps the old marker |
+| `symbolication` | `devtool: false` in the host config | the frame is the bundle URL, not the source file |
+
+A user asset rule only replaces `ExpoPlugin`'s when it covers the whole
+extension; a rule for one file name (`/logo\.png$/`) is silently ignored.
+
+### CI
+
+`e2e-expo.yml` is the MF2 workflow plus `expo prebuild` (native folders are not
+in git), a third server and `adb reverse tcp:8083`. The semver check runs on
+8081 and 8082 only: `OrdinaryWidget` shares with `requiredVersion: false`, so
+8083 says `*` by design. The remotes warm on `mf-manifest.json` — any request
+compiles the whole platform.
+
+The Android job keeps `~/.gradle` in the Actions cache, so Gradle does not
+download its dependencies from Maven Central on every run. Central sometimes
+answers 404 for artifacts that exist, and Gradle does not retry a 404. Only
+`main` runs save the cache; PR runs only restore it. The restore runs after
+`expo prebuild`, because the key hashes files in the generated `android/`. The
+key prefix is `gradle-expo-`, so after a lockfile change the fallback is an
+older Expo copy, not another job's copy that lacks the Expo libraries.
+
+### Traps
+
+- **RN 0.88 turns the first warning into a toast** ("Open debugger to view
+ warnings.") that steals taps. The one warning was babel-preset-expo's
+ deep-import lint, which fires outside `node_modules`: the linked
+ `packages/repack` (`WebpackHMRClient.js`) and `SymbolicationProbe.tsx` (RN
+ has no public export for its helpers) both trip it. `babel.config.cjs` turns
+ it off — no LogBox filter, so a new warning still shows and a test goes red.
+- **A `pnpm install` that re-resolves peers needs fresh dev servers.** A
+ running server keeps the old `.pnpm/@` paths and mixes copies —
+ boot dies with "Tried to register two views with the same name
+ ExpoRouterActivityContents".
+- **CocoaPods needs a UTF-8 locale.** Without `LANG=en_US.UTF-8` it crashes
+ while printing its own error report. The system CocoaPods works; no `Gemfile`
+ (unlike MF2).
+- **The iOS job needs Xcode 26.** `expo-modules-jsi` builds a Swift package
+ with `swift-tools-version: 6.2`; `macos-15`'s default Xcode 16.4 has Swift
+ 6.1 and fails with "Could not resolve package dependencies". The job sets
+ `DEVELOPER_DIR` to Xcode 26.3, which matches local, and boots an iOS 26 simulator.
+- **`fast-refresh` is the only test that edits a source file.** A run killed
+ before `finally` leaves `components/FastRefreshLeaf.tsx` changed — check
+ `git status` locally.
+- **The host config is read once at server start.** A break in
+ `rspack.config.mjs` needs an 8081 restart, and so does the revert.
+- **After any rebuild, the manifest reports `*` for the `react-native/…`
+ deep-import shares**, and the semver check fails. Only the first compile
+ after server start has real versions; locally, restart before the check.
+- **`asset/source` on font files breaks the whole bundle** ("octals not allowed
+ in strict mode"). Use `asset/resource` for breaks.
+
+---
+
+## Debugging a red run (both lanes)
+
+Evidence, in order of usefulness: `.e2e/results//trace.md` (step-by-step,
+with the full node dump at failure), `attempt-*/screenshots/`,
+`screen-at-failure.txt`, `logcat.txt` / `ios-syslog.txt`, the dev-server logs —
+all uploaded as the `e2e-android` / `e2e-ios` artifact (`e2e-expo-*` in the
+Expo lane) even when the run dies before tests start.
+
+| Symptom | Cause | Fix |
+| --- | --- | --- |
+| `home-screen` never visible, first test of the day | cold dev-server compile eating the boot anchor's budget on first launch | warm the platform bundle ("Running it locally") |
+| `home-screen` never visible, screenshot is the status bar / launcher | something owns the screen: ANR dialog, toast | the workflow's ANR clear; locally dismiss and rerun |
+| taps do nothing, screen dump says "unchanged" | a toast covers the control — CI screens are ~15 % smaller than devices, so it may not reproduce locally | screenshot proves it; suppress the warning at its source, never with `ignoreAllLogs` |
+| everything passes on iOS, fails on Android only | the local emulator is `arm64-v8a`, docs habit is `-PreactNativeArchitectures=x86_64` → `INSTALL_FAILED_NO_MATCHING_ABIS`; or a restored AVD snapshot rolled the APK back | build for `arm64-v8a`; compare `dumpsys package ` `lastUpdateTime` against the APK on disk |
+| Gallery hangs, nothing loads | 8082 never compiled, or its reverse is missing | the warm step / `adb reverse --list` |
+| `404 to be 200` on the asset fetch | the served-asset route needs the query string (`?platform=…&hash=…`) — path alone 404s | the test keeps `pathname + search`; if the URL shape changed, that is the regression |
+| `Property '__X__' doesn't exist` redbox | `DefinePlugin` only applies at server start | restart the server after config edits |
+| the engine reads an empty/stale screen tree while `adb shell uiautomator dump` sees everything | the engine's snapshot helper is wedged | restart the emulator; reinstall `com.callstack.agentdevice.*` helpers if it persists |
diff --git a/apps/tester-expo/.gitignore b/apps/tester-expo/.gitignore
index a486fe0a3..c8cbf4394 100644
--- a/apps/tester-expo/.gitignore
+++ b/apps/tester-expo/.gitignore
@@ -1,3 +1,6 @@
# Native projects are generated by Expo prebuild and intentionally untracked.
/android
/ios
+
+# e2e
+.e2e/
diff --git a/apps/tester-expo/FEATURES.ts b/apps/tester-expo/FEATURES.ts
new file mode 100644
index 000000000..93be35e78
--- /dev/null
+++ b/apps/tester-expo/FEATURES.ts
@@ -0,0 +1,60 @@
+import type { Href } from 'expo-router';
+
+export const FEATURE_GROUPS = [
+ 'Expo modules',
+ 'Assets',
+ 'Chunks',
+ 'Module Federation',
+ 'Dev loop',
+] as const;
+
+export type FeatureGroup = (typeof FEATURE_GROUPS)[number];
+
+export type Feature = {
+ key: string;
+ name: string;
+ group: FeatureGroup;
+ href: Href;
+};
+
+export const FEATURES: Feature[] = [
+ {
+ key: 'expo-module',
+ name: 'Expo module',
+ group: 'Expo modules',
+ href: '/details',
+ },
+ { key: 'image', name: 'Image', group: 'Assets', href: '/image' },
+ {
+ key: 'density-image',
+ name: 'Density image',
+ group: 'Assets',
+ href: '/density-image',
+ },
+ { key: 'font', name: 'Font', group: 'Assets', href: '/font' },
+ { key: 'lazy', name: 'Lazy chunk', group: 'Chunks', href: '/lazy' },
+ {
+ key: 'expo-widget',
+ name: 'Expo widget',
+ group: 'Module Federation',
+ href: '/widget',
+ },
+ {
+ key: 'ordinary-widget',
+ name: 'Ordinary Re.Pack widget',
+ group: 'Module Federation',
+ href: '/ordinary-widget',
+ },
+ {
+ key: 'fast-refresh',
+ name: 'Fast Refresh',
+ group: 'Dev loop',
+ href: '/fast-refresh',
+ },
+ {
+ key: 'symbolication',
+ name: 'Symbolication',
+ group: 'Dev loop',
+ href: '/symbolication',
+ },
+];
diff --git a/apps/tester-expo/app/_layout.tsx b/apps/tester-expo/app/_layout.tsx
index 624cfe6c7..75d81a809 100644
--- a/apps/tester-expo/app/_layout.tsx
+++ b/apps/tester-expo/app/_layout.tsx
@@ -1,5 +1,21 @@
import { Stack } from 'expo-router';
export default function RootLayout() {
- return ;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
}
diff --git a/apps/tester-expo/app/density-image.tsx b/apps/tester-expo/app/density-image.tsx
index 88fea187a..b1029c48a 100644
--- a/apps/tester-expo/app/density-image.tsx
+++ b/apps/tester-expo/app/density-image.tsx
@@ -1,55 +1,44 @@
-import { Link } from 'expo-router';
import { useState } from 'react';
-import { Image, PixelRatio, StyleSheet, Text, View } from 'react-native';
+import { Image, PixelRatio, StyleSheet } from 'react-native';
+import type { LoadState } from '../ui/LoadState';
+import Report from '../ui/Report';
+import Screen from '../ui/Screen';
const densityImage = require('../assets/images/density.png');
export default function DensityImageRoute() {
const resolvedImage = Image.resolveAssetSource(densityImage);
- const [imageLoaded, setImageLoaded] = useState(false);
- const [imageError, setImageError] = useState(false);
+ const [load, setLoad] = useState('loading');
return (
-
- {imageLoaded && !imageError ? (
-
- Density-aware bundled image loaded
-
- ) : null}
- {imageError ? (
-
- Density-aware bundled image failed to load
-
- ) : null}
+
+
+
+
setImageError(true)}
- onLoad={() => setImageLoaded(true)}
+ onError={() => setLoad('error')}
+ onLoad={() => setLoad('loaded')}
source={densityImage}
style={styles.image}
testID="density-image"
/>
-
- Selected density scale: {resolvedImage?.scale}
-
-
- Device density scale: {PixelRatio.get()}
-
- Back home
-
+
);
}
const styles = StyleSheet.create({
- container: {
- alignItems: 'center',
- flex: 1,
- gap: 16,
- justifyContent: 'center',
- padding: 24,
- },
image: {
height: 48,
+ marginHorizontal: 12,
width: 48,
},
});
diff --git a/apps/tester-expo/app/details.tsx b/apps/tester-expo/app/details.tsx
index ddc776e6a..f33be519f 100644
--- a/apps/tester-expo/app/details.tsx
+++ b/apps/tester-expo/app/details.tsx
@@ -1,24 +1,15 @@
import Constants from 'expo-constants';
-import { Link } from 'expo-router';
-import { StyleSheet, Text, View } from 'react-native';
+import Report from '../ui/Report';
+import Screen from '../ui/Screen';
export default function DetailsRoute() {
return (
-
-
- Expo runtime: {Constants.expoVersion ?? 'development build'}
-
- Back home
-
+
+
+
);
}
-
-const styles = StyleSheet.create({
- container: {
- alignItems: 'center',
- flex: 1,
- gap: 16,
- justifyContent: 'center',
- padding: 24,
- },
-});
diff --git a/apps/tester-expo/app/fast-refresh.tsx b/apps/tester-expo/app/fast-refresh.tsx
new file mode 100644
index 000000000..ee7bd0860
--- /dev/null
+++ b/apps/tester-expo/app/fast-refresh.tsx
@@ -0,0 +1,25 @@
+import { useState } from 'react';
+import FastRefreshLeaf from '../components/FastRefreshLeaf';
+import ActionButton from '../ui/ActionButton';
+import Report from '../ui/Report';
+import Screen from '../ui/Screen';
+
+export default function FastRefreshRoute() {
+ const [count, setCount] = useState(0);
+
+ return (
+
+
+ setCount((value) => value + 1)}
+ />
+
+
+ );
+}
diff --git a/apps/tester-expo/app/font.tsx b/apps/tester-expo/app/font.tsx
index 08fe4d71a..390041de8 100644
--- a/apps/tester-expo/app/font.tsx
+++ b/apps/tester-expo/app/font.tsx
@@ -1,49 +1,35 @@
import { useFonts } from 'expo-font';
-import { Link } from 'expo-router';
-import { StyleSheet, Text, View } from 'react-native';
+import { StyleSheet, Text } from 'react-native';
+import Report from '../ui/Report';
+import Screen from '../ui/Screen';
export default function FontRoute() {
const [fontsLoaded, fontError] = useFonts({
ExpoFixture: require('../assets/fonts/material-symbols-regular.ttf'),
});
- if (fontError) {
- return (
-
- Font error: {fontError.message}
-
- );
- }
-
- if (!fontsLoaded) {
- return (
-
- Loading bundled font…
-
- );
- }
+ const state = fontError
+ ? `error: ${fontError.message}`
+ : fontsLoaded
+ ? 'loaded'
+ : 'loading';
return (
-
- Bundled font loaded by expo-font:
-
- home
-
- Back home
-
+
+
+ {fontsLoaded ? (
+
+ home
+
+ ) : null}
+
);
}
const styles = StyleSheet.create({
- container: {
- alignItems: 'center',
- flex: 1,
- gap: 16,
- justifyContent: 'center',
- padding: 24,
- },
fixtureFont: {
fontFamily: 'ExpoFixture',
fontSize: 32,
+ paddingHorizontal: 12,
},
});
diff --git a/apps/tester-expo/app/image.tsx b/apps/tester-expo/app/image.tsx
index 122c16af6..d96ecb3d5 100644
--- a/apps/tester-expo/app/image.tsx
+++ b/apps/tester-expo/app/image.tsx
@@ -1,68 +1,63 @@
import { Asset } from 'expo-asset';
-import { Link } from 'expo-router';
import { useState } from 'react';
-import { Image, StyleSheet, Text, View } from 'react-native';
+import { Image, StyleSheet } from 'react-native';
+import type { LoadState } from '../ui/LoadState';
+import Report from '../ui/Report';
+import Screen from '../ui/Screen';
const packagedImage = require('../assets/images/repack.png');
const packagedAsset = Asset.fromModule(packagedImage);
const monorepoImage = require('../../../tests/integration/src/loaders/__fixtures__/assets/logo.png');
const monorepoAsset = Asset.fromModule(monorepoImage);
+const assetLabel = (state: LoadState, asset: Asset) =>
+ state === 'loaded' ? `${asset.name}.${asset.type}` : state;
+
export default function ImageRoute() {
- const [appLocalImageLoaded, setAppLocalImageLoaded] = useState(false);
- const [monorepoImageLoaded, setMonorepoImageLoaded] = useState(false);
- const [imageError, setImageError] = useState();
- const imagesLoaded =
- appLocalImageLoaded && monorepoImageLoaded && !imageError;
+ const [local, setLocal] = useState('loading');
+ const [monorepo, setMonorepo] = useState('loading');
return (
-
- {imagesLoaded ? (
-
- Expo assets loaded: {packagedAsset.name}.{packagedAsset.type} and{' '}
- monorepo {monorepoAsset.name}.{monorepoAsset.type}
-
- ) : null}
- {imageError ? {imageError} : null}
+
+
- setImageError((error) => error ?? 'App-local image failed to load')
- }
- onLoad={() => setAppLocalImageLoaded(true)}
+ onError={() => setLocal('error')}
+ onLoad={() => setLocal('loaded')}
source={packagedImage}
style={styles.logo}
testID="app-local-image"
/>
+
- setImageError((error) => error ?? 'Monorepo image failed to load')
- }
- onLoad={() => setMonorepoImageLoaded(true)}
+ onError={() => setMonorepo('error')}
+ onLoad={() => setMonorepo('loaded')}
source={monorepoImage}
style={styles.monorepoLogo}
testID="monorepo-image"
/>
- Back home
-
+
);
}
const styles = StyleSheet.create({
- container: {
- alignItems: 'center',
- flex: 1,
- gap: 16,
- justifyContent: 'center',
- padding: 24,
- },
logo: {
height: 30,
+ marginHorizontal: 12,
width: 147,
},
monorepoLogo: {
height: 64,
+ marginHorizontal: 12,
width: 64,
},
});
diff --git a/apps/tester-expo/app/index.tsx b/apps/tester-expo/app/index.tsx
index 8e5a32427..4ac687662 100644
--- a/apps/tester-expo/app/index.tsx
+++ b/apps/tester-expo/app/index.tsx
@@ -1,42 +1,83 @@
-import { Link } from 'expo-router';
+import { useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
-import { useState } from 'react';
-import { Button, StyleSheet, Text, View } from 'react-native';
-import FastRefreshLeaf from '../components/FastRefreshLeaf';
-import SymbolicationProbe from '../components/SymbolicationProbe';
+import {
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+import { FEATURE_GROUPS, FEATURES } from '../FEATURES';
export default function HomeRoute() {
- const [count, setCount] = useState(0);
+ const router = useRouter();
return (
-
- Expo Router compiled by Re.Pack
- Parent state: {count}
-
+
);
}
const styles = StyleSheet.create({
- container: {
- alignItems: 'center',
- flex: 1,
- gap: 16,
- justifyContent: 'center',
- padding: 24,
+ content: {
+ padding: 20,
+ gap: 8,
+ },
+ title: {
+ fontSize: 28,
+ fontWeight: '700',
+ color: '#200a12',
+ },
+ subtitle: {
+ fontSize: 16,
+ color: '#55424a',
+ marginBottom: 12,
+ },
+ group: {
+ gap: 6,
+ marginBottom: 16,
+ },
+ groupTitle: {
+ fontSize: 18,
+ fontWeight: '600',
+ color: 'rgba(56, 30, 114, 1)',
+ marginBottom: 4,
+ },
+ button: {
+ backgroundColor: 'rgba(56, 30, 114, 1)',
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderRadius: 8,
+ },
+ buttonText: {
+ color: '#ffffff',
+ fontSize: 16,
+ textAlign: 'center',
},
});
diff --git a/apps/tester-expo/app/lazy.tsx b/apps/tester-expo/app/lazy.tsx
index 640845720..988141fb4 100644
--- a/apps/tester-expo/app/lazy.tsx
+++ b/apps/tester-expo/app/lazy.tsx
@@ -1,39 +1,25 @@
-import { type ErrorBoundaryProps, Link } from 'expo-router';
+import type { ErrorBoundaryProps } from 'expo-router';
import { lazy, Suspense } from 'react';
-import { StyleSheet, Text, View } from 'react-native';
+import { Text } from 'react-native';
+import Report from '../ui/Report';
+import Screen from '../ui/Screen';
const LazyCard = lazy(() => import('../components/LazyCard.local'));
export function ErrorBoundary({ error }: ErrorBoundaryProps) {
return (
-
- Lazy component route failed
-
- Unable to load the local async chunk: {error.message}
-
- Back home
-
+
+
+
);
}
export default function LazyRoute() {
return (
-
- Lazy component route
+
Loading chunk…}>
- Back home
-
+
);
}
-
-const styles = StyleSheet.create({
- container: {
- alignItems: 'center',
- flex: 1,
- gap: 16,
- justifyContent: 'center',
- padding: 24,
- },
-});
diff --git a/apps/tester-expo/app/ordinary-widget.tsx b/apps/tester-expo/app/ordinary-widget.tsx
index f74e1c383..20e42306b 100644
--- a/apps/tester-expo/app/ordinary-widget.tsx
+++ b/apps/tester-expo/app/ordinary-widget.tsx
@@ -1,6 +1,7 @@
import { type ErrorBoundaryProps, Link } from 'expo-router';
import { lazy, Suspense } from 'react';
import { StyleSheet, Text, View } from 'react-native';
+import Screen from '../ui/Screen';
const OrdinaryWidget = lazy(() => import('OrdinaryWidget/Widget'));
@@ -16,17 +17,13 @@ export function ErrorBoundary({ error }: ErrorBoundaryProps) {
export default function OrdinaryWidgetRoute() {
return (
-
-
- Ordinary Re.Pack widget route
-
+
Loading widget…}
>
- Back home
-
+
);
}
diff --git a/apps/tester-expo/app/symbolication.tsx b/apps/tester-expo/app/symbolication.tsx
new file mode 100644
index 000000000..6878e99e3
--- /dev/null
+++ b/apps/tester-expo/app/symbolication.tsx
@@ -0,0 +1,26 @@
+import { useSymbolicationProbe } from '../components/SymbolicationProbe';
+import ActionButton from '../ui/ActionButton';
+import Report from '../ui/Report';
+import Screen from '../ui/Screen';
+
+export default function SymbolicationRoute() {
+ const { frame, probe } = useSymbolicationProbe();
+
+ return (
+
+
+ {frame && (
+
+ )}
+
+ );
+}
diff --git a/apps/tester-expo/app/widget.tsx b/apps/tester-expo/app/widget.tsx
index c1e0d1a06..b8e5f290a 100644
--- a/apps/tester-expo/app/widget.tsx
+++ b/apps/tester-expo/app/widget.tsx
@@ -8,6 +8,8 @@ import {
useState,
} from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
+import Report from '../ui/Report';
+import Screen from '../ui/Screen';
type ExpoWidgetComponent = ComponentType<{ recoveryAttempt?: number }>;
@@ -120,17 +122,16 @@ export function ErrorBoundary({ error }: ErrorBoundaryProps) {
export default function WidgetRoute() {
return (
-
- Expo federated widget route
-
- Host public environment:{' '}
- {process.env.EXPO_PUBLIC_FEDERATION_OWNER ?? 'missing'}
-
+
+
{(attempt) => }
- Back home
-
+
);
}
diff --git a/apps/tester-expo/babel.config.cjs b/apps/tester-expo/babel.config.cjs
index e135c946f..7280e88d4 100644
--- a/apps/tester-expo/babel.config.cjs
+++ b/apps/tester-expo/babel.config.cjs
@@ -1,6 +1,7 @@
module.exports = function babelConfig(api) {
api.cache(true);
return {
- presets: ['babel-preset-expo'],
+ // linked Re.Pack and SymbolicationProbe must deep-import RN; the warning's toast steals e2e taps
+ presets: [['babel-preset-expo', { disableDeepImportWarnings: true }]],
};
};
diff --git a/apps/tester-expo/components/FastRefreshLeaf.tsx b/apps/tester-expo/components/FastRefreshLeaf.tsx
index 73f5140e8..13d58095f 100644
--- a/apps/tester-expo/components/FastRefreshLeaf.tsx
+++ b/apps/tester-expo/components/FastRefreshLeaf.tsx
@@ -1,5 +1,7 @@
import { Text } from 'react-native';
export default function FastRefreshLeaf() {
- return EXPO_FAST_REFRESH_LEAF_MARKER;
+ return (
+ EXPO_FAST_REFRESH_LEAF_MARKER
+ );
}
diff --git a/apps/tester-expo/components/SymbolicationProbe.tsx b/apps/tester-expo/components/SymbolicationProbe.tsx
index c20891007..a14de6c9e 100644
--- a/apps/tester-expo/components/SymbolicationProbe.tsx
+++ b/apps/tester-expo/components/SymbolicationProbe.tsx
@@ -1,28 +1,31 @@
-import { Button } from 'react-native';
-
-declare global {
- var __REPACK_ANDROID_SYMBOLICATION_STACK__: string | undefined;
-}
+import { useState } from 'react';
+import parseErrorStack from 'react-native/Libraries/Core/Devtools/parseErrorStack';
+import symbolicateStackTrace from 'react-native/Libraries/Core/Devtools/symbolicateStackTrace';
function throwSymbolicationProbeError() {
- throw new Error('EXPO_ANDROID_SYMBOLICATION_SENTINEL');
+ throw new Error('EXPO_SYMBOLICATION_SENTINEL');
}
-function captureSymbolicationStack() {
+async function symbolicateProbeError() {
try {
throwSymbolicationProbeError();
} catch (error) {
- globalThis.__REPACK_ANDROID_SYMBOLICATION_STACK__ =
- error instanceof Error ? error.stack : undefined;
+ const { stack } = await symbolicateStackTrace(
+ parseErrorStack((error as Error).stack)
+ );
+ return `${stack[0].file}:${stack[0].lineNumber}`;
}
+ return 'nothing thrown';
}
-export default function SymbolicationProbe() {
- return (
-
- );
+export function useSymbolicationProbe() {
+ const [frame, setFrame] = useState();
+
+ const probe = () => {
+ symbolicateProbeError().then(setFrame, (error: Error) =>
+ setFrame(`failed: ${error.message}`)
+ );
+ };
+
+ return { frame, probe };
}
diff --git a/apps/tester-expo/e2e.config.ts b/apps/tester-expo/e2e.config.ts
new file mode 100644
index 000000000..912de1bb4
--- /dev/null
+++ b/apps/tester-expo/e2e.config.ts
@@ -0,0 +1,20 @@
+import { mobile } from '@e2e-dev/mobile';
+import type { E2EConfig } from 'e2e';
+
+export default {
+ tests: 'e2e/**/*.e2e.ts',
+ retries: 0,
+ workers: 1,
+ targets: [
+ {
+ name: 'android',
+ engine: mobile({ platform: 'android', device: process.env.E2E_DEVICE }),
+ app: { bundleId: 'com.callstack.repackexpo' },
+ },
+ {
+ name: 'ios',
+ engine: mobile({ platform: 'ios', device: process.env.E2E_DEVICE }),
+ app: { bundleId: 'com.callstack.repackexpo' },
+ },
+ ],
+} satisfies E2EConfig;
diff --git a/apps/tester-expo/e2e/density-image.e2e.ts b/apps/tester-expo/e2e/density-image.e2e.ts
new file mode 100644
index 000000000..c6cb1b835
--- /dev/null
+++ b/apps/tester-expo/e2e/density-image.e2e.ts
@@ -0,0 +1,33 @@
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+const SCALES = [1, 2, 3];
+
+test('the density variant matching the device is picked', async ({
+ app,
+ screen,
+}) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-density-image-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+ await expect(screen.getByTestId('density-image-screen')).toBeVisible();
+
+ await expect(screen.getByTestId('density-image-load-report')).toHaveText(
+ 'image: loaded'
+ );
+
+ const pixelRatioText =
+ (await screen
+ .getByTestId('density-image-pixel-ratio-report')
+ .textContent()) ?? '';
+ const pixelRatio = Number(pixelRatioText.slice('pixel ratio: '.length));
+ const expected = SCALES.find((scale) => scale >= pixelRatio) ?? SCALES.at(-1);
+ await expect(screen.getByTestId('density-image-scale-report')).toHaveText(
+ `scale: ${expected}`
+ );
+});
diff --git a/apps/tester-expo/e2e/expo-module.e2e.ts b/apps/tester-expo/e2e/expo-module.e2e.ts
new file mode 100644
index 000000000..49872c9fd
--- /dev/null
+++ b/apps/tester-expo/e2e/expo-module.e2e.ts
@@ -0,0 +1,26 @@
+import { readFileSync } from 'node:fs';
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+const appJson = JSON.parse(
+ readFileSync(new URL('../app.json', import.meta.url), 'utf8')
+);
+
+test('an Expo native module answers the Re.Pack bundle', async ({
+ app,
+ screen,
+}) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-expo-module-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+ await expect(screen.getByTestId('expo-module-screen')).toBeVisible();
+
+ await expect(screen.getByTestId('expo-module-app-name-report')).toHaveText(
+ `app name: ${appJson.expo.name}`
+ );
+});
diff --git a/apps/tester-expo/e2e/expo-widget.e2e.ts b/apps/tester-expo/e2e/expo-widget.e2e.ts
new file mode 100644
index 000000000..2ce4be9ac
--- /dev/null
+++ b/apps/tester-expo/e2e/expo-widget.e2e.ts
@@ -0,0 +1,65 @@
+import { readFileSync } from 'node:fs';
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+const SCALES = [1, 2, 3];
+
+const readOwner = (envFile: URL) =>
+ readFileSync(envFile, 'utf8').match(
+ /^EXPO_PUBLIC_FEDERATION_OWNER=(.*)$/m
+ )?.[1];
+
+const hostOwner = readOwner(new URL('../.env.development', import.meta.url));
+const widgetOwner = readOwner(
+ new URL('../../tester-expo-widget/.env.development', import.meta.url)
+);
+const appJson = JSON.parse(
+ readFileSync(new URL('../app.json', import.meta.url), 'utf8')
+);
+
+test('an Expo-built remote loads inside the Expo host', async ({
+ app,
+ screen,
+}) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-expo-widget-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+ await expect(screen.getByTestId('expo-widget-screen')).toBeVisible();
+
+ await expect(screen.getByTestId('expo-widget-ready')).toBeVisible({
+ timeout: 45_000,
+ });
+ await expect(screen.getByTestId('expo-widget-host-env-report')).toHaveText(
+ `host env: ${hostOwner}`
+ );
+ await expect(screen.getByTestId('expo-widget-public-environment')).toHaveText(
+ `Widget public environment: ${widgetOwner}`
+ );
+ await expect(screen.getByTestId('expo-widget-native-module')).toHaveText(
+ `Native app owner: ${appJson.expo.name}`
+ );
+ await expect(screen.getByTestId('expo-widget-asset-metadata')).toHaveText(
+ 'Widget asset type: png'
+ );
+
+ const deviceText =
+ (await screen
+ .getByTestId('expo-widget-device-density-scale')
+ .textContent()) ?? '';
+ const device = Number(
+ deviceText.slice('Widget device density scale: '.length)
+ );
+ const expected = SCALES.find((scale) => scale >= device) ?? SCALES.at(-1);
+ await expect(screen.getByTestId('expo-widget-density-scale')).toHaveText(
+ `Selected widget density scale: ${expected}`
+ );
+
+ const lazy = screen.getByTestId('expo-widget-lazy-ready');
+ await screen.scrollUntilVisible(lazy);
+ await expect(lazy).toBeVisible();
+});
diff --git a/apps/tester-expo/e2e/fast-refresh.e2e.ts b/apps/tester-expo/e2e/fast-refresh.e2e.ts
new file mode 100644
index 000000000..5091588fa
--- /dev/null
+++ b/apps/tester-expo/e2e/fast-refresh.e2e.ts
@@ -0,0 +1,45 @@
+import { readFileSync, writeFileSync } from 'node:fs';
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+const leafFile = new URL('../components/FastRefreshLeaf.tsx', import.meta.url);
+const MARKER = 'EXPO_FAST_REFRESH_LEAF_MARKER';
+const EDITED = 'EXPO_FAST_REFRESH_LEAF_EDITED';
+
+test('an edit reaches the running app and state survives', async ({
+ app,
+ screen,
+}) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-fast-refresh-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+ await expect(screen.getByTestId('fast-refresh-screen')).toBeVisible();
+
+ const increment = screen.getByTestId('fast-refresh-increment-button');
+ await increment.tap();
+ await increment.tap();
+ await expect(screen.getByTestId('fast-refresh-state-report')).toHaveText(
+ 'parent state: 2'
+ );
+ await expect(screen.getByTestId('fast-refresh-leaf-report')).toHaveText(
+ MARKER
+ );
+
+ const original = readFileSync(leafFile, 'utf8');
+ try {
+ writeFileSync(leafFile, original.replace(MARKER, EDITED));
+ await expect(screen.getByTestId('fast-refresh-leaf-report')).toHaveText(
+ EDITED
+ );
+ await expect(screen.getByTestId('fast-refresh-state-report')).toHaveText(
+ 'parent state: 2'
+ );
+ } finally {
+ writeFileSync(leafFile, original);
+ }
+});
diff --git a/apps/tester-expo/e2e/font.e2e.ts b/apps/tester-expo/e2e/font.e2e.ts
new file mode 100644
index 000000000..40f3a5560
--- /dev/null
+++ b/apps/tester-expo/e2e/font.e2e.ts
@@ -0,0 +1,18 @@
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+test('a bundled font reaches expo-font', async ({ app, screen }) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-font-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+ await expect(screen.getByTestId('font-screen')).toBeVisible();
+
+ await expect(screen.getByTestId('font-load-report')).toHaveText(
+ 'font: loaded'
+ );
+});
diff --git a/apps/tester-expo/e2e/image.e2e.ts b/apps/tester-expo/e2e/image.e2e.ts
new file mode 100644
index 000000000..fd9faf8f6
--- /dev/null
+++ b/apps/tester-expo/e2e/image.e2e.ts
@@ -0,0 +1,24 @@
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+test('bundled images load, including one outside the project', async ({
+ app,
+ screen,
+}) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-image-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+ await expect(screen.getByTestId('image-screen')).toBeVisible();
+
+ await expect(screen.getByTestId('image-local-report')).toHaveText(
+ 'local: repack.png'
+ );
+ await expect(screen.getByTestId('image-monorepo-report')).toHaveText(
+ 'monorepo: logo.png'
+ );
+});
diff --git a/apps/tester-expo/e2e/lazy.e2e.ts b/apps/tester-expo/e2e/lazy.e2e.ts
new file mode 100644
index 000000000..973df1b8e
--- /dev/null
+++ b/apps/tester-expo/e2e/lazy.e2e.ts
@@ -0,0 +1,16 @@
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+test('a local async chunk of the host loads', async ({ app, screen }) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-lazy-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+ await expect(screen.getByTestId('lazy-screen')).toBeVisible();
+
+ await expect(screen.getByTestId('lazy-component-ready')).toBeVisible();
+});
diff --git a/apps/tester-expo/e2e/ordinary-widget.e2e.ts b/apps/tester-expo/e2e/ordinary-widget.e2e.ts
new file mode 100644
index 000000000..ce4a039ff
--- /dev/null
+++ b/apps/tester-expo/e2e/ordinary-widget.e2e.ts
@@ -0,0 +1,21 @@
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+test('a plain Re.Pack remote loads inside the Expo host', async ({
+ app,
+ screen,
+}) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-ordinary-widget-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+ await expect(screen.getByTestId('ordinary-widget-screen')).toBeVisible();
+
+ await expect(screen.getByTestId('ordinary-repack-widget-ready')).toBeVisible({
+ timeout: 45_000,
+ });
+});
diff --git a/apps/tester-expo/e2e/symbolication.e2e.ts b/apps/tester-expo/e2e/symbolication.e2e.ts
new file mode 100644
index 000000000..89d7c7af4
--- /dev/null
+++ b/apps/tester-expo/e2e/symbolication.e2e.ts
@@ -0,0 +1,33 @@
+import { readFileSync } from 'node:fs';
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+const probeSource = readFileSync(
+ new URL('../components/SymbolicationProbe.tsx', import.meta.url),
+ 'utf8'
+);
+const throwLine =
+ probeSource
+ .split('\n')
+ .findIndex((line) => line.includes("throw new Error('")) + 1;
+
+test('a stack trace maps back to the source file and line', async ({
+ app,
+ screen,
+}) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-symbolication-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+ await expect(screen.getByTestId('symbolication-screen')).toBeVisible();
+
+ await screen.getByTestId('symbolication-probe-button').tap();
+
+ await expect(screen.getByTestId('symbolication-frame-report')).toContainText(
+ `components/SymbolicationProbe.tsx:${throwLine}`
+ );
+});
diff --git a/apps/tester-expo/package.json b/apps/tester-expo/package.json
index 9db2d3ba3..917190eca 100644
--- a/apps/tester-expo/package.json
+++ b/apps/tester-expo/package.json
@@ -10,7 +10,8 @@
"repack:android": "expo run:android --no-bundler",
"repack:release:android": "repack-expo build --platform android",
"repack:release:ios": "repack-expo build --platform ios",
- "typecheck": "tsc --noEmit"
+ "typecheck": "tsc --noEmit",
+ "e2e": "pnpm exec e2e run"
},
"dependencies": {
"@callstack/repack": "workspace:*",
@@ -34,14 +35,17 @@
},
"devDependencies": {
"@babel/core": "catalog:",
+ "@e2e-dev/mobile": "catalog:testers",
"@react-native-community/cli": "catalog:testers",
"@react-native-community/cli-platform-android": "catalog:testers",
"@react-native-community/cli-platform-ios": "catalog:testers",
"@react-native/babel-preset": "catalog:testers",
"@react-native/community-cli-plugin": "catalog:testers",
"@rspack/core": "catalog:",
+ "@types/node": "catalog:",
"@types/react": "catalog:testers",
"babel-preset-expo": "catalog:expo58",
+ "e2e": "catalog:testers",
"typescript": "catalog:",
"ws": "^8.21.1"
}
diff --git a/apps/tester-expo/tsconfig.json b/apps/tester-expo/tsconfig.json
index 51b20857b..8bd228a25 100644
--- a/apps/tester-expo/tsconfig.json
+++ b/apps/tester-expo/tsconfig.json
@@ -1,13 +1,19 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
- "strict": true
+ "strict": true,
+ "types": ["node"]
},
"include": [
"app/**/*.ts",
"app/**/*.tsx",
"components/**/*.ts",
"components/**/*.tsx",
+ "ui/**/*.ts",
+ "ui/**/*.tsx",
+ "FEATURES.ts",
+ "e2e/**/*.ts",
+ "e2e.config.ts",
"types/**/*.d.ts",
".expo/types/**/*.ts",
"expo-env.d.ts"
diff --git a/apps/tester-expo/types/react-native-devtools.d.ts b/apps/tester-expo/types/react-native-devtools.d.ts
new file mode 100644
index 000000000..de1c90591
--- /dev/null
+++ b/apps/tester-expo/types/react-native-devtools.d.ts
@@ -0,0 +1,18 @@
+declare module 'react-native/Libraries/Core/Devtools/parseErrorStack' {
+ export type StackFrame = {
+ file?: string;
+ lineNumber?: number;
+ column?: number;
+ methodName: string;
+ };
+
+ export default function parseErrorStack(errorStack?: string): StackFrame[];
+}
+
+declare module 'react-native/Libraries/Core/Devtools/symbolicateStackTrace' {
+ import type { StackFrame } from 'react-native/Libraries/Core/Devtools/parseErrorStack';
+
+ export default function symbolicateStackTrace(
+ stack: StackFrame[]
+ ): Promise<{ stack: StackFrame[] }>;
+}
diff --git a/apps/tester-expo/ui/ActionButton.tsx b/apps/tester-expo/ui/ActionButton.tsx
new file mode 100644
index 000000000..c41a0658f
--- /dev/null
+++ b/apps/tester-expo/ui/ActionButton.tsx
@@ -0,0 +1,60 @@
+import { StyleSheet, Text, TouchableOpacity } from 'react-native';
+
+type ActionButtonProps = {
+ testID: string;
+ label: string;
+ onPress: () => void;
+ disabled?: boolean;
+ variant?: 'primary' | 'secondary';
+};
+
+const ActionButton = ({
+ testID,
+ label,
+ onPress,
+ disabled = false,
+ variant = 'primary',
+}: ActionButtonProps) => (
+
+
+ {label}
+
+
+);
+
+const styles = StyleSheet.create({
+ button: {
+ alignSelf: 'stretch',
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderRadius: 8,
+ backgroundColor: 'rgba(56, 30, 114, 1)',
+ },
+ secondary: {
+ backgroundColor: '#d8cbe8',
+ },
+ text: {
+ color: '#ffffff',
+ fontSize: 16,
+ textAlign: 'center',
+ },
+ secondaryText: {
+ color: '#200a12',
+ },
+ disabled: {
+ opacity: 0.5,
+ },
+});
+
+export default ActionButton;
diff --git a/apps/tester-expo/ui/LoadState.ts b/apps/tester-expo/ui/LoadState.ts
new file mode 100644
index 000000000..5c7f83d9c
--- /dev/null
+++ b/apps/tester-expo/ui/LoadState.ts
@@ -0,0 +1 @@
+export type LoadState = 'loading' | 'loaded' | 'error';
diff --git a/apps/tester-expo/ui/Report.tsx b/apps/tester-expo/ui/Report.tsx
new file mode 100644
index 000000000..bce95ac28
--- /dev/null
+++ b/apps/tester-expo/ui/Report.tsx
@@ -0,0 +1,25 @@
+import { StyleSheet, Text } from 'react-native';
+
+type ReportProps = {
+ testID: string;
+ name: string;
+ value: unknown;
+};
+
+const Report = ({ testID, name, value }: ReportProps) => (
+
+ {`${name}: ${String(value)}`}
+
+);
+
+const styles = StyleSheet.create({
+ row: {
+ alignSelf: 'stretch',
+ paddingVertical: 6,
+ paddingHorizontal: 12,
+ fontSize: 14,
+ color: '#200a12',
+ },
+});
+
+export default Report;
diff --git a/apps/tester-expo/ui/Screen.tsx b/apps/tester-expo/ui/Screen.tsx
new file mode 100644
index 000000000..44e83f8d1
--- /dev/null
+++ b/apps/tester-expo/ui/Screen.tsx
@@ -0,0 +1,30 @@
+import type { ReactNode } from 'react';
+import { ScrollView, StyleSheet, View } from 'react-native';
+
+type ScreenProps = {
+ testID: string;
+ children: ReactNode;
+};
+
+const Screen = ({ testID, children }: ScreenProps) => (
+
+
+ {children}
+
+
+);
+
+const styles = StyleSheet.create({
+ content: {
+ paddingVertical: 16,
+ },
+ container: {
+ paddingHorizontal: 16,
+ gap: 4,
+ },
+});
+
+export default Screen;
diff --git a/apps/tester-federation-v2/babel.config.js b/apps/tester-federation-v2/babel.config.js
index d4461aec7..dabc63302 100644
--- a/apps/tester-federation-v2/babel.config.js
+++ b/apps/tester-federation-v2/babel.config.js
@@ -1,4 +1,18 @@
module.exports = {
presets: ['module:@react-native/babel-preset'],
+ plugins: ['react-native-worklets/plugin'],
comments: true,
+ overrides: [
+ {
+ // Regex instead of a relative string: babel resolves strings against the
+ // launch cwd, and the rspack CLI launches from the repo root.
+ test: /[\\/]src[\\/]host[\\/]/,
+ plugins: [
+ [
+ '@babel/plugin-transform-react-jsx',
+ { runtime: 'automatic', importSource: 'nativewind' },
+ ],
+ ],
+ },
+ ],
};
diff --git a/apps/tester-federation-v2/configs/hostAssetRules.mts b/apps/tester-federation-v2/configs/hostAssetRules.mts
new file mode 100644
index 000000000..b53edc3d8
--- /dev/null
+++ b/apps/tester-federation-v2/configs/hostAssetRules.mts
@@ -0,0 +1,44 @@
+import path from 'node:path';
+import * as Repack from '@callstack/repack';
+
+const BITMAP_EXTENSIONS = Repack.getAssetExtensionsRegExp(
+ Repack.ASSET_EXTENSIONS.filter((ext) => ext !== 'svg')
+);
+
+export function buildHostAssetRules(context: string) {
+ const localAssets = path.join(context, 'src/host/assets/localAssets');
+ const inlineAssets = path.join(context, 'src/host/assets/inlineAssets');
+
+ return [
+ {
+ test: BITMAP_EXTENSIONS,
+ exclude: [localAssets, inlineAssets],
+ use: {
+ loader: '@callstack/repack/assets-loader',
+ options: { inline: true, maxInlineSize: 90 * 1024 },
+ },
+ },
+ {
+ test: /\.svg$/,
+ use: [
+ {
+ loader: '@svgr/webpack',
+ options: { native: true, dimensions: false },
+ },
+ ],
+ },
+ {
+ test: BITMAP_EXTENSIONS,
+ include: [localAssets],
+ use: '@callstack/repack/assets-loader',
+ },
+ {
+ test: BITMAP_EXTENSIONS,
+ include: [inlineAssets],
+ use: {
+ loader: '@callstack/repack/assets-loader',
+ options: { inline: true },
+ },
+ },
+ ];
+}
diff --git a/apps/tester-federation-v2/configs/rspack.host-app.mts b/apps/tester-federation-v2/configs/rspack.host-app.mts
index 138dbf24c..0fcd4313f 100644
--- a/apps/tester-federation-v2/configs/rspack.host-app.mts
+++ b/apps/tester-federation-v2/configs/rspack.host-app.mts
@@ -1,6 +1,9 @@
import * as Repack from '@callstack/repack';
+import { NativeWindPlugin } from '@callstack/repack-plugin-nativewind';
+import { ReanimatedPlugin } from '@callstack/repack-plugin-reanimated';
import rspack from '@rspack/core';
+import { buildHostAssetRules } from './hostAssetRules.mts';
import { buildSharedSingletons } from './sharedSingletons.mts';
export default Repack.defineRspackConfig((env) => {
@@ -28,15 +31,19 @@ export default Repack.defineRspackConfig((env) => {
},
type: 'javascript/auto',
},
- ...Repack.getAssetTransformRules({
- inline: true,
- maxInlineSize: 90 * 1024,
- }),
+ ...buildHostAssetRules(context!),
],
},
plugins: [
+ new NativeWindPlugin({
+ cssInteropOptions: { inlineRem: 16 },
+ }),
new Repack.RepackPlugin({
extraChunks: [
+ {
+ include: /^host-async-local$/,
+ type: 'local',
+ },
{
include: /.*/,
type: 'remote',
@@ -62,6 +69,7 @@ export default Repack.defineRspackConfig((env) => {
process.env.WITH_PRELOAD === 'true' ||
process.env.WITH_PRELOAD === '1',
}),
+ new ReanimatedPlugin({ unstable_disableTransform: true }),
],
};
});
diff --git a/apps/tester-federation-v2/configs/webpack.host-app.mts b/apps/tester-federation-v2/configs/webpack.host-app.mts
index 45ac79aed..437a0afea 100644
--- a/apps/tester-federation-v2/configs/webpack.host-app.mts
+++ b/apps/tester-federation-v2/configs/webpack.host-app.mts
@@ -1,7 +1,10 @@
// @ts-check
import * as Repack from '@callstack/repack';
+import { NativeWindPlugin } from '@callstack/repack-plugin-nativewind';
+import { ReanimatedPlugin } from '@callstack/repack-plugin-reanimated';
import webpack from 'webpack';
+import { buildHostAssetRules } from './hostAssetRules.mts';
import { buildSharedSingletons } from './sharedSingletons.mts';
export default Repack.defineWebpackConfig((env) => {
@@ -25,13 +28,19 @@ export default Repack.defineWebpackConfig((env) => {
use: '@callstack/repack/babel-swc-loader',
type: 'javascript/auto',
},
- ...Repack.getAssetTransformRules(),
+ ...buildHostAssetRules(context!),
],
},
plugins: [
- // @ts-expect-error
+ new NativeWindPlugin({
+ cssInteropOptions: { inlineRem: 16 },
+ }),
new Repack.RepackPlugin({
extraChunks: [
+ {
+ include: /^host-async-local$/,
+ type: 'local',
+ },
{
include: /.*/,
type: 'remote',
@@ -39,7 +48,6 @@ export default Repack.defineWebpackConfig((env) => {
},
],
}),
- // @ts-expect-error
new Repack.plugins.ModuleFederationPluginV2({
name: 'HostApp',
filename: 'HostApp.container.js.bundle',
@@ -58,6 +66,7 @@ export default Repack.defineWebpackConfig((env) => {
process.env.WITH_PRELOAD === 'true' ||
process.env.WITH_PRELOAD === '1',
}),
+ new ReanimatedPlugin({ unstable_disableTransform: true }),
],
};
});
diff --git a/apps/tester-federation-v2/configs/webpack.mini-app.mts b/apps/tester-federation-v2/configs/webpack.mini-app.mts
index 59e14fde9..05a9378c8 100644
--- a/apps/tester-federation-v2/configs/webpack.mini-app.mts
+++ b/apps/tester-federation-v2/configs/webpack.mini-app.mts
@@ -28,7 +28,6 @@ export default Repack.defineWebpackConfig((env) => {
],
},
plugins: [
- // @ts-expect-error
new Repack.RepackPlugin({
extraChunks: [
{
@@ -38,7 +37,6 @@ export default Repack.defineWebpackConfig((env) => {
},
],
}),
- // @ts-expect-error
new Repack.plugins.ModuleFederationPluginV2({
name: 'MiniApp',
filename: 'MiniApp.container.js.bundle',
diff --git a/apps/tester-federation-v2/e2e/async-chunk.e2e.ts b/apps/tester-federation-v2/e2e/async-chunk.e2e.ts
new file mode 100644
index 000000000..7f268f751
--- /dev/null
+++ b/apps/tester-federation-v2/e2e/async-chunk.e2e.ts
@@ -0,0 +1,19 @@
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+test('Async chunk loads through ScriptManager and renders', async ({
+ app,
+ screen,
+}) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-async-chunk-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+ await expect(screen.getByTestId('async-chunk-screen')).toBeVisible();
+
+ await expect(screen.getByTestId('async-chunk-content')).toBeVisible();
+});
diff --git a/apps/tester-federation-v2/e2e/gallery.e2e.ts b/apps/tester-federation-v2/e2e/gallery.e2e.ts
new file mode 100644
index 000000000..b5e0b1ee3
--- /dev/null
+++ b/apps/tester-federation-v2/e2e/gallery.e2e.ts
@@ -0,0 +1,21 @@
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+test('Gallery MiniApp loads and renders its own navigator', async ({
+ app,
+ screen,
+}) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-gallery-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+
+ await expect(screen.getByTestId('miniapp-gallery')).toBeVisible({
+ timeout: 45_000,
+ });
+ await expect(screen.getByText('Picture 1')).toBeVisible();
+});
diff --git a/apps/tester-federation-v2/e2e/host-assets.e2e.ts b/apps/tester-federation-v2/e2e/host-assets.e2e.ts
new file mode 100644
index 000000000..27e6ff7b6
--- /dev/null
+++ b/apps/tester-federation-v2/e2e/host-assets.e2e.ts
@@ -0,0 +1,44 @@
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+test('Host Assets screen renders', async ({ app, screen }) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-host-assets-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+ await expect(screen.getByTestId('host-assets-screen')).toBeVisible();
+
+ const pic1Mode = screen.getByTestId('host-asset-pic-1-mode-report');
+ const pic2Mode = screen.getByTestId('host-asset-pic-2-mode-report');
+ const localMode = screen.getByTestId('host-asset-local-mode-report');
+ const inlineMode = screen.getByTestId('host-asset-inline-mode-report');
+ const localUri = screen.getByTestId('host-asset-local-uri-report');
+
+ await screen.scrollUntilVisible(pic1Mode);
+ await expect(pic1Mode).toHaveText('mode: Inlined');
+ await screen.scrollUntilVisible(pic2Mode);
+ await expect(pic2Mode).toHaveText('mode: Emitted');
+ await screen.scrollUntilVisible(localMode);
+ await expect(localMode).toHaveText('mode: Emitted');
+ await screen.scrollUntilVisible(inlineMode);
+ await expect(inlineMode).toHaveText('mode: Inlined');
+
+ await screen.scrollUntilVisible(localUri);
+ const uriText = (await localUri.textContent()) ?? '';
+ const assetUrl = new URL(uriText.slice('uri: '.length));
+ expect(assetUrl.protocol).toBe('http:');
+ expect(assetUrl.port).toBe('8081');
+ // Android resolves the dev server as 10.0.2.2 and iOS as localhost
+ expect(['localhost', '10.0.2.2']).toContain(assetUrl.hostname);
+
+ const probeHost =
+ assetUrl.hostname === '10.0.2.2' ? 'localhost' : assetUrl.hostname;
+ const response = await fetch(
+ `${assetUrl.protocol}//${probeHost}:${assetUrl.port}${assetUrl.pathname}${assetUrl.search}`
+ );
+ expect(response.status).toBe(200);
+});
diff --git a/apps/tester-federation-v2/e2e/miniapp-install.e2e.ts b/apps/tester-federation-v2/e2e/miniapp-install.e2e.ts
new file mode 100644
index 000000000..670eb5a1a
--- /dev/null
+++ b/apps/tester-federation-v2/e2e/miniapp-install.e2e.ts
@@ -0,0 +1,34 @@
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+test('install, show, and remove the host mini-app chunk', async ({
+ app,
+ screen,
+}) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-miniapp-install-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+ await expect(screen.getByTestId('miniapp-install-screen')).toBeVisible();
+
+ await screen.getByTestId('miniapp-install-button').tap();
+ await expect(screen.getByTestId('miniapp-install-button')).toBeDisabled();
+
+ await screen.getByTestId('miniapp-toggle-button').tap();
+ await expect(screen.getByTestId('miniapp-install-content')).toBeVisible();
+
+ await screen.getByTestId('miniapp-toggle-button').tap();
+ await expect(screen.getByTestId('miniapp-install-content')).toBeHidden();
+
+ await screen.getByTestId('miniapp-toggle-button').tap();
+ await expect(screen.getByTestId('miniapp-install-content')).toBeVisible();
+
+ await screen.getByTestId('miniapp-uninstall-button').tap();
+ await expect(screen.getByTestId('miniapp-install-content')).toBeHidden();
+ await expect(screen.getByTestId('miniapp-install-button')).toBeEnabled();
+ await expect(screen.getByTestId('miniapp-toggle-button')).toBeDisabled();
+});
diff --git a/apps/tester-federation-v2/e2e/nativewind.e2e.ts b/apps/tester-federation-v2/e2e/nativewind.e2e.ts
new file mode 100644
index 000000000..8e7a6decc
--- /dev/null
+++ b/apps/tester-federation-v2/e2e/nativewind.e2e.ts
@@ -0,0 +1,30 @@
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+test('NativeWind styles render, respond and switch to dark', async ({
+ app,
+ screen,
+}) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-nativewind-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+ await expect(screen.getByTestId('nativewind-screen')).toBeVisible();
+
+ const padding = screen.getByTestId('nativewind-padding-report');
+ await expect(padding).toHaveText('padding-applied: 16');
+
+ const calcWidth = screen.getByTestId('nativewind-calc-width-report');
+ await expect(calcWidth).toHaveText('calc-width: 248');
+
+ const colorScheme = screen.getByTestId('nativewind-color-scheme-report');
+ await expect(colorScheme).toHaveText('color-scheme: light');
+ await screen.getByTestId('nativewind-dark-mode-button').tap();
+ await expect(colorScheme).toHaveText('color-scheme: dark');
+ await screen.getByTestId('nativewind-dark-mode-button').tap();
+ await expect(colorScheme).toHaveText('color-scheme: light');
+});
diff --git a/apps/tester-federation-v2/e2e/reanimated.e2e.ts b/apps/tester-federation-v2/e2e/reanimated.e2e.ts
new file mode 100644
index 000000000..9f705c5af
--- /dev/null
+++ b/apps/tester-federation-v2/e2e/reanimated.e2e.ts
@@ -0,0 +1,26 @@
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+test('Reanimated spring tracks taps and reset', async ({ app, screen }) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-reanimated-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+ await expect(screen.getByTestId('reanimated-screen')).toBeVisible();
+
+ const width = screen.getByTestId('reanimated-width-report');
+ await expect(width).toHaveText('width: 100');
+
+ await screen.getByTestId('reanimated-click-button').tap();
+ await expect(width).toHaveText('width: 150');
+
+ await screen.getByTestId('reanimated-click-button').tap();
+ await expect(width).toHaveText('width: 200');
+
+ await screen.getByTestId('reanimated-reset-button').tap();
+ await expect(width).toHaveText('width: 100');
+});
diff --git a/apps/tester-federation-v2/e2e/remote-chunk.e2e.ts b/apps/tester-federation-v2/e2e/remote-chunk.e2e.ts
new file mode 100644
index 000000000..9e10f609d
--- /dev/null
+++ b/apps/tester-federation-v2/e2e/remote-chunk.e2e.ts
@@ -0,0 +1,32 @@
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+test('prefetch, load, and invalidate the host remote chunk', async ({
+ app,
+ screen,
+}) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const entry = screen.getByTestId('home-remote-chunk-button');
+ await screen.scrollUntilVisible(entry);
+ await entry.tap();
+ await expect(screen.getByTestId('remote-chunk-screen')).toBeVisible();
+
+ await screen.getByTestId('remote-chunk-prefetch-button').tap();
+ await expect(
+ screen.getByTestId('remote-chunk-prefetch-button')
+ ).toBeDisabled();
+
+ await screen.getByTestId('remote-chunk-load-button').tap();
+ await expect(screen.getByTestId('remote-chunk-content')).toBeVisible();
+
+ await screen.getByTestId('remote-chunk-invalidate-button').tap();
+ await expect(screen.getByTestId('remote-chunk-content')).toBeHidden();
+ await expect(screen.getByTestId('remote-chunk-load-button')).toBeVisible();
+ await expect(
+ screen.getByTestId('remote-chunk-prefetch-button')
+ ).toBeEnabled();
+});
diff --git a/apps/tester-federation-v2/e2e/runtime-events.e2e.ts b/apps/tester-federation-v2/e2e/runtime-events.e2e.ts
new file mode 100644
index 000000000..2584ca3d9
--- /dev/null
+++ b/apps/tester-federation-v2/e2e/runtime-events.e2e.ts
@@ -0,0 +1,51 @@
+import { test } from '@e2e-dev/mobile';
+import { expect } from 'e2e';
+
+const EVENTS = [
+ 'resolving',
+ 'resolved',
+ 'loading',
+ 'loaded',
+ 'beforeResolve',
+ 'afterResolve',
+ 'beforeLoad',
+ 'afterLoad',
+];
+
+test('opening the mini app drives every event and hook', async ({
+ app,
+ screen,
+}) => {
+ await app.open();
+ await expect(screen.getByTestId('home-screen')).toBeVisible({
+ timeout: 30_000,
+ });
+
+ const gallery = screen.getByTestId('home-gallery-button');
+ await screen.scrollUntilVisible(gallery);
+ await gallery.tap();
+ await expect(screen.getByTestId('miniapp-gallery')).toBeVisible({
+ timeout: 45_000,
+ });
+
+ await app.back();
+ const runtime = screen.getByTestId('home-runtime-events-button');
+ await screen.scrollUntilVisible(runtime);
+ await runtime.tap();
+ await expect(screen.getByTestId('runtime-events-screen')).toBeVisible();
+
+ for (const event of EVENTS) {
+ await expect(
+ screen.getByTestId(`runtime-event-${event}-report`)
+ ).toHaveText(new RegExp(`^${event}: [1-9]\\d*$`));
+ }
+
+ await expect(screen.getByTestId('runtime-event-error-report')).toHaveText(
+ 'error: 0'
+ );
+
+ await expect(screen.getByTestId('runtime-events-pipeline-report')).toHaveText(
+ 'pipeline: settled',
+ { timeout: 30_000 }
+ );
+});
diff --git a/apps/tester-federation-v2/e2e/smoke.e2e.ts b/apps/tester-federation-v2/e2e/smoke.e2e.ts
deleted file mode 100644
index 2097ed870..000000000
--- a/apps/tester-federation-v2/e2e/smoke.e2e.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { test } from '@e2e-dev/mobile';
-import { expect } from 'e2e';
-
-test('the host app boots to Home', async ({ app, screen }) => {
- await app.open();
-
- await expect(screen.getByTestId('home-details-button')).toBeVisible({
- timeout: 60_000,
- });
- await expect(screen.getByTestId('home-gallery-button')).toBeVisible();
-});
-
-test('navigates from Home to Detail and back', async ({ app, screen }) => {
- await app.open();
- await expect(screen.getByTestId('home-details-button')).toBeVisible({
- timeout: 60_000,
- });
-
- await screen.getByTestId('home-details-button').tap();
- await expect(screen.getByTestId('detail-screen')).toBeVisible();
-
- await app.back();
- await expect(screen.getByTestId('home-details-button')).toBeVisible();
- await expect(screen.getByTestId('detail-screen')).toBeHidden();
-});
-
-test('Home opens the Gallery MiniApp', {
- timeout: 120_000,
-}, async ({ app, screen }) => {
- await app.open();
- await expect(screen.getByTestId('home-gallery-button')).toBeVisible({
- timeout: 60_000,
- });
-
- await screen.getByTestId('home-gallery-button').tap();
- await expect(screen.getByTestId('miniapp-gallery')).toBeVisible({
- timeout: 45_000,
- });
- await expect(screen.getByText('Picture 1')).toBeVisible();
-});
diff --git a/apps/tester-federation-v2/global.css b/apps/tester-federation-v2/global.css
new file mode 100644
index 000000000..14df9aa59
--- /dev/null
+++ b/apps/tester-federation-v2/global.css
@@ -0,0 +1,26 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+.custom-style-test {
+ color: mediumpurple;
+}
+
+.calc-element {
+ width: calc(var(--my-variable) - (20px + 2rem));
+}
+
+:root {
+ --H: 200;
+ --S: 50%;
+ --L: 40%;
+ --my-variable: 300px;
+}
+
+.color-element {
+ background-color: hsl(
+ calc(var(--H) + 20),
+ calc(var(--S) - 10%),
+ calc(var(--L) + 30%)
+ );
+}
diff --git a/apps/tester-federation-v2/ios/Podfile.lock b/apps/tester-federation-v2/ios/Podfile.lock
index cf9ca48e6..b471e7911 100644
--- a/apps/tester-federation-v2/ios/Podfile.lock
+++ b/apps/tester-federation-v2/ios/Podfile.lock
@@ -1891,6 +1891,109 @@ PODS:
- React-Core
- React-jsi
- ReactTestApp-Resources (1.0.0-dev)
+ - RNReanimated (4.7.1):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - RNReanimated/apple (= 4.7.1)
+ - RNReanimated/common (= 4.7.1)
+ - RNReanimated/view (= 4.7.1)
+ - RNWorklets
+ - Yoga
+ - RNReanimated/apple (4.7.1):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - RNWorklets
+ - Yoga
+ - RNReanimated/common (4.7.1):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - RNWorklets
+ - Yoga
+ - RNReanimated/view (4.7.1):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - RNWorklets
+ - Yoga
- RNScreens (4.30.0):
- hermes-engine
- RCTRequired
@@ -1940,6 +2043,127 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
+ - RNSVG (15.15.5):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - RNSVG/common (= 15.15.5)
+ - Yoga
+ - RNSVG/common (15.15.5):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - RNWorklets (0.13.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - RNWorklets/apple (= 0.13.0)
+ - RNWorklets/common (= 0.13.0)
+ - Yoga
+ - RNWorklets/apple (0.13.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
+ - RNWorklets/common (0.13.0):
+ - hermes-engine
+ - RCTRequired
+ - RCTTypeSafety
+ - React-bridging
+ - React-Core
+ - React-Core-prebuilt
+ - React-cxxstableapi
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/core
+ - ReactNativeDependencies
+ - Yoga
- SocketRocket (0.7.1):
- ReactNativeDependencies
- SwiftyRSA (1.7.0):
@@ -2040,7 +2264,10 @@ DEPENDENCIES:
- "ReactNativeHost (from `../../../node_modules/.pnpm/react-native-test-app@5.4.14_@expo+config-plugins@58.0.9_typescript@7.0.2__@react-nativ_454696e0707abccdd63dcea849d4f6b7/node_modules/@rnx-kit/react-native-host`)"
- ReactTestApp-DevSupport (from `../node_modules/react-native-test-app`)
- ReactTestApp-Resources (from `..`)
+ - RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`)
+ - RNSVG (from `../node_modules/react-native-svg`)
+ - RNWorklets (from `../node_modules/react-native-worklets`)
- SocketRocket (from `build/rndeps-facades/SocketRocket`)
- Yoga (from `build/rncore-facades/Yoga`)
@@ -2231,8 +2458,14 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-test-app"
ReactTestApp-Resources:
:path: ".."
+ RNReanimated:
+ :path: "../node_modules/react-native-reanimated"
RNScreens:
:path: "../node_modules/react-native-screens"
+ RNSVG:
+ :path: "../node_modules/react-native-svg"
+ RNWorklets:
+ :path: "../node_modules/react-native-worklets"
SocketRocket:
:path: build/rndeps-facades/SocketRocket
Yoga:
@@ -2330,7 +2563,10 @@ SPEC CHECKSUMS:
ReactNativeHost: 12775fd18bc0260080b2a4196dc0522476dde0ce
ReactTestApp-DevSupport: 92d3bc3acb9ddf9f90f96976add4577957be1d90
ReactTestApp-Resources: 88392c1cd85eed4e50cc2798de87b790a9791c7f
+ RNReanimated: b38c0bcd07f37ce6a7b85baf7ad29fbe7eef5f0b
RNScreens: 6d0d3b7819284dbf9f222899eb819f069fce4b8b
+ RNSVG: 8b5f2fdb9f0245a863cdd6e9c48a7689821a08b0
+ RNWorklets: ac481cd68c74e369c2835d7cb070cf7c70918c95
SocketRocket: 37aec555668fb852ec12e3c0de59a86ca58f0871
SwiftyRSA: 8c6dd1ea7db1b8dc4fb517a202f88bb1354bc2c6
Yoga: d1c536142c5ff8ec8cd856ab2a7c227a1d875c8e
diff --git a/apps/tester-federation-v2/nativewind-env.d.ts b/apps/tester-federation-v2/nativewind-env.d.ts
new file mode 100644
index 000000000..958346287
--- /dev/null
+++ b/apps/tester-federation-v2/nativewind-env.d.ts
@@ -0,0 +1,3 @@
+///
+
+// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
diff --git a/apps/tester-federation-v2/package.json b/apps/tester-federation-v2/package.json
index ed90c3d62..22de2dfdb 100644
--- a/apps/tester-federation-v2/package.json
+++ b/apps/tester-federation-v2/package.json
@@ -20,13 +20,25 @@
"@react-native-async-storage/async-storage": "catalog:testers",
"@react-navigation/native": "7.3.14",
"@react-navigation/native-stack": "7.18.6",
+ "@rn-primitives/slot": "^1.2.0",
+ "@rn-primitives/types": "^1.2.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "nativewind": "^4.2.6",
"react": "catalog:",
"react-native": "catalog:",
+ "react-native-css-interop": "0.2.6",
+ "react-native-reanimated": "catalog:testers",
"react-native-safe-area-context": "catalog:testers",
- "react-native-screens": "catalog:testers"
+ "react-native-screens": "catalog:testers",
+ "react-native-svg": "15.15.5",
+ "react-native-worklets": "catalog:testers",
+ "tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@babel/core": "^7.29.7",
+ "@callstack/repack-plugin-nativewind": "workspace:*",
+ "@callstack/repack-plugin-reanimated": "workspace:*",
"@e2e-dev/mobile": "catalog:testers",
"@react-native-community/cli": "catalog:testers",
"@react-native-community/cli-platform-android": "catalog:testers",
@@ -37,12 +49,17 @@
"@react-native/typescript-config": "catalog:testers",
"@rsdoctor/rspack-plugin": "1.6.4",
"@rspack/core": "catalog:",
+ "@svgr/webpack": "^8.1.0",
"@swc/helpers": "catalog:",
"@types/jest": "^29.5.13",
"@types/node": "catalog:",
"@types/react": "catalog:testers",
+ "autoprefixer": "^10.4.20",
"e2e": "catalog:testers",
+ "postcss": "^8.5.23",
+ "postcss-loader": "^8.1.1",
"react-native-test-app": "catalog:testers",
+ "tailwindcss": "^3.4.17",
"terser-webpack-plugin": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:"
diff --git a/apps/tester-federation-v2/src/globals.d.ts b/apps/tester-federation-v2/src/globals.d.ts
index f51151309..8e43034ec 100644
--- a/apps/tester-federation-v2/src/globals.d.ts
+++ b/apps/tester-federation-v2/src/globals.d.ts
@@ -1,5 +1,16 @@
declare const __WITH_PRELOAD__: boolean;
+declare module '*.svg' {
+ import type { ComponentType } from 'react';
+
+ const SvgComponent: ComponentType<{
+ width?: number | string;
+ height?: number | string;
+ color?: string;
+ }>;
+ export default SvgComponent;
+}
+
declare module 'MiniApp/MiniAppNavigator' {
import type { ComponentType } from 'react';
diff --git a/apps/tester-federation-v2/src/host/FEATURES.ts b/apps/tester-federation-v2/src/host/FEATURES.ts
new file mode 100644
index 000000000..9cf08456d
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/FEATURES.ts
@@ -0,0 +1,61 @@
+export const FEATURE_GROUPS = [
+ 'Runtime',
+ 'Mini Apps',
+ 'Chunks',
+ 'Plugins',
+] as const;
+
+export type FeatureGroup = (typeof FEATURE_GROUPS)[number];
+
+export type Feature = {
+ key: string;
+ name: string;
+ group: FeatureGroup;
+ screen: keyof import('./navigation/MainNavigator').MainStackParamList;
+};
+
+export const FEATURES: Feature[] = [
+ { key: 'gallery', name: 'Gallery', group: 'Mini Apps', screen: 'MiniApp' },
+ {
+ key: 'host-assets',
+ name: 'Host assets',
+ group: 'Runtime',
+ screen: 'HostAssets',
+ },
+ {
+ key: 'runtime-events',
+ name: 'Runtime events',
+ group: 'Runtime',
+ screen: 'RuntimeEvents',
+ },
+ {
+ key: 'async-chunk',
+ name: 'Async chunk',
+ group: 'Chunks',
+ screen: 'AsyncChunk',
+ },
+ {
+ key: 'remote-chunk',
+ name: 'Remote chunk',
+ group: 'Chunks',
+ screen: 'RemoteChunk',
+ },
+ {
+ key: 'miniapp-install',
+ name: 'Mini-app install',
+ group: 'Chunks',
+ screen: 'MiniAppInstall',
+ },
+ {
+ key: 'nativewind',
+ name: 'NativeWind',
+ group: 'Plugins',
+ screen: 'NativeWind',
+ },
+ {
+ key: 'reanimated',
+ name: 'Reanimated',
+ group: 'Plugins',
+ screen: 'Reanimated',
+ },
+];
diff --git a/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack.png b/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack.png
new file mode 100644
index 000000000..352e4631e
Binary files /dev/null and b/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack.png differ
diff --git a/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack@2x.png b/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack@2x.png
new file mode 100644
index 000000000..23b99fc52
Binary files /dev/null and b/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack@2x.png differ
diff --git a/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack@3x.png b/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack@3x.png
new file mode 100644
index 000000000..3719056cc
Binary files /dev/null and b/apps/tester-federation-v2/src/host/assets/inlineAssets/webpack@3x.png differ
diff --git a/apps/tester-federation-v2/src/host/assets/localAssets/webpack.png b/apps/tester-federation-v2/src/host/assets/localAssets/webpack.png
new file mode 100644
index 000000000..352e4631e
Binary files /dev/null and b/apps/tester-federation-v2/src/host/assets/localAssets/webpack.png differ
diff --git a/apps/tester-federation-v2/src/host/assets/localAssets/webpack@2x.png b/apps/tester-federation-v2/src/host/assets/localAssets/webpack@2x.png
new file mode 100644
index 000000000..23b99fc52
Binary files /dev/null and b/apps/tester-federation-v2/src/host/assets/localAssets/webpack@2x.png differ
diff --git a/apps/tester-federation-v2/src/host/assets/localAssets/webpack@3x.png b/apps/tester-federation-v2/src/host/assets/localAssets/webpack@3x.png
new file mode 100644
index 000000000..3719056cc
Binary files /dev/null and b/apps/tester-federation-v2/src/host/assets/localAssets/webpack@3x.png differ
diff --git a/apps/tester-federation-v2/src/host/assets/pic_3.jpg b/apps/tester-federation-v2/src/host/assets/pic_3.jpg
deleted file mode 100644
index 29f6093e8..000000000
Binary files a/apps/tester-federation-v2/src/host/assets/pic_3.jpg and /dev/null differ
diff --git a/apps/tester-federation-v2/src/host/assets/repack-logo.svg b/apps/tester-federation-v2/src/host/assets/repack-logo.svg
new file mode 100644
index 000000000..f6b2cd3de
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/assets/repack-logo.svg
@@ -0,0 +1,4 @@
+
diff --git a/apps/tester-federation-v2/src/host/features/async/Async.local.tsx b/apps/tester-federation-v2/src/host/features/async/Async.local.tsx
new file mode 100644
index 000000000..32e54d35d
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/async/Async.local.tsx
@@ -0,0 +1,21 @@
+import { StyleSheet, Text, View } from 'react-native';
+
+export default function AsyncLocal() {
+ return (
+
+
+ Async: this text comes from async chunk
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ },
+ text: {
+ fontSize: 16,
+ color: '#200a12',
+ },
+});
diff --git a/apps/tester-federation-v2/src/host/features/async/AsyncChunkSection.tsx b/apps/tester-federation-v2/src/host/features/async/AsyncChunkSection.tsx
new file mode 100644
index 000000000..1ddfa9cea
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/async/AsyncChunkSection.tsx
@@ -0,0 +1,34 @@
+import * as React from 'react';
+import { ActivityIndicator, StyleSheet, View } from 'react-native';
+
+const AsyncLocal = React.lazy(
+ () => import(/* webpackChunkName: "host-async-local" */ './Async.local')
+);
+
+const Fallback = () => (
+
+
+
+);
+
+const AsyncChunkSection = () => {
+ return (
+
+ }>
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ alignSelf: 'stretch',
+ paddingVertical: 12,
+ },
+ fallback: {
+ paddingVertical: 24,
+ },
+});
+
+export default AsyncChunkSection;
diff --git a/apps/tester-federation-v2/src/host/features/miniapp/MiniApp.tsx b/apps/tester-federation-v2/src/host/features/miniapp/MiniApp.tsx
new file mode 100644
index 000000000..c72bf92eb
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/miniapp/MiniApp.tsx
@@ -0,0 +1,21 @@
+import { StyleSheet, Text, View } from 'react-native';
+
+export default function MiniAppLocal() {
+ return (
+
+
+ MiniApp: this text comes from MiniApp chunk
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ },
+ text: {
+ fontSize: 16,
+ color: '#200a12',
+ },
+});
diff --git a/apps/tester-federation-v2/src/host/features/miniapp/MiniAppSection.tsx b/apps/tester-federation-v2/src/host/features/miniapp/MiniAppSection.tsx
new file mode 100644
index 000000000..62f6d5825
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/miniapp/MiniAppSection.tsx
@@ -0,0 +1,66 @@
+import { ScriptManager } from '@callstack/repack/client';
+import * as React from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import ActionButton from '../../ui/ActionButton';
+
+const MiniAppChunkId = 'miniapp';
+
+const MiniAppSection = () => {
+ const [isInstalled, setIsInstalled] = React.useState(false);
+ const [isVisible, setIsVisible] = React.useState(false);
+ const [MiniAppComponent, setMiniAppComponent] = React.useState<
+ React.ComponentType | undefined
+ >(undefined);
+
+ const install = async () => {
+ const bundle = await import(/* webpackChunkName: "miniapp" */ './MiniApp');
+ setMiniAppComponent(() => bundle.default);
+ setIsInstalled(true);
+ };
+
+ const uninstall = async () => {
+ await ScriptManager.shared.invalidateScripts([MiniAppChunkId]);
+ setMiniAppComponent(undefined);
+ setIsInstalled(false);
+ setIsVisible(false);
+ };
+
+ return (
+
+
+
+ setIsVisible((visible) => !visible)}
+ />
+ {isInstalled && isVisible && MiniAppComponent ? (
+
+ ) : null}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ alignSelf: 'stretch',
+ paddingVertical: 12,
+ gap: 8,
+ },
+});
+
+export default MiniAppSection;
diff --git a/apps/tester-federation-v2/src/host/features/nativewind/Basic.tsx b/apps/tester-federation-v2/src/host/features/nativewind/Basic.tsx
new file mode 100644
index 000000000..788c0ceb1
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/nativewind/Basic.tsx
@@ -0,0 +1,14 @@
+import { Text, View } from 'react-native';
+
+export default function Basic() {
+ return (
+
+
+ Colored through CSS!
+
+
+ );
+}
diff --git a/apps/tester-federation-v2/src/host/features/nativewind/ComponentWithVariants.tsx b/apps/tester-federation-v2/src/host/features/nativewind/ComponentWithVariants.tsx
new file mode 100644
index 000000000..e47dfbfff
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/nativewind/ComponentWithVariants.tsx
@@ -0,0 +1,25 @@
+import type React from 'react';
+import { Text } from 'react-native';
+
+const variantStyles = {
+ default: 'rounded',
+ primary: 'bg-blue-500 text-white',
+};
+
+type Props = {
+ variant: keyof typeof variantStyles;
+ className?: string;
+} & React.ComponentProps;
+
+export default function ComponentWithVariants({
+ variant,
+ className,
+ ...props
+}: Props) {
+ const merged = [variantStyles.default, variantStyles[variant], className]
+ .filter(Boolean)
+ .join(' ');
+ return (
+
+ );
+}
diff --git a/apps/tester-federation-v2/src/host/features/nativewind/CustomComponent.tsx b/apps/tester-federation-v2/src/host/features/nativewind/CustomComponent.tsx
new file mode 100644
index 000000000..4b9411362
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/nativewind/CustomComponent.tsx
@@ -0,0 +1,14 @@
+import { Text } from 'react-native';
+
+import { cn } from './ui/utils';
+
+export default function CustomComponent({ className }: { className: string }) {
+ return (
+
+ I am a custom component
+
+ );
+}
diff --git a/apps/tester-federation-v2/src/host/features/nativewind/DarkMode.tsx b/apps/tester-federation-v2/src/host/features/nativewind/DarkMode.tsx
new file mode 100644
index 000000000..ddec886d5
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/nativewind/DarkMode.tsx
@@ -0,0 +1,27 @@
+import { colorScheme } from 'nativewind';
+import { Button, Text, View } from 'react-native';
+
+export default function DarkMode() {
+ return (
+
+
+ );
+}
diff --git a/apps/tester-federation-v2/src/host/features/nativewind/FuncsDirs.tsx b/apps/tester-federation-v2/src/host/features/nativewind/FuncsDirs.tsx
new file mode 100644
index 000000000..f76411411
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/nativewind/FuncsDirs.tsx
@@ -0,0 +1,33 @@
+import { vars } from 'nativewind';
+import * as React from 'react';
+import { type LayoutChangeEvent, Text, View } from 'react-native';
+
+export default function FuncsDirs({
+ onCalcWidth,
+}: {
+ onCalcWidth: (width: number) => void;
+}) {
+ const handleLayout = React.useCallback(
+ (event: LayoutChangeEvent) => {
+ onCalcWidth(Math.round(event.nativeEvent.layout.width));
+ },
+ [onCalcWidth]
+ );
+
+ return (
+ <>
+
+
+ Custom color
+
+
+
+ Calculated size
+
+ >
+ );
+}
diff --git a/apps/tester-federation-v2/src/host/features/nativewind/PaddingProbe.tsx b/apps/tester-federation-v2/src/host/features/nativewind/PaddingProbe.tsx
new file mode 100644
index 000000000..9d1b44f03
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/nativewind/PaddingProbe.tsx
@@ -0,0 +1,27 @@
+import * as React from 'react';
+import { type LayoutChangeEvent, Text, View } from 'react-native';
+
+export default function PaddingProbe({
+ onWidth,
+}: {
+ onWidth: (width: number) => void;
+}) {
+ const handleLayout = React.useCallback(
+ (event: LayoutChangeEvent) => {
+ onWidth(Math.round(event.nativeEvent.layout.width));
+ },
+ [onWidth]
+ );
+
+ return (
+
+
+ The container's padding narrows this box
+
+
+ );
+}
diff --git a/apps/tester-federation-v2/src/host/features/nativewind/Reusables.tsx b/apps/tester-federation-v2/src/host/features/nativewind/Reusables.tsx
new file mode 100644
index 000000000..f047847d2
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/nativewind/Reusables.tsx
@@ -0,0 +1,12 @@
+import { Button } from './ui/Button';
+import { Text } from './ui/Text';
+
+export default function Reusables() {
+ return (
+
+ );
+}
diff --git a/apps/tester-federation-v2/src/host/features/nativewind/ui/Button.tsx b/apps/tester-federation-v2/src/host/features/nativewind/ui/Button.tsx
new file mode 100644
index 000000000..19693965a
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/nativewind/ui/Button.tsx
@@ -0,0 +1,81 @@
+import { cva, type VariantProps } from 'class-variance-authority';
+import * as React from 'react';
+import { Pressable } from 'react-native';
+import { TextClassContext } from './Text';
+import { cn } from './utils';
+
+const buttonVariants = cva(
+ 'group flex items-center justify-center rounded-md',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary active:opacity-90',
+ primary: 'bg-primary active:opacity-90',
+ },
+ size: {
+ default: 'h-10 px-4 py-2 native:h-12 native:px-5 native:py-3',
+ sm: 'h-9 rounded-md px-3',
+ lg: 'h-11 rounded-md px-8 native:h-14',
+ icon: 'h-10 w-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ }
+);
+
+const buttonTextVariants = cva(
+ 'web:whitespace-nowrap text-sm native:text-base font-medium text-foreground web:transition-colors',
+ {
+ variants: {
+ variant: {
+ default: 'text-primary-foreground',
+ primary: 'text-primary-foreground',
+ },
+ size: {
+ default: '',
+ sm: '',
+ lg: 'native:text-lg',
+ icon: '',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ }
+);
+
+type ButtonProps = React.ComponentPropsWithoutRef &
+ VariantProps;
+
+const Button = React.forwardRef<
+ React.ElementRef,
+ ButtonProps
+>(({ className, variant, size, ...props }, ref) => {
+ return (
+
+
+
+ );
+});
+Button.displayName = 'Button';
+
+export type { ButtonProps };
+export { Button, buttonTextVariants, buttonVariants };
diff --git a/apps/tester-federation-v2/src/host/features/nativewind/ui/Text.tsx b/apps/tester-federation-v2/src/host/features/nativewind/ui/Text.tsx
new file mode 100644
index 000000000..a933f0829
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/nativewind/ui/Text.tsx
@@ -0,0 +1,28 @@
+import * as Slot from '@rn-primitives/slot';
+import type { SlottableTextProps, TextRef } from '@rn-primitives/types';
+import * as React from 'react';
+import { Text as RNText } from 'react-native';
+import { cn } from './utils';
+
+const TextClassContext = React.createContext(undefined);
+
+const Text = React.forwardRef(
+ ({ className, asChild = false, ...props }, ref) => {
+ const textClass = React.useContext(TextClassContext);
+ const Component = asChild ? Slot.Text : RNText;
+ return (
+
+ );
+ }
+);
+Text.displayName = 'Text';
+
+export { Text, TextClassContext };
diff --git a/apps/tester-federation-v2/src/host/features/nativewind/ui/utils.ts b/apps/tester-federation-v2/src/host/features/nativewind/ui/utils.ts
new file mode 100644
index 000000000..9ad0df426
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/nativewind/ui/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/apps/tester-federation-v2/src/host/features/reanimated/ReanimatedBox.tsx b/apps/tester-federation-v2/src/host/features/reanimated/ReanimatedBox.tsx
new file mode 100644
index 000000000..e8653e05a
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/reanimated/ReanimatedBox.tsx
@@ -0,0 +1,80 @@
+import * as React from 'react';
+import { Button, StyleSheet, View } from 'react-native';
+import Animated, {
+ useAnimatedReaction,
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated';
+import { scheduleOnRN } from 'react-native-worklets';
+
+const BASE_WIDTH = 100;
+
+export default function ReanimatedBox({
+ onWidthChange,
+}: {
+ onWidthChange: (width: number) => void;
+}) {
+ const width = useSharedValue(BASE_WIDTH);
+ const target = React.useRef(BASE_WIDTH);
+
+ const boxStyle = useAnimatedStyle(() => {
+ 'worklet';
+ return { width: width.value };
+ });
+
+ useAnimatedReaction(
+ () => width.value,
+ (_previous, next) => {
+ 'worklet';
+ if (next !== null) {
+ scheduleOnRN(onWidthChange, Math.round(next));
+ }
+ }
+ );
+
+ const handlePress = () => {
+ target.current += 50;
+ width.value = withSpring(target.current);
+ };
+
+ const handleReset = () => {
+ target.current = BASE_WIDTH;
+ width.value = withSpring(target.current);
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ marginVertical: 16,
+ },
+ box: {
+ height: 100,
+ backgroundColor: '#b58df1',
+ borderRadius: 20,
+ marginVertical: 0,
+ },
+ buttons: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ },
+});
diff --git a/apps/tester-federation-v2/src/host/features/remote/Remote.tsx b/apps/tester-federation-v2/src/host/features/remote/Remote.tsx
new file mode 100644
index 000000000..ab95161b1
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/remote/Remote.tsx
@@ -0,0 +1,21 @@
+import { StyleSheet, Text, View } from 'react-native';
+
+export default function Remote() {
+ return (
+
+
+ Remote: this text comes from remote chunk
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ },
+ text: {
+ fontSize: 16,
+ color: '#200a12',
+ },
+});
diff --git a/apps/tester-federation-v2/src/host/features/remote/RemoteChunkSection.tsx b/apps/tester-federation-v2/src/host/features/remote/RemoteChunkSection.tsx
new file mode 100644
index 000000000..146a0ef8b
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/remote/RemoteChunkSection.tsx
@@ -0,0 +1,79 @@
+import { ScriptManager } from '@callstack/repack/client';
+import * as React from 'react';
+import { ActivityIndicator, StyleSheet, View } from 'react-native';
+
+import ActionButton from '../../ui/ActionButton';
+
+const RemoteChunkId = 'remote';
+
+const Remote = React.lazy(
+ () => import(/* webpackChunkName: "remote" */ './Remote')
+);
+
+const Fallback = () => (
+
+
+
+);
+
+const RemoteChunkSection = () => {
+ const [isPrefetched, setIsPrefetched] = React.useState(false);
+ const [isLoaded, setIsLoaded] = React.useState(false);
+
+ const prefetch = async () => {
+ await ScriptManager.shared.prefetchScript(RemoteChunkId);
+ setIsPrefetched(true);
+ };
+
+ const invalidate = async () => {
+ await ScriptManager.shared.invalidateScripts([RemoteChunkId]);
+ setIsLoaded(false);
+ setIsPrefetched(false);
+ };
+
+ return (
+
+ {isLoaded ? (
+ }>
+
+
+ ) : (
+
+
+ setIsLoaded(true)}
+ />
+
+ )}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ alignSelf: 'stretch',
+ paddingVertical: 12,
+ gap: 8,
+ },
+ actions: {
+ gap: 8,
+ },
+ fallback: {
+ paddingVertical: 24,
+ },
+});
+
+export default RemoteChunkSection;
diff --git a/apps/tester-federation-v2/src/host/features/runtime/events.ts b/apps/tester-federation-v2/src/host/features/runtime/events.ts
new file mode 100644
index 000000000..856f0cf15
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/features/runtime/events.ts
@@ -0,0 +1,138 @@
+import { ScriptManager } from '@callstack/repack/client';
+
+export const EVENT_NAMES = [
+ 'resolving',
+ 'resolved',
+ 'prefetching',
+ 'loading',
+ 'loaded',
+ 'error',
+ 'beforeResolve',
+ 'afterResolve',
+ 'beforeLoad',
+ 'afterLoad',
+] as const;
+
+export type ScriptManagerCounts = Record<(typeof EVENT_NAMES)[number], number>;
+
+const counts = Object.fromEntries(
+ EVENT_NAMES.map((name) => [name, 0])
+) as ScriptManagerCounts;
+
+const listeners = new Set<(counts: ScriptManagerCounts) => void>();
+
+export function subscribeEventCounts(
+ listener: (counts: ScriptManagerCounts) => void
+) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+export function readEventCounts(): ScriptManagerCounts {
+ return { ...counts };
+}
+
+function bump(key: keyof ScriptManagerCounts) {
+ counts[key] += 1;
+ for (const listener of listeners) {
+ listener(readEventCounts());
+ }
+}
+
+const RESOLVE_STAGES: Array = [
+ 'beforeResolve',
+ 'resolving',
+ 'afterResolve',
+ 'resolved',
+];
+
+const LOAD_STAGES: Array = [
+ 'beforeLoad',
+ 'loading',
+ 'afterLoad',
+ 'loaded',
+];
+
+export type PipelineStatus = {
+ settled: boolean;
+ label: string;
+};
+
+function findStuckStage(
+ counts: ScriptManagerCounts,
+ stages: Array,
+ expected: number
+) {
+ const stage = stages.find((name) => counts[name] !== expected);
+ return stage === undefined ? undefined : { stage, expected };
+}
+
+export function getPipelineStatus(counts: ScriptManagerCounts): PipelineStatus {
+ if (counts.error > 0) {
+ return { settled: false, label: `error=${counts.error}` };
+ }
+
+ if (counts.beforeResolve === 0) {
+ return { settled: false, label: 'idle' };
+ }
+
+ const loaded = counts.beforeResolve - counts.prefetching;
+
+ const stuck =
+ findStuckStage(counts, RESOLVE_STAGES, counts.beforeResolve) ??
+ findStuckStage(counts, LOAD_STAGES, loaded);
+
+ if (stuck === undefined) {
+ return { settled: true, label: 'settled' };
+ }
+
+ return {
+ settled: false,
+ label: `${stuck.stage}=${counts[stuck.stage]} of ${stuck.expected}`,
+ };
+}
+
+function countEvent(event: keyof ScriptManagerCounts) {
+ return (...args: unknown[]) => {
+ console.debug(`ScriptManagerEvent:${event}`, ...args);
+ bump(event);
+ };
+}
+
+export function registerEventCounter() {
+ ScriptManager.shared.on('resolving', countEvent('resolving'));
+ ScriptManager.shared.on('resolved', countEvent('resolved'));
+ ScriptManager.shared.on('prefetching', countEvent('prefetching'));
+ ScriptManager.shared.on('loading', countEvent('loading'));
+ ScriptManager.shared.on('loaded', countEvent('loaded'));
+ ScriptManager.shared.on('error', countEvent('error'));
+ ScriptManager.shared.on('invalidated', (scriptIds) => {
+ console.debug('ScriptManagerEvent:invalidated', scriptIds);
+ });
+
+ ScriptManager.shared.hooks.beforeResolve((args) => {
+ console.debug('ScriptManager.shared.hooks.beforeResolve', args.options);
+ bump('beforeResolve');
+ return args;
+ });
+
+ ScriptManager.shared.hooks.afterResolve((args) => {
+ console.debug('ScriptManager.shared.hooks.afterResolve', args.options);
+ bump('afterResolve');
+ return args;
+ });
+
+ ScriptManager.shared.hooks.beforeLoad((args) => {
+ console.debug('ScriptManager.shared.hooks.beforeLoad', args.options);
+ bump('beforeLoad');
+ return args;
+ });
+
+ ScriptManager.shared.hooks.afterLoad((args) => {
+ console.debug('ScriptManager.shared.hooks.afterLoad', args.options);
+ bump('afterLoad');
+ return args;
+ });
+}
diff --git a/apps/tester-federation-v2/src/host/index.js b/apps/tester-federation-v2/src/host/index.js
index 61610d410..4956f1c93 100644
--- a/apps/tester-federation-v2/src/host/index.js
+++ b/apps/tester-federation-v2/src/host/index.js
@@ -1,11 +1,16 @@
import { ScriptManager } from '@callstack/repack/client';
import { preloadRemote } from '@module-federation/runtime';
import AsyncStorage from '@react-native-async-storage/async-storage';
-import { AppRegistry } from 'react-native';
+import { AppRegistry, LogBox } from 'react-native';
+import '../../global.css';
import { components } from '../../app.json';
+import './setup';
import App from './App';
+// the toast covers Home's last row and steals taps from the e2e suite
+LogBox.ignoreLogs(['ImageBackground is deprecated']);
+
if (__WITH_PRELOAD__) {
// enable caching of scripts in the AsyncStorage
ScriptManager.shared.setStorage(AsyncStorage);
diff --git a/apps/tester-federation-v2/src/host/navigation/MainNavigator.tsx b/apps/tester-federation-v2/src/host/navigation/MainNavigator.tsx
index 2c544c03e..f2a46ad84 100644
--- a/apps/tester-federation-v2/src/host/navigation/MainNavigator.tsx
+++ b/apps/tester-federation-v2/src/host/navigation/MainNavigator.tsx
@@ -4,16 +4,26 @@ import {
} from '@react-navigation/native-stack';
import { StyleSheet } from 'react-native';
-import DetailScreen from '../screens/DetailScreen';
+import AsyncChunkScreen from '../screens/AsyncChunkScreen';
import HomeScreen from '../screens/HomeScreen';
import HostAssetsScreen from '../screens/HostAssetsScreen';
+import MiniAppInstallScreen from '../screens/MiniAppInstallScreen.tsx';
import MiniAppScreen from '../screens/MiniAppScreen';
+import NativeWindScreen from '../screens/NativeWindScreen';
+import ReanimatedScreen from '../screens/ReanimatedScreen';
+import RemoteChunkScreen from '../screens/RemoteChunkScreen';
+import RuntimeEventsScreen from '../screens/RuntimeEventsScreen';
export type MainStackParamList = {
Home: undefined;
- Detail: undefined;
HostAssets: undefined;
MiniApp: undefined;
+ RuntimeEvents: undefined;
+ AsyncChunk: undefined;
+ RemoteChunk: undefined;
+ MiniAppInstall: undefined;
+ NativeWind: undefined;
+ Reanimated: undefined;
};
export type MainStackNavigationProp =
@@ -30,12 +40,13 @@ const MainNavigator = () => {
headerBackTitle: 'Back',
headerLargeTitle: true,
headerStyle: styles.header,
+ headerLargeStyle: styles.largeHeader,
headerTitleStyle: styles.headerTitle,
+ headerLargeTitleStyle: styles.largeTitle,
headerTintColor: 'rgba(255,255,255,1)',
}}
>
-
{
component={MiniAppScreen}
options={{ headerStyle: { backgroundColor: '#FF9F00' } }}
/>
+
+
+
+
+
+
);
};
@@ -65,6 +82,12 @@ const styles = StyleSheet.create({
headerTitle: {
color: '#FFFFFF', // White text
},
+ largeHeader: {
+ backgroundColor: 'transparent',
+ },
+ largeTitle: {
+ color: '#2C3E50',
+ },
hostAssetsLargeHeader: {
backgroundColor: 'transparent',
},
diff --git a/apps/tester-federation-v2/src/host/screens/AsyncChunkScreen.tsx b/apps/tester-federation-v2/src/host/screens/AsyncChunkScreen.tsx
new file mode 100644
index 000000000..7daa90291
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/screens/AsyncChunkScreen.tsx
@@ -0,0 +1,28 @@
+import { ScrollView, StyleSheet, View } from 'react-native';
+
+import AsyncChunkSection from '../features/async/AsyncChunkSection';
+
+const AsyncChunkScreen = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ content: {
+ paddingVertical: 16,
+ },
+ container: {
+ paddingHorizontal: 16,
+ gap: 4,
+ },
+});
+
+export default AsyncChunkScreen;
diff --git a/apps/tester-federation-v2/src/host/screens/DetailScreen.tsx b/apps/tester-federation-v2/src/host/screens/DetailScreen.tsx
deleted file mode 100644
index 3097b001a..000000000
--- a/apps/tester-federation-v2/src/host/screens/DetailScreen.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { StyleSheet, Text, View } from 'react-native';
-
-const DetailScreen = () => {
- return (
-
- DetailScreen
-
- );
-};
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- gap: 10,
- justifyContent: 'center',
- alignItems: 'center',
- },
- label: {
- fontSize: 20,
- fontWeight: '700',
- },
-});
-
-export default DetailScreen;
diff --git a/apps/tester-federation-v2/src/host/screens/HomeScreen.tsx b/apps/tester-federation-v2/src/host/screens/HomeScreen.tsx
index 20dfbb5b6..4e4ed7bf1 100644
--- a/apps/tester-federation-v2/src/host/screens/HomeScreen.tsx
+++ b/apps/tester-federation-v2/src/host/screens/HomeScreen.tsx
@@ -1,175 +1,84 @@
import { useNavigation } from '@react-navigation/native';
-import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import {
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+
+import { FEATURE_GROUPS, FEATURES } from '../FEATURES';
import type { MainStackNavigationProp } from '../navigation/MainNavigator';
const HomeScreen = () => {
const navigation = useNavigation();
return (
-
-
- Welcome Home
-
- Explore the host app and its features
-
- navigation.navigate('Detail')}
- >
- See details
-
- navigation.navigate('HostAssets')}
- >
- Host assets
-
-
-
- Mini Apps
-
- navigation.navigate('MiniApp')}
- >
- 🖼️
-
- Gallery
-
-
-
- 💬
-
- Messages
-
-
-
- ⚙️
-
- Settings
-
-
+
+ Welcome Home
+ Explore the host app and its features
+ {FEATURE_GROUPS.filter((group) =>
+ FEATURES.some((feature) => feature.group === group)
+ ).map((group) => (
+
+ {group}
+ {FEATURES.filter((feature) => feature.group === group).map(
+ (feature) => (
+ navigation.navigate(feature.screen)}
+ >
+ {feature.name}
+
+ )
+ )}
-
-
+ ))}
+
);
};
const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: '#F5F9FF',
- },
- hostSection: {
- flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
- paddingHorizontal: 20,
+ content: {
+ padding: 20,
+ gap: 8,
},
title: {
fontSize: 28,
- fontWeight: 'bold',
- marginBottom: 10,
- color: '#2C3E50',
+ fontWeight: '700',
+ color: '#200a12',
},
subtitle: {
fontSize: 16,
- color: '#34495E',
- marginBottom: 30,
- textAlign: 'center',
- },
- button: {
- backgroundColor: '#3498DB',
- padding: 15,
- borderRadius: 10,
- alignItems: 'center',
+ color: '#55424a',
+ marginBottom: 12,
},
- secondaryButton: {
- marginTop: 12,
- backgroundColor: '#FFFFFF',
- borderColor: '#3498DB',
- borderWidth: 1,
+ group: {
+ gap: 6,
+ marginBottom: 16,
},
- buttonText: {
- color: 'white',
- fontSize: 16,
+ groupTitle: {
+ fontSize: 18,
fontWeight: '600',
+ color: 'rgba(56, 30, 114, 1)',
+ marginBottom: 4,
},
- secondaryButtonText: {
- color: '#3498DB',
- fontSize: 16,
- fontWeight: '600',
- },
- miniAppSection: {
- backgroundColor: 'white',
- padding: 20,
- borderTopLeftRadius: 30,
- borderTopRightRadius: 30,
- shadowColor: '#34495E',
- shadowOffset: { width: 0, height: -3 },
- shadowOpacity: 0.1,
- shadowRadius: 5,
- elevation: 5,
- },
- sectionTitle: {
- fontSize: 24,
- fontWeight: '600',
- marginBottom: 20,
- color: '#2C3E50',
- textAlign: 'center',
- },
- miniAppContainer: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- justifyContent: 'center',
- minHeight: 150,
- },
- miniAppButton: {
- backgroundColor: '#ECF0F1',
- width: 100,
- height: 100,
- borderColor: '#BDC3C7',
- borderWidth: 1,
- borderRadius: 15,
- alignItems: 'center',
- justifyContent: 'center',
- margin: 5,
- shadowColor: '#000',
- shadowOffset: { width: 0, height: 2 },
- shadowOpacity: 0.1,
- shadowRadius: 4,
- elevation: 3,
- },
- galleryButton: {
- borderColor: '#FF9F00',
- backgroundColor: '#FFF5E6',
- },
- galleryButtonText: {
- color: '#FF9F00',
- },
- miniAppIcon: {
- fontSize: 40,
- marginBottom: 5,
- color: '#FFFFFF',
+ button: {
+ backgroundColor: 'rgba(56, 30, 114, 1)',
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderRadius: 8,
},
- miniAppText: {
- color: '#FFFFFF',
+ buttonText: {
+ color: '#ffffff',
fontSize: 16,
- fontWeight: '700',
- },
- disabledMiniApp: {
- opacity: 0.5,
- },
- disabledText: {
- color: '#95A5A6',
+ textAlign: 'center',
},
});
diff --git a/apps/tester-federation-v2/src/host/screens/HostAssetsScreen.tsx b/apps/tester-federation-v2/src/host/screens/HostAssetsScreen.tsx
index 62e2333ee..e435707ae 100644
--- a/apps/tester-federation-v2/src/host/screens/HostAssetsScreen.tsx
+++ b/apps/tester-federation-v2/src/host/screens/HostAssetsScreen.tsx
@@ -1,3 +1,4 @@
+import type { ComponentType } from 'react';
import {
FlatList,
Image,
@@ -7,86 +8,167 @@ import {
View,
} from 'react-native';
-const assets = [
+import RepackLogo from '../assets/repack-logo.svg';
+import Report from '../ui/Report';
+
+type SvgComponentType = ComponentType<{
+ width?: number | string;
+ height?: number | string;
+}>;
+
+type BaseItem = { key: string; title: string; subtitle: string };
+
+type AssetItem = BaseItem &
+ (
+ | {
+ kind: 'bitmap';
+ source: ImageRequireSource;
+ showUri?: boolean;
+ compact?: boolean;
+ }
+ | { kind: 'svg'; SvgComponent: SvgComponentType }
+ );
+
+const data: AssetItem[] = [
{
+ key: 'pic-1',
+ title: 'Host asset 1',
+ subtitle:
+ 'Loaded from the host app bundle to test inline asset size limits',
+ kind: 'bitmap',
source: require('../assets/pic_1.jpg'),
- status: 'Inlined',
- size: '89,400 bytes',
},
{
+ key: 'pic-2',
+ title: 'Host asset 2',
+ subtitle:
+ 'Loaded from the host app bundle to test inline asset size limits',
+ kind: 'bitmap',
source: require('../assets/pic_2.jpg'),
- status: 'Emitted',
- size: '121,569 bytes',
},
{
- source: require('../assets/pic_3.jpg'),
- status: 'Inlined',
- size: '59,862 bytes',
+ key: 'local',
+ title: 'Local fixture',
+ subtitle: 'Assets loader without inline options, always emitted as a file',
+ kind: 'bitmap',
+ source: require('../assets/localAssets/webpack.png'),
+ showUri: true,
+ compact: true,
+ },
+ {
+ key: 'inline',
+ title: 'Inline fixture',
+ subtitle:
+ 'Assets loader with inline enabled, always inlined into the bundle',
+ kind: 'bitmap',
+ source: require('../assets/inlineAssets/webpack.png'),
+ showUri: true,
+ compact: true,
+ },
+ {
+ key: 'svg',
+ title: 'SVG via svgr',
+ subtitle:
+ 'Imported as a React component by @svgr/webpack, not as a bitmap asset',
+ kind: 'svg',
+ SvgComponent: RepackLogo,
},
];
-const data = assets.map((asset, index) => ({
- title: `Host asset ${index + 1}`,
- ...asset,
-}));
-
-const AssetRow = ({
- title,
- source,
- status,
- size,
+const SvgRow = ({
+ SvgComponent,
+ item,
}: {
- title: string;
- source: ImageRequireSource;
- status: string;
- size: string;
+ SvgComponent: SvgComponentType;
+ item: BaseItem;
}) => (
-
+
+
+
- {title}
-
-
- {status}
+ {item.title}
+
+
+ Component
- {size}
-
- Loaded from the host app bundle to test inline asset size limits
-
+ {item.subtitle}
);
+type BitmapItem = Extract;
+
+const AssetRow = ({ item }: { item: AssetItem }) => {
+ if (item.kind === 'svg') {
+ return ;
+ }
+ return ;
+};
+
+const BitmapRow = ({ item }: { item: BitmapItem }) => {
+ const resolved = Image.resolveAssetSource(item.source) as { uri: string };
+ const isInline = resolved.uri.startsWith('data:');
+ const status = isInline ? 'Inlined' : 'Emitted';
+
+ return (
+
+
+
+
+ {item.title}
+
+
+ {status}
+
+
+
+
+
+ {item.showUri ? (
+
+ ) : null}
+
+ {item.subtitle}
+
+
+ );
+};
+
const HostAssetsScreen = () => {
return (
item.title}
- renderItem={({ item }) => (
-
- )}
+ keyExtractor={(item) => item.key}
+ renderItem={({ item }) => }
style={styles.container}
/>
);
@@ -117,6 +199,17 @@ const styles = StyleSheet.create({
height: 250,
resizeMode: 'cover',
},
+ fixtureImage: {
+ width: '100%',
+ height: 96,
+ resizeMode: 'contain',
+ },
+ svgContainer: {
+ height: 120,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: '#F0F4FA',
+ },
titleContainer: {
padding: 15,
},
@@ -153,9 +246,8 @@ const styles = StyleSheet.create({
emittedBadgeText: {
color: '#9A5A00',
},
- size: {
- color: '#7F8C8D',
- fontSize: 13,
+ reports: {
+ marginTop: 5,
marginBottom: 5,
},
subtitle: {
diff --git a/apps/tester-federation-v2/src/host/screens/MiniAppInstallScreen.tsx b/apps/tester-federation-v2/src/host/screens/MiniAppInstallScreen.tsx
new file mode 100644
index 000000000..2c90344ab
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/screens/MiniAppInstallScreen.tsx
@@ -0,0 +1,27 @@
+import { ScrollView, StyleSheet, View } from 'react-native';
+import MiniAppSection from '../features/miniapp/MiniAppSection';
+
+const MiniAppInstallScreen = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ content: {
+ paddingVertical: 16,
+ },
+ container: {
+ paddingHorizontal: 16,
+ gap: 4,
+ },
+});
+
+export default MiniAppInstallScreen;
diff --git a/apps/tester-federation-v2/src/host/screens/MiniAppScreen.tsx b/apps/tester-federation-v2/src/host/screens/MiniAppScreen.tsx
index af41cfd97..b7df2c403 100644
--- a/apps/tester-federation-v2/src/host/screens/MiniAppScreen.tsx
+++ b/apps/tester-federation-v2/src/host/screens/MiniAppScreen.tsx
@@ -34,15 +34,20 @@ const FallbackComponent = () => (
const MiniAppScreen = () => {
return (
-
- }>
-
-
-
+
+
+ }>
+
+
+
+
);
};
const styles = StyleSheet.create({
+ screen: {
+ flex: 1,
+ },
container: {
flex: 1,
justifyContent: 'center',
diff --git a/apps/tester-federation-v2/src/host/screens/NativeWindScreen.tsx b/apps/tester-federation-v2/src/host/screens/NativeWindScreen.tsx
new file mode 100644
index 000000000..a760dc6b4
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/screens/NativeWindScreen.tsx
@@ -0,0 +1,80 @@
+import { useColorScheme } from 'nativewind';
+import * as React from 'react';
+import {
+ type LayoutChangeEvent,
+ ScrollView,
+ StyleSheet,
+ View,
+} from 'react-native';
+
+import Basic from '../features/nativewind/Basic';
+import ComponentWithVariants from '../features/nativewind/ComponentWithVariants';
+import CustomComponent from '../features/nativewind/CustomComponent';
+import DarkMode from '../features/nativewind/DarkMode';
+import FuncsDirs from '../features/nativewind/FuncsDirs';
+import PaddingProbe from '../features/nativewind/PaddingProbe';
+import Reusables from '../features/nativewind/Reusables';
+import Report from '../ui/Report';
+
+const NativeWindScreen = () => {
+ const { colorScheme } = useColorScheme();
+ const [calcWidth, setCalcWidth] = React.useState(0);
+ const [containerWidth, setContainerWidth] = React.useState(0);
+ const [boxWidth, setBoxWidth] = React.useState(0);
+
+ const handleContainerLayout = React.useCallback(
+ (event: LayoutChangeEvent) => {
+ setContainerWidth(Math.round(event.nativeEvent.layout.width));
+ },
+ []
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ content: {
+ paddingVertical: 16,
+ },
+ container: {
+ paddingHorizontal: 16,
+ gap: 4,
+ },
+});
+
+export default NativeWindScreen;
diff --git a/apps/tester-federation-v2/src/host/screens/ReanimatedScreen.tsx b/apps/tester-federation-v2/src/host/screens/ReanimatedScreen.tsx
new file mode 100644
index 000000000..34f80373b
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/screens/ReanimatedScreen.tsx
@@ -0,0 +1,34 @@
+import * as React from 'react';
+import { ScrollView, StyleSheet, View } from 'react-native';
+
+import ReanimatedBox from '../features/reanimated/ReanimatedBox';
+import Report from '../ui/Report';
+
+const ReanimatedScreen = () => {
+ const [width, setWidth] = React.useState(100);
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ content: {
+ paddingVertical: 16,
+ },
+ container: {
+ paddingHorizontal: 16,
+ gap: 4,
+ },
+});
+
+export default ReanimatedScreen;
diff --git a/apps/tester-federation-v2/src/host/screens/RemoteChunkScreen.tsx b/apps/tester-federation-v2/src/host/screens/RemoteChunkScreen.tsx
new file mode 100644
index 000000000..50703bec6
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/screens/RemoteChunkScreen.tsx
@@ -0,0 +1,27 @@
+import { ScrollView, StyleSheet, View } from 'react-native';
+import RemoteChunkSection from '../features/remote/RemoteChunkSection';
+
+const RemoteChunkScreen = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ content: {
+ paddingVertical: 16,
+ },
+ container: {
+ paddingHorizontal: 16,
+ gap: 4,
+ },
+});
+
+export default RemoteChunkScreen;
diff --git a/apps/tester-federation-v2/src/host/screens/RuntimeEventsScreen.tsx b/apps/tester-federation-v2/src/host/screens/RuntimeEventsScreen.tsx
new file mode 100644
index 000000000..2c790b96c
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/screens/RuntimeEventsScreen.tsx
@@ -0,0 +1,53 @@
+import * as React from 'react';
+import { ScrollView, StyleSheet, View } from 'react-native';
+import {
+ EVENT_NAMES,
+ getPipelineStatus,
+ readEventCounts,
+ type ScriptManagerCounts,
+ subscribeEventCounts,
+} from '../features/runtime/events';
+import Report from '../ui/Report';
+
+const RuntimeEventsScreen = () => {
+ const [counts, setCounts] = React.useState(() =>
+ readEventCounts()
+ );
+
+ React.useEffect(() => subscribeEventCounts((next) => setCounts(next)), []);
+
+ return (
+
+
+ {EVENT_NAMES.map((event) => (
+
+ ))}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ content: {
+ paddingVertical: 16,
+ },
+ container: {
+ paddingHorizontal: 16,
+ gap: 4,
+ },
+});
+
+export default RuntimeEventsScreen;
diff --git a/apps/tester-federation-v2/src/host/setup.ts b/apps/tester-federation-v2/src/host/setup.ts
new file mode 100644
index 000000000..ed46d6140
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/setup.ts
@@ -0,0 +1,31 @@
+import { Script, ScriptManager } from '@callstack/repack/client';
+
+import { registerEventCounter } from './features/runtime/events';
+
+const HOST_OWNED_CHUNKS = ['remote', 'miniapp', 'host-async-local'];
+
+ScriptManager.shared.addResolver(async (scriptId) => {
+ if (!HOST_OWNED_CHUNKS.includes(scriptId)) {
+ return undefined;
+ }
+
+ if (__DEV__) {
+ return {
+ url: Script.getDevServerURL(scriptId),
+ cache: false,
+ };
+ }
+
+ if (scriptId.includes('local')) {
+ return {
+ url: Script.getFileSystemURL(scriptId),
+ cache: false,
+ };
+ }
+
+ return {
+ url: Script.getRemoteURL(`http://localhost:9999/${scriptId}`),
+ };
+});
+
+registerEventCounter();
diff --git a/apps/tester-federation-v2/src/host/ui/ActionButton.tsx b/apps/tester-federation-v2/src/host/ui/ActionButton.tsx
new file mode 100644
index 000000000..c41a0658f
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/ui/ActionButton.tsx
@@ -0,0 +1,60 @@
+import { StyleSheet, Text, TouchableOpacity } from 'react-native';
+
+type ActionButtonProps = {
+ testID: string;
+ label: string;
+ onPress: () => void;
+ disabled?: boolean;
+ variant?: 'primary' | 'secondary';
+};
+
+const ActionButton = ({
+ testID,
+ label,
+ onPress,
+ disabled = false,
+ variant = 'primary',
+}: ActionButtonProps) => (
+
+
+ {label}
+
+
+);
+
+const styles = StyleSheet.create({
+ button: {
+ alignSelf: 'stretch',
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderRadius: 8,
+ backgroundColor: 'rgba(56, 30, 114, 1)',
+ },
+ secondary: {
+ backgroundColor: '#d8cbe8',
+ },
+ text: {
+ color: '#ffffff',
+ fontSize: 16,
+ textAlign: 'center',
+ },
+ secondaryText: {
+ color: '#200a12',
+ },
+ disabled: {
+ opacity: 0.5,
+ },
+});
+
+export default ActionButton;
diff --git a/apps/tester-federation-v2/src/host/ui/Report.tsx b/apps/tester-federation-v2/src/host/ui/Report.tsx
new file mode 100644
index 000000000..bce95ac28
--- /dev/null
+++ b/apps/tester-federation-v2/src/host/ui/Report.tsx
@@ -0,0 +1,25 @@
+import { StyleSheet, Text } from 'react-native';
+
+type ReportProps = {
+ testID: string;
+ name: string;
+ value: unknown;
+};
+
+const Report = ({ testID, name, value }: ReportProps) => (
+
+ {`${name}: ${String(value)}`}
+
+);
+
+const styles = StyleSheet.create({
+ row: {
+ alignSelf: 'stretch',
+ paddingVertical: 6,
+ paddingHorizontal: 12,
+ fontSize: 14,
+ color: '#200a12',
+ },
+});
+
+export default Report;
diff --git a/apps/tester-federation-v2/tailwind.config.js b/apps/tester-federation-v2/tailwind.config.js
new file mode 100644
index 000000000..a46140772
--- /dev/null
+++ b/apps/tester-federation-v2/tailwind.config.js
@@ -0,0 +1,26 @@
+const path = require('node:path');
+
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: [path.join(__dirname, 'src/host/**/*.{js,jsx,ts,tsx}')],
+ presets: [require('nativewind/preset')],
+ theme: {
+ extend: {
+ colors: {
+ custom: 'var(--my-custom-color)',
+ primary: 'rgb(var(--color-values) / )',
+ 'primary-foreground': '#ffffff',
+ foreground: '#0f172a',
+ },
+ },
+ },
+ plugins: [
+ ({ addBase }) =>
+ addBase({
+ ':root': {
+ '--color-values': '255 0 0',
+ },
+ }),
+ ],
+ darkMode: 'class',
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b55062204..a03f9bb42 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -377,6 +377,9 @@ importers:
'@babel/core':
specifier: 'catalog:'
version: 7.29.7
+ '@e2e-dev/mobile':
+ specifier: catalog:testers
+ version: 0.11.0(e2e@0.19.0)
'@react-native-community/cli':
specifier: catalog:testers
version: 20.2.0(typescript@7.0.2)
@@ -395,12 +398,18 @@ importers:
'@rspack/core':
specifier: 'catalog:'
version: 1.6.0(@swc/helpers@0.5.23)
+ '@types/node':
+ specifier: 'catalog:'
+ version: 20.19.43
'@types/react':
specifier: catalog:testers
version: 19.2.17
babel-preset-expo:
specifier: catalog:expo58
version: 58.0.17(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@58.0.13)(react-refresh@0.19.0)
+ e2e:
+ specifier: catalog:testers
+ version: 0.19.0
typescript:
specifier: 'catalog:'
version: 7.0.2
@@ -565,22 +574,58 @@ importers:
'@react-navigation/native-stack':
specifier: 7.18.6
version: 7.18.6(8b1d585db36fbc96bc28225ebb75c6b0)
+ '@rn-primitives/slot':
+ specifier: ^1.2.0
+ version: 1.5.2(react-native@0.88.0(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@7.0.2))(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.3.0))(react@19.3.0)
+ '@rn-primitives/types':
+ specifier: ^1.2.0
+ version: 1.5.2(react-native@0.88.0(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@7.0.2))(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.3.0))(react@19.3.0)
+ class-variance-authority:
+ specifier: ^0.7.1
+ version: 0.7.1
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ nativewind:
+ specifier: ^4.2.6
+ version: 4.2.6(65131ab4b69ad60b869660d529711dc4)
react:
specifier: 'catalog:'
version: 19.3.0
react-native:
specifier: 'catalog:'
version: 0.88.0(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@7.0.2))(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.3.0)
+ react-native-css-interop:
+ specifier: 0.2.6
+ version: 0.2.6(65131ab4b69ad60b869660d529711dc4)
+ react-native-reanimated:
+ specifier: catalog:testers
+ version: 4.7.1(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(react-native@0.88.0(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@7.0.2))(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@7.0.2))(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.3.0))(react@19.3.0)
react-native-safe-area-context:
specifier: catalog:testers
version: 5.10.1(react-native@0.88.0(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@7.0.2))(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.3.0))(react@19.3.0)
react-native-screens:
specifier: 4.30.0
version: 4.30.0(react-native@0.88.0(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@7.0.2))(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.3.0))(react@19.3.0)
+ react-native-svg:
+ specifier: 15.15.5
+ version: 15.15.5(react-native@0.88.0(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@7.0.2))(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.3.0))(react@19.3.0)
+ react-native-worklets:
+ specifier: catalog:testers
+ version: 0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(react-native@0.88.0(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@7.0.2))(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.3.0))(react@19.3.0)
+ tailwind-merge:
+ specifier: ^3.5.0
+ version: 3.6.0
devDependencies:
'@babel/core':
specifier: ^7.29.7
version: 7.29.7
+ '@callstack/repack-plugin-nativewind':
+ specifier: workspace:*
+ version: link:../../packages/plugin-nativewind
+ '@callstack/repack-plugin-reanimated':
+ specifier: workspace:*
+ version: link:../../packages/plugin-reanimated
'@e2e-dev/mobile':
specifier: catalog:testers
version: 0.11.0(e2e@0.19.0)
@@ -611,6 +656,9 @@ importers:
'@rspack/core':
specifier: 'catalog:'
version: 1.6.0(@swc/helpers@0.5.23)
+ '@svgr/webpack':
+ specifier: ^8.1.0
+ version: 8.1.0(typescript@7.0.2)
'@swc/helpers':
specifier: 'catalog:'
version: 0.5.23
@@ -623,12 +671,24 @@ importers:
'@types/react':
specifier: catalog:testers
version: 19.2.17
+ autoprefixer:
+ specifier: ^10.4.20
+ version: 10.5.4(postcss@8.5.23)
e2e:
specifier: catalog:testers
version: 0.19.0
+ postcss:
+ specifier: ^8.5.23
+ version: 8.5.23
+ postcss-loader:
+ specifier: ^8.1.1
+ version: 8.2.1(@rspack/core@1.6.0(@swc/helpers@0.5.23))(postcss@8.5.23)(typescript@7.0.2)(webpack@5.105.4)
react-native-test-app:
specifier: catalog:testers
version: 5.4.14(@expo/config-plugins@58.0.9(typescript@7.0.2))(@react-native-community/cli-types@20.2.0)(memfs@4.64.0)(metro@0.87.1)(react-native@0.88.0(@babel/core@7.29.7)(@react-native-community/cli@20.2.0(typescript@7.0.2))(@react-native/metro-config@0.88.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.3.0))(react@19.3.0)
+ tailwindcss:
+ specifier: ^3.4.17
+ version: 3.4.19(yaml@2.9.0)
terser-webpack-plugin:
specifier: 'catalog:'
version: 5.5.0(webpack@5.105.4)
@@ -10313,7 +10373,7 @@ snapshots:
'@babel/helper-member-expression-to-functions@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
transitivePeerDependencies:
- supports-color
@@ -10335,7 +10395,7 @@ snapshots:
'@babel/helper-optimise-call-expression@7.29.7':
dependencies:
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
'@babel/helper-plugin-utils@7.29.7': {}
@@ -10360,7 +10420,7 @@ snapshots:
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
transitivePeerDependencies:
- supports-color
@@ -10374,7 +10434,7 @@ snapshots:
dependencies:
'@babel/template': 7.29.7
'@babel/traverse': 7.29.7
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
transitivePeerDependencies:
- supports-color
@@ -14904,7 +14964,7 @@ snapshots:
'@svgr/hast-util-to-babel-ast@8.0.0':
dependencies:
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
entities: 4.5.0
'@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@7.0.2))':
@@ -15040,16 +15100,16 @@ snapshots:
'@types/babel__generator@7.27.0':
dependencies:
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
'@types/babel__template@7.4.4':
dependencies:
'@babel/parser': 7.29.7
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
'@types/babel__traverse@7.28.0':
dependencies:
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
'@types/chai@5.2.3':
dependencies:
@@ -15627,7 +15687,7 @@ snapshots:
babel-plugin-jest-hoist@29.6.3:
dependencies:
'@babel/template': 7.29.7
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
'@types/babel__core': 7.20.5
'@types/babel__traverse': 7.28.0
@@ -18083,7 +18143,7 @@ snapshots:
'@babel/generator': 7.29.7
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
'@babel/plugin-syntax-typescript': 7.25.4(@babel/core@7.29.7)
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
'@jest/expect-utils': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
@@ -19734,6 +19794,18 @@ snapshots:
transitivePeerDependencies:
- typescript
+ postcss-loader@8.2.1(@rspack/core@1.6.0(@swc/helpers@0.5.23))(postcss@8.5.23)(typescript@7.0.2)(webpack@5.105.4):
+ dependencies:
+ cosmiconfig: 9.0.2(typescript@7.0.2)
+ jiti: 2.7.0
+ postcss: 8.5.23
+ semver: 7.8.5
+ optionalDependencies:
+ '@rspack/core': 1.6.0(@swc/helpers@0.5.23)
+ webpack: 5.105.4
+ transitivePeerDependencies:
+ - typescript
+
postcss-nested@6.2.0(postcss@8.5.23):
dependencies:
postcss: 8.5.23
@@ -19878,7 +19950,7 @@ snapshots:
dependencies:
'@babel/helper-module-imports': 7.29.7
'@babel/traverse': 7.29.7
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
debug: 4.4.3
lightningcss: 1.27.0
react: 19.3.0
@@ -19896,7 +19968,7 @@ snapshots:
dependencies:
'@babel/helper-module-imports': 7.29.7
'@babel/traverse': 7.29.7
- '@babel/types': 7.29.7
+ '@babel/types': 7.29.8
debug: 4.4.3
lightningcss: 1.27.0
react: 19.3.0