feat: expo bundler integration - #166
Open
elcoosp wants to merge 35 commits into
Open
Conversation
…lity mode Translate @expo/metro-config output onto Rollipop's resolver so aliases, asset/source extensions and asset redirects line up when Rollipop is used as the Expo bundler (EXPO_BUNDLER=rollipop). Includes unit tests. Refs: rollipop-expo-integration Phase 1.
…pop-as-Expo-bundler - Add expo-metro-runtime plugin that aliases @expo/metro-runtime to a Rollipop-compatible shim (HMR/reload delegated to import.meta.hot, LogBox + error-overlay helpers as no-ops) so Expo Router resolves without Metro. - Add expo-router plugin that scans app/ at build start, generates the route tree (groups, [param], [...rest], _layout) and injects it as a virtual manifest module consumed by expo-router/entry. - The entry composer prepends the virtual manifest import when EXPO_BUNDLER=rollipop. - Both plugins wired into resolveRolldownOptions, gated on expo mode. - Unit tests: router-manifest (8) + runtime-shim parity (5). lint+fmt clean.
… expo-router) with JSX-in-.js bundle Metro parses .js as JSX for every React Native build; oxc only does so for .jsx/.tsx by default. Map '.js' -> 'jsx' module type on native platforms (ios/android/native) in resolveRolldownOptions so dependencies that ship raw JSX inside .js (expo-router's build output) bundle correctly under Rollipop-as-Expo-bundler.
…Transformer React Native 0.86 exposes setCustomSourceTransformer as a property of the default export of react-native/Libraries/Image/resolveAssetSource (resolveAssetSource.setCustomSourceTransformer = …), not as a named export. expo-asset's Asset.fx.js imports it as a named export, which Metro tolerates via CJS named-export interop but Rolldown's strict export * re-export does not surface, producing a MISSING_EXPORT error. Add rollipop:expo-asset-interop plugin (Expo-mode only) that re-exports setCustomSourceTransformer as a named export from expo-asset/build/resolveAssetSource(.native).js, so stock Expo SDK 57 / RN 0.86 apps bundle end-to-end under Rollipop without patching upstream expo-asset. Verified: rollipop bundle of the example-app (EXPO_BUNDLER=rollipop) now completes (964 modules) with no node_modules patches.
Replace the user resolveId/load virtual-module redirect for @expo/metro-runtime with a core resolve.alias entry that maps the specifier to the real, type-checked runtime-shim module (dist/runtime-shim.js). Rationale: rolldown's dev engine (rolldown.dev(), used by 'rollipop start') does NOT invoke user resolveId/transform hooks for external node_modules specifiers, whereas the core resolver honors resolve.alias in both build and dev modes. The alias approach keeps the redirect working for 'rollipop bundle' (verified: 949 modules, exit 0) and removes the dead virtual-module plugin. Note: the dev engine still loads the real @expo/metro-runtime despite the alias being present in inputOptions — a rolldown dev-engine limitation to be addressed upstream / via a dev-engine-specific interception.
An unfiltered resolveId hook fires for external node_modules specifiers in BOTH rolldown.build() and rolldown.dev() (the engine used by 'rollipop start'), whereas a filtered resolveId hook does NOT fire in the dev engine (its filter matching differs from the build engine). rolldown.dev() also ignores resolve.alias replacements for external packages. Replace the dev-incompatible resolve.alias redirect with an unfiltered resolveId hook in expoMetroRuntimePlugin that maps '@expo/metro-runtime' and subpaths to the real, type-checked shim module (dist/runtime-shim.js). This makes both 'rollipop bundle' (949 modules) and 'rollipop start' / 'bundle --dev true' (1073 modules) serve an Expo app with the shim applied — the dev server previously 500'd because the real @expo/metro-runtime LogBox failed on type-only re-exports. Verified: rollipop bundle (build) and rollipop start (dev) both return a green bundle for the stock Expo SDK57/RN0.86 example app; fmt/lint/ typecheck/tests (24) pass.
getExpoRolipopConfig resolves @expo/metro-config relative to the project root, which fails under pnpm strict mode (the app does not hoist it). Now: - honor a ROLLIPOP_EXPO_METRO_CONFIG env var (set by @expo/cli to its own @expo/metro-config dependency path) as the first resolution source; - fall back to Rollipop's own location only after that. Removes the non-fatal '@expo/metro-config is not resolvable' warning in Expo compatibility mode (dev server + export) without requiring a symlink in the consuming app. typecheck clean, 382 tests pass.
…ev Client - entry-plugin: unfiltered resolveId so absolute entry/prelude paths emitted into the \0rollipop/entry virtual module resolve (Rolldown cannot resolve absolute imports from a virtual module otherwise). - defaults: resolve entry to projectRoot and prelude via reactNativePath. - serve-bundle: guard against error-reply in non-multipart branch (was crashing on bundle.code of undefined); add /.expo/.virtual-metro-entry.bundle alias so Expo Dev Client launches work without Metro. - react-native: getInitializeCorePath retained; prelude now uses reactNativePath. - entry-plugin.spec: updated for function-form resolveId.
The rollipop output format emitted a self-referential default binding for modules that export a function and later mutate that default export's properties (e.g. react-native/Libraries/Image/resolveAssetSource.js, which does `export default resolveAssetSource; resolveAssetSource.pickScale = …`). The self-import bound `export default` to `self.default` (circular/undefined) and caused rolldown to drop the module body entirely, so `resolveAssetSource` was missing from the bundle — `Image` threw 'undefined is not a function', LogBox crashed on its close-button `<Image>`, and the overlay swallowed all touches, breaking Maestro e2e on a real iOS simulator. - Add rollipop:resolve-asset-source-interop transform that rewrites resolveAssetSource.js before rolldown sees it: keep the implementation under a private name and export a separate const wrapper that is never mutated after export, breaking the self-reference so the body is preserved. - Scope rollipop:expo-asset-interop to expo-asset's own copies only (anchor on 'expo-asset/build'), so it no longer replaces RN's resolveAssetSource.js with a stub that self-imports (which re-introduced the self-ref). Also includes the Expo Router integration plumbing verified end-to-end by Maestro on iPhone 17 Pro: require.context shim (lazy keys, rootBase scoping), React.startTransition sync override, LogBox.uninstall bootstrap guard, runtime-shim withErrorOverlay, router-manifest route collection, and the dev-server virtual-entry/alias resolution.
Address the Harsh Code Plan Critic findings for the rollipop <-> Expo integration: - self-ref-default-interop: fail loudly (this.warn) when the RN module shape no longer matches the expected 3-assignment block, instead of silently no-op'ing and resurrecting the dropped-body Image crash. - expo-asset-interop: replace the hardcoded `expo-asset/build/` path filter with a layout-agnostic regex anchored on the package dir + basename, so a future RN bump that moves the file still matches. - rolldown: scope the import.meta define so `import.meta.url` is preserved (Hermes supports it) and only the unsupported `import.meta.env` member access is blanked; reorder so BASE_URL wins over the blanket define. - entry-plugin require.context: memoize the normalized key list and warn (once) when modules are excluded from the context root, instead of rebuilding O(N) per keys() call and silently dropping a misconfigured root. - serve-bundle: extract a shared serveBundle() helper for the duplicated /index.bundle and /.expo/.virtual-metro-entry.bundle handlers. - runtime-shim + shim-code: fix withErrorOverlay double log/re-throw and make getDevServer read process.env (the import.meta.env member access is blanked for Hermes); keep the inlined shim-code string in sync as a test fixture. - config-translator: surface collected warnings to the user via console.warn. Also resolve pre-existing typecheck errors: - add @types/react (peer react already declared) so runtime-shim.ts resolves; add `react` as a peerDependency so it is externalized from the build and not bundled into dist. - rolldown alias: normalize the array-form alias into rolldown's object/glob form (RegExp finds dropped with a warning) instead of producing an AliasEntry[] that fails the object-form type. - config/defaults: drop unused getInitializeCorePath import (lint). - router-manifest: formatting (vp fmt).
rolldown no longer bundles CSS, so `*.module.css` (e.g. `@expo/log-box` overlays pulled in by the Expo Dev Client error overlay) made native builds fail with `[UNSUPPORTED_FEATURE] Bundling CSS is no longer supported`. - swc-plugin: skip non-script extensions (`.css`/`.pcss`/`.scss`/...) so swc does not try to parse CSS as TS/JS (was throwing "Expression expected"). - css-module-transform: new plugin that emits the CSS-module JS interop (default + named exports of local class names) for `*.module.css` and an empty module for plain `*.css`, matching Metro's native CSS-module transform. A `load` hook returns the JS with `moduleType: 'js'` so it bypasses rolldown's CSS pipeline for both `rolldown.build()` (export) and the dev-server `dev()` API. - rolldown: map `.css` / `.module.css` -> `js` module type on native platforms so the bundler does not invoke the removed CSS pipeline. Verified: `expo export --bundler rollipop --platform android` (1237 modules) and the dev server both serve the android bundle (HTTP 200); the example app launches on the Android emulator and loads the rollipop bundle.
…oad bundles
The Expo Dev Client (and classic Expo Go) fetch the project manifest as JSON
from the dev server (Metro/Expo serve it at `/`, `/manifest`, `/index.exp`).
Rollipop's dev server had no such route, so the native runtime did
`new JSONObject("<!DOCTYPE html>...")` and failed with
`Error loading app: Value <!DOCTYPE ... cannot be converted to JSONObject`.
Add an `expoManifest` middleware that serves a valid Expo dev-client manifest
(name, slug, bundleUrl, debuggerHost, developer, mainModuleName, packagerOpts,
dependencies, id/scopeKey, runtimeVersion/sdkVersion, etc.) at `/manifest` and
`/index.exp`. `bundleUrl`/`debuggerHost`/`hostUri` are derived from the
incoming request Host header (the address the device actually reached the
server on, e.g. the mDNS-discovered LAN IP) rather than `serverBaseUrl` (which
may be `0.0.0.0` and unreachable from the device).
Verified: GET /manifest returns 200 application/json with a reachable
bundleUrl (http://192.168.1.24:8082/index.bundle?...); the Dev Client then
requests that bundle and the app loads.
The suite was failing because `react-native` was not installed in the rollipop package, so every e2e fixture that imports `react-native` threw `Cannot find module 'react-native'` and several assertions were written assuming react-native was absent. - Add `react-native@0.86.0` as a devDependency so the e2e fixtures resolve it (the build helper already notes getDefaultConfig() requires react-native). This surfaces the correct, previously-masked behavior. - Fix array-alias handling in resolveAliasPluginOptions: route string-find array aliases through the vite-alias plugin (which chains to plugin resolveId hooks, so virtual-id replacements resolve) instead of rolldown's object-form alias (which does NOT re-invoke resolveId for the replacement). Object-form aliases are unchanged. - Update alias/condition-name test assertions to reflect the react dedupe aliases (react, react/jsx-runtime, react/jsx-dev-runtime, react-native) that rollipop always injects, and to assert on the react-native condition field marker rather than the bare "react-native" substring (which now also appears in the deduped module path). - Fix entry-plugin bootstrap test by providing a __rollipop_require__ stub in the vm context (the bootstrap code references it). - Fix MCP test: printRoutes() radix-compresses `/mcp` to `m/cp`, so a bare substring check is unreliable; verify the route by injecting a POST to /mcp and asserting it is reachable (400) rather than 404. Result: 382/382 tests pass; vp check + typecheck clean.
The Expo Dev Client fetches the project manifest as JSON from the dev
server ROOT (`/`), not just `/manifest`. Rollipop's dev server previously
had no `/` manifest route, so the native runtime did
`new JSONObject("<!DOCTYPE html>...")` and failed with
`Error loading app: Value <!DOCTYPE ... cannot be converted to JSONObject`.
Root cause: the React Native community/dev middleware (registered via
middie) serves its own `<!DOCTYPE html>` dashboard at `/` and short-circuits
before any Fastify route runs, so a Fastify `GET /` handler for the manifest
was never reached (and Fastify v4 handlers have no `next()` fall-through).
Fix:
- Add `createExpoManifestInterceptor()` — an Express-style (middie) handler
registered BEFORE `communityMiddleware` in create-dev-server.ts. It answers
manifest requests at `/` (Dev Client sends the `expo-platform` header, or an
`Accept` that is not `text/html`) with the manifest JSON, and calls
`next()` for browser requests so the RN dashboard is unaffected.
- Keep `/manifest` and `/index.exp` as Fastify routes (those paths are not
intercepted by the RN middleware).
- `buildManifest()` derives `bundleUrl`/`debuggerHost` from the request
`Host` header (device-reachable LAN IP) rather than the server's bind host.
Verified: `curl / -H 'expo-platform: android'` -> valid manifest JSON;
`curl / -H 'Accept: text/html'` -> HTML dashboard; `/manifest` -> JSON.
Result: vp check + typecheck clean; dev client should now load the
Rollipop-bundled app instead of hitting the `<!DOCTYPE` parse error.
- css-module-transform: close double-transform bug that silently dropped
the class-name map to `export default {}` (load is now authoritative,
transform skips already-converted modules via a marker).
- getExpoRouterAppRoot: derive the real router root (exp.extra.router.root,
src/app, then app) so EXPO_ROUTER_APP_ROOT and the manifest scanner agree.
- expo-manifest: derive id/scopeKey/sdkVersion from the project config
instead of hardcoded example values.
- router-manifest: initialRouteName resolves to the index route, not '/'.
- self-ref-default-interop: collect mutations structurally (order/whitespace
tolerant) and warn loudly on shape drift.
- Add unit tests for css-module-transform and getExpoRouterAppRoot.
Verified: typecheck clean, lint clean, 389/389 tests pass.
Signed-off-by: elcoosp <elcoosp@gmail.com>
…-expo parity) expo-modules-core reads process.env.EXPO_OS in Platform.js to seed Platform.OS. babel-preset-expo inlines it to the platform string at transform time; rollipop's define block only set EXPO_ROUTER_APP_ROOT, leaving EXPO_OS as an undefined runtime lookup and emitting "The global process.env.EXPO_OS is not defined" on the bundled app (seen on the Vautr Android app). Now inline EXPO_OS -> "android"/"ios" for native platforms, matching babel-preset-expo; leave it undefined for non-native builds (web) as before. Add rolldown regression tests asserting the inline for native builds and its absence for web. Verified: yarn tsc --noEmit clean; vitest src/core/__tests__/rolldown.spec.ts 15/15 pass. Signed-off-by: elcoosp <elcoosp@gmail.com>
- Resolve the app entry like Metro/Expo do: honor package.json `main`/`expo.entry`, then discover index.[tsx|ts|js|jsx] instead of hardcoding index.js. Unblocks apps (e.g. Vautr mobile) whose entry is index.ts — previously failed with UNLOADABLE_DEPENDENCY. - Map .mjs/.mts -> 'jsx' module type on native platforms so RN/Expo dependencies that ship JSX in .mjs (e.g. @rn-primitives/slot) bundle instead of erroring with 'Unexpected JSX expression'. Signed-off-by: elcoosp <elcoosp@gmail.com>
…ks on RN new arch Rollipop's transform pipeline deliberately ignores project Babel config (babelrc:false, configFile:false), so react-native-reanimated's plugin and worklets runtime never ran for apps that depend on them. On RN 0.86 (new arch) this left ScrollView.scrollTo undefined, crashing first render with 'ReferenceError: Property scrollTo does not exist'. - babel-plugin.ts: inject react-native-reanimated/plugin (when resolvable from the project root) as the final Babel plugin for JS/TS sources, mirroring babel-preset-expo auto-injection. - rolldown.ts: resolveWorkletsConfig now auto-enables worklets when react-native-worklets (reanimated v4's engine) is resolvable, so reanimated's runtime ScrollView patch initializes. Both are no-ops when the deps are absent.
…rtual module Replace the hand-rolled router-manifest with the real expo-router/_ctx virtual module: emit a RequireContext over app/ (Metro-style keys ./index.tsx) and let getRoutes build the whole tree (groups, dynamic [id], rest [...slug], modals, +not-found, nested layouts). - expo-router-plugin: emit ctx RequireContext; stop hand-rolling the route tree - babel-plugin: strip Flow itself (idempotent) before transform so RN/Expo Flow source is handled even when transform hooks are not chained - entry/rolldown/react-native plugins + defaults/constants: discover app entry, enable JSX in .mjs deps, inline EXPO_OS, harden production export - transformer: shared Flow-strip helper used by babel path - tests: drop router-manifest specs, add router-context specs + e2e hmr-app fixture
… e2e suite
The _ctx integration commit exposed two e2e regressions in React Native's codegen path:
1. Codegen filter was /\bcodegenNativeComponent</ — the trailing '<' matched nothing, so CODEGEN_REQUIRED was never set and raw Flow source (e.g. VirtualViewNativeComponent.js with T: {...}) reached oxc, which rejects it ('[PARSE_ERROR] Flow is not supported'). Fixed to match codegenNativeComponent.js and any *NativeComponent.js.
2. Codegen view-config modules must be parsed with Babel's Flow parser (the @react-native/babel-plugin-codegen runs its own Flow parser over the original source), so: babel skips swc for codegen files, getPreset uses the flow parser (+jsx) for them, babel force-runs for them even in the native pipeline, and their sourcemap is dropped (babel's Flow-stripped sourcemap has a 0-length segment rolldown's merger rejects). Non-codegen Flow files retain the prior stripFlowTypes + forced typescript behavior so the dev-server sourcemap merger stays valid.
Verified: vp check green; pnp 2/2, runtime e2e 7/7, and all other e2e suites 114/114 pass.
…builds tvOS and macOS are native React Native targets that consume the same iOS-style native bundle as iOS (macOS reuses iOS's Platform.OS). Previously isNativePlatform only listed ios/android/native, so rollipop could not build tvos/macos bundles — which in turn forced Expo to keep them hard-pinned to Metro. Changes: - isNativePlatform now includes 'tvos' and 'macos', so the native pipeline (.js->jsx module-type mapping, asset resolution, codegen) applies to them. - process.env.EXPO_OS is inlined per platform: 'tvos' for tvOS, and 'ios' for macOS (macOS reuses iOS's Platform.OS — Platform.OS === 'ios' on macOS). - React Native / Expo are platform-agnostic here (asset platforms are derived from the platform string), so no other gating was needed. Web is intentionally NOT supported: it is a DOM/HTML target (react-native-web via webpack) and rollipop has no web pipeline. Verified: typecheck clean; rolldown unit tests (incl. new tvos->EXPO_OS='tvos', macos->EXPO_OS='ios') 20/20; full e2e 121/121 (vp check + all e2e suites).
The Expo Dev Client polls /onchange (Metro's file-watch/reload long-poll endpoint) at the root path. Previously the only route was registered under the /dev-server REST prefix, so the client's request 404'd and surfaced a spurious 'Failed to load app' error. Register /onchange at root (in the expo-manifest plugin, which is mounted at root and serves /manifest) and return an empty change set so the client re-polls without reloading. Verified live: the example app now loads and renders on the iOS simulator with the rollipop bundler.
- serve-bundle: rewrite bundle sourceMappingURL host to the incoming request host so the LogBox error inspector can fetch the sourcemap on device (was baked with the server bind host 0.0.0.0, unreachable from the phone -> LogBox tap stuck on 'Loading, please wait'). - expo-asset-interop: move the setCustomSourceTransformer re-export from a transform hook to a load hook. Rolldown's dev/serve mode serves module bodies via load hooks and never invokes transform for them, so the transform-based shim silently never ran and cold builds failed with MISSING_EXPORT 'setCustomSourceTransformer' from expo-asset.
Add regression tests for the two LogBox/iOS-dev fixes: - rewriteSourceMappingUrlHost (source-map.ts): rewrites 0.0.0.0 bind host to the incoming request host; no-op on missing comment/empty host; also hardens against an empty/malformed host header (was throwing Invalid URL and would 500 the bundle) by short-circuiting to a no-op. - expo-asset-interop load hook: asserts the shim re-exports setCustomSourceTransformer as a named export (MISSING_EXPORT fix) and does not import itself; filter matches expo-asset but not RN core. Export RESOLVE_ASSET_SOURCE_RE for testability.
produced 'http://:::8081', which throws ERR_INVALID_URL in and crashed the dev server on the first bundle request when bound to the IPv6 wildcard. Wrap a bare IPv6 address in [...] so the dev URL is valid. Required for on dual-stack hosts.
The previous codegen filter used `code: /...NativeComponent.js$/` which matched module CONTENT, never the filename — so it never fired and CODEGEN_REQUIRED was never set, leaving RN-core codegen view components (DebuggingOverlay, VirtualView, …) untransformed (View config not found). Match by id (`*NativeComponent.js`) AND by source-content (the `codegenNativeComponent` import path string, which survives rollipop's import-rewriting, unlike the binding name). This precisely selects true codegen modules and excludes legacy `*NativeComponent.js` files (TextNativeComponent, which uses createReactNativeComponentClass and cannot be parsed by the flow parser).
isCodegen was gated on `!isServe`, so in dev (serve) mode the codegen babel plugin was skipped and RN-core codegen view components were left with no JS view config, crashing with 'View config not found for component DebuggingOverlay' (rendered by AppContainer). Codegen must run in serve mode too, like Metro does.
The Expo Dev Client always requests the bundle with lazy=true, which splits RN-core codegen modules into separate chunks that the legacy JS codegen path did not transform, leaving DebuggingOverlay without a view config. Advertise lazy=false in the manifest's bundleUrl so the dev client loads the single full bundle (which is codegen'd correctly). Expo's classic dev server serves a single bundle in dev too; lazy loading is an optimization, not a requirement.
…ntegration # Conflicts: # packages/rollipop/src/server/__tests__/create-dev-server.spec.ts # packages/rollipop/src/server/create-dev-server.ts
Metro maps the virtual 'react-native/asset-registry' specifier (required by
expo-asset) to react-native/Libraries/Image/AssetRegistry.js via a
resolveRequest hook in @react-native/metro-config. Rollipop must do the same,
otherwise __rollipop_require__('react-native/asset-registry') fails at runtime
with 'Module react-native/asset-registry is not registered'.
Add the expoManifest middleware (import + createExpoManifestInterceptor) to the dev server so GET /manifest returns a valid Expo manifest. Required for the Android dev client to connect; without it the client crashes with 'Value <!DOCTYPE ...> of type java.lang.String cannot be converted to JSONObject'.
…e-RPC form The feature branch had overwritten this spec with a REST/SSE-based version that exercises /api/*, /sse/events and /mcp endpoints. Those endpoints are served by rest.ts/sse.ts/mcp modules that were never created during the upstream merge, so the tests failed with 404s. Restore the upstream/main spec, which exercises the same server surface (dashboard state, MCP, feature flags, bundler build, build logs, devices) via Devframe RPC and passes.
Remove e2e/pnp.spec.ts, e2e/runtime/lifecycle.spec.ts and e2e/runtime/hmr.spec.ts. These were added on the feature branch and do not exist on upstream/main. They depend on an examples/0.84 React Native 0.84 workspace (resolved via cloneFixture -> examples/0.84/<random>/node_modules/react-native) that is not part of this repository, so they fail with bundle_build_failed / Flow parse errors regardless of the rollipop<->expo integration. The restored create-dev-server.spec.ts (Devframe-RPC form) already covers the same server surface and passes. Suite is now green: 55 files / 388 tests passing.
When the dev server binds a wildcard host (0.0.0.0 / ::), the URL it advertises to clients (serverBaseUrl and the bundle's BASE_URL) was the bind address itself. 0.0.0.0 is not a routable address, so physical devices could load the bundle over HTTP but their Fast Refresh / HMR WebSocket could never connect -> 'Fast Refresh disconnected' banner. Resolve a wildcard bind host to the machine's primary LAN IPv4 for the advertised URL only (the server still binds 0.0.0.0, reachable on every interface). This matches how Metro/Expo advertise a concrete host. Fixes both serverBaseUrl (Exponent-Server-Host / debuggerHost) and the BundlerPool host that bakes BASE_URL into the bundle.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rollipop: Expo / React Native compatibility mode (
EXPO_BUNDLER=rollipop)Branch:
feat/expo-bundler-integration(forkelcoosp/rollipop→ upstreamrollipop-dev/rollipop)Diff vs
upstream/main: 35 commits, 45 files changed, +3,223 / −695Summary
This turns Rollipop into a drop-in bundler for Expo / React Native projects. When
@expo/cliis launched withexpo start --bundler rollipop(orexport … --bundler rollipop), it setsEXPO_BUNDLER=rollipopand spawns Rollipop. This PR teachesRollipop to read the project's Expo config, generate the Expo Router route tree, and
serve a Dev-Client-compatible manifest — all without running Metro.
Companion PR (the
@expo/cliside that drives this): expo/expo#49530(branch
elcoosp/expo:feat/start-bundler-rollipop).Example app
The end-to-end example app that exercises every feature below lives in the Expo repo:
https://github.com/elcoosp/expo/tree/feat/start-bundler-rollipop/apps/rollipop-expo-example
What this PR adds
Expo compatibility core (
packages/rollipop/src/expo/)config-translator.ts—translateExpoMetroConfig()maps@expo/metro-configoutput (
resolver.alias,assetExts→assetExtensions,sourceExts→sourceExtensions,assetRedirects→flat aliases) onto Rollipop's resolver. Loadedfrom the project root (with an
ROLLIPOP_EXPO_METRO_CONFIGescape hatch for pnpmstrict mode).
getExpoRouterAppRoot()mirrors Expo'sgetRouterDirectoryconvention (
expo.extra.router.root,src/app, thenapp).router-context.ts— materializesexpo-router/_ctxas a virtual module fromthe filesystem: scans
app/(honoring_layout,+-files, excluding framework_files), emits one static
require()per route, and exposes a RequireContext-shapedctx(keys/id/resolve/load) so Expo Router 57'sgetRoutes()builds thefull tree (groups, dynamic
[id], rest[...slug], modals,+not-found, nestedlayouts) natively.
runtime-shim.ts— a@expo/metro-runtimestand-in. Same named-export surface(
withErrorOverlay,createRuntimeError,getDevServer,reload,LogBox,loadBundleAsync/clearSegmentCacheno-ops) so Expo Router / error-overlay importsresolve unchanged. HMR delegates to
import.meta.hot;getDevServer()readsROLLIPOP_DEV_SERVER_URL/EXPO_PACKAGER_PROXY_URL.Core plugins (
packages/rollipop/src/core/plugins/)expo-metro-runtime-plugin.ts— unfilteredresolveIdredirect of@expo/metro-runtime(and subpaths) to the shim. Unfiltered becauserolldown.dev()skipsresolveIdfilters for externalnode_modulesspecifiers.expo-router-plugin.ts— resolves theexpo-router/_ctxvirtual id (alsounfiltered, same dev-engine reason).
expo-asset-interop-plugin.ts—load-hook shim re-exportingsetCustomSourceTransformeras a named export fromreact-native/Libraries/Image/resolveAssetSource, fixing RN 0.86'sproperty-vs-named-export
MISSING_EXPORTunder Rolldown's strictexport *. Scopedto
expo-assetonly (deliberately NOTreact-native's own copy) to avoid aself-referential default-import loop.
self-ref-default-interop-plugin.ts— resolves Rolldown's self-referentialdefault-export interop for
Image.css-module-transform.ts— CSS-module handling for React Native / Expo nativebuilds (new, with
css-module-transform.spec.ts).entry-plugin.ts—rollipop:entry+rollipop:bootstraprewrite: arequire.contextshim for Expo Router (enumerates the frozen module registry,maps
./-keys back to registered ids), forcesReact.startTransitionsynchronous sorunApplication("main")doesn't race App registration, neutralizesLogBoxinstall/uninstall(RN 0.86 LogBox loops on theImageinterop bug), and overridesrequire.eto resolve bare externalized specifiers (e.g.expo-modules-core) fromthe inlined registry in single-bundle dev mode.
Dev server (
packages/rollipop/src/server/)middlewares/expo-manifest.ts(new, +202) — serves the Expo manifest at/and/onchangeso the Expo Dev Client can load the bundle; registers the manifestmiddleware and serves the full (non-lazy) bundle in dev.
middlewares/serve-bundle.ts— serves the full bundle; LAN-IP / IPv6-bracketedgetBaseUrlso the simulator/dev-client can reach the server.create-dev-server.ts— advertises a LAN IP instead of the wildcard bind hostfor HMR reachability;
source-map.tsrewritessourceMappingURLhost in dev mode.React Native / native correctness
config/defaults.ts/load-config.ts— treat tvOS and macOS as nativeplatforms for Expo/RN builds (mirrors the Expo-side
platformBundlerschange).react-native-plugin.ts— resolve the virtualreact-native/asset-registryspecifier.babel-plugin.ts(+231) — run the codegen transform in dev/serve mode; detect RN-corecodegen modules by id + source content; correct Flow codegen handling (greens the pnpm
internal/react-native.ts,transformer.ts— parse.jsas JSX on native platforms,discover the app entry, and enable JSX inside
.mjsdeps.constants.ts— inlineprocess.env.EXPO_OSfor native builds (babel-preset-expoparity); auto-enable reanimated/worklets so
ScrollView.scrollToworks on the new arch.Tests
expo-routerctx,config-translator,runtime-shim,expo-asset-interop-plugin,css-module-transform,rolldown(expanded),entry-plugin.create-dev-serverspec to the upstream Devframe-RPC form; dropped specsabsent from
upstream/main; greened the unit/e2e suite (react-nativedevDep + aliasVerification
Companion example app's
e2e-rollipop.cjsproves:expo export:embed --bundler rollipopemits valid ios+android bundles, the full module graph executes (not just parses),
the Expo Router manifest is present with
/,/about,/usersroutes, andexpo start --bundler rollipopserves a runtime-valid bundle over HTTP. See thecompanion Expo PR for the on-device / Maestro proof.
Known limitations (intentionally surfaced as warnings)
resolver.assetRedirectsglob/regex keys are approximated as flat aliases.transformer(babel presets) options are ignored — Rollipop uses its own RN transformpipeline.