Skip to content

feat: add experimental React Native router adapter#7622

Draft
tannerlinsley wants to merge 33 commits into
mainfrom
feat/react-native
Draft

feat: add experimental React Native router adapter#7622
tannerlinsley wants to merge 33 commits into
mainfrom
feat/react-native

Conversation

@tannerlinsley

Copy link
Copy Markdown
Member

Summary

  • add the experimental React Native router adapter release changeset and cleanup
  • harden the Metro Start transformer and normalize React Start Metro server function URLs
  • align the React Native Start example server with production server function IDs
  • update React Native example docs and state-of-work notes

Validation

  • @tanstack/react-native-router eslint, types, unit, and build checks
  • @tanstack/history unit and types
  • @tanstack/router-core unit and types
  • @tanstack/react-router targeted router test
  • @tanstack/router-plugin unit, types, and build
  • @tanstack/router-generator unit, types, and build
  • @tanstack/start-plugin-core eslint, types, unit, and build
  • @tanstack/react-start build and package export checks
  • React Native Metro bundle emits normalized /_serverFn/ RPC URLs
  • Built _start-server returns data through the same createServerFn/createClientRpc call shape used by the RN bundle

Notes

Native simulator UI rendering is still pending on a machine with Xcode simctl or an Android emulator/device.

Brings the React Native router work onto a fresh main base. Conflicts
resolved by:
- .gitignore: combined both branches' additions (eslint-plugin-start
  fixtures + ios/android wildcards)
- package.json conflicts (router-core, start-{client,server}-core,
  start-static-server-functions): took main's versions; the deps
  feat/react-native added (@tanstack/store, tiny-invariant, tiny-warning)
  are already present on main from later evolution
- pnpm-lock.yaml: took main's; will regenerate after install
…icy, file-based routing, and stack reuse

Add comprehensive React Native router capabilities:
- Deep linking system with configurable prefixes, URL parsing, and initial/runtime modes
- Native header system with route-level options inheritance and custom header escape hatch
- minStackState/defaultMinStackState lifecycle policy replacing the old stackState function
- Stack reuse navigation (stackBehavior/stackMatch/entryId) in router-core and RN bindings
- createFileRoute support and react-native target in router-generator/router-plugin
- Migrated example app to file-based routing with native headers and deep link config
- Stack debug snapshot utilities for development tooling
- Updated docs with deep linking, headers, lifecycle, and reuse sections
…example

- Add sheet detent props (sheetAllowedDetents, sheetGrabberVisible, etc.) to NativeRouteOptions with passthrough to react-native-screens
- Register @tanstack/react-native-router in publish.js for npm releases
- Update package.json: alpha label, engines node >=20.19
- Make example degit-able: concrete dep versions, simplified metro/tsconfig with monorepo auto-detection
- Clean up .gitignore and remove stale App.tsx.bak
Metro's resolver doesn't reliably follow pnpm's nested .pnpm/<pkg>@<ver>/
node_modules symlinks for runtime helpers like @babel/runtime. Adding a
public-hoist-pattern places a hoisted symlink at the workspace root so the
standard upward node_modules walk finds it. Additive — no impact on other
packages' resolution.

Required for the React Native example apps to bundle inside this monorepo.
Adds @tanstack/router-plugin/metro for React Native projects bundled with
Metro (bare RN, Expo, Expo Dev Client). The exported withTanStackRouter
wraps a Metro config and:

- runs an initial blocking route generation on metro.config.js load (via
  execFileSync of the router-cli bin) so the route tree exists before
  Metro starts the dependency graph
- starts a chokidar watcher in dev so route file changes regenerate the
  tree out-of-band; Metro's own watcher then picks up the rewritten
  routeTree.gen.ts and triggers a fast refresh

Returning the config synchronously is important: Expo's Metro CLI reads
config fields synchronously before awaiting promises, so an async wrapper
silently lost the resolver settings.

Wired into package.json exports, vite.config.ts entry, and publint-clean
across all module systems.
…ndler

NativeRouterProvider tries to render a GestureHandlerRootView when
react-native-gesture-handler is installed. In some runtimes (notably
Expo Go), the JS version of gesture-handler is present but the native
binary's TurboModule registration doesn't match what the JS expects, so
the component throws during render with:

  TurboModuleRegistry.getEnforcing(...): 'RNGestureHandlerModule' could
  not be found.

The wrap-the-require try/catch we had didn't help because the require
itself succeeds; the throw fires when the component renders.

Probe TurboModuleRegistry.get('RNGestureHandlerModule') first (returns
null instead of throwing) and bail out to the View-based fallback when
the native module isn't there. The router still runs, just without
swipe-back gestures.
Four tests covering the contract:

- returns the metro config object reference unchanged (sync identity for
  the consumer)
- runs initial route generation synchronously when enabled — proven by
  asserting the generated tree exists immediately after the call
  returns, no await
- skips generation when enableRouteGeneration is false (Config opt-out)
- skips initial generation when initialGenerate: false (option opt-out)

Each test uses a fresh tmp dir with a minimal tsr.config.json + a single
__root.tsx route so the generator has something to template.
…n't built

The 'runs initial route generation' test shells out to the compiled router-cli
bin. In nx affected runs this is built first via the dep graph; in a bare
`pnpm test:unit` it may not be. Added an isRouterCliBuilt() probe so the
suite stays green out-of-the-box and only this single integration assertion
is skipped when the prerequisite isn't available.
…+ add example matrix reference

- Adds explicit code samples for the @react-native/metro-config (stock RN)
  vs expo/metro-config flows; previously only Expo was shown.
- Calls out @tanstack/router-cli as a required dev dep (the plugin shells
  out to it for the initial blocking generate).
- Documents all withTanStackRouter options including the new
  initialGenerate escape hatch.
- Adds a Behavior notes section explaining the sync-return contract,
  initial-generate blocking cost (~300ms), and async watch mode.
- Adds a Reference Examples section pointing at the bare / expo-go /
  expo-dev-client matrix in examples/react-native/, including the
  Maestro flow skeletons.
The react-native-native-stack guide existed in the file tree but wasn't
listed in the docs sidebar config, so it didn't show up in the rendered
docs site. Adding it under the Guides section.
…ntegration

Captures the current branch layout (the RN router lives on
feat/react-native, the Start Metro adapter on a fresh branch off main),
explains why, lists the commits on each, and documents the recommended
sequencing to bring them together. Delete once merged.
…ty with iOS

Generated by @react-native-community/cli init (RN 0.81.5 template) and
renamed TmpBareScaffold → TanStackRouterBare throughout. App lives at
package com.tanstackrouterbare.

The bare example now ships both iOS and Android native projects, matching
what most stock RN apps look like. Build artifacts (android/app/build/,
android/.gradle/, etc.) are excluded via the example's .gitignore.

Use \`npm run android\` to build + install on a connected device or
emulator (requires Android Studio + ANDROID_HOME).
…ld + sync metro plugin

- bare: document the new android/ folder, npm run android workflow, and
  remove the stale "plugin doesn't have a sync entry yet" note now that
  withTanStackRouter is sync.
- expo-dev-client: add Metro plugin section explaining that the wrapper
  is now wired and what it does; the older README implied the user had
  to run routes:generate themselves.
Adds @tanstack/start-plugin-core/metro alongside the existing vite/ and
rsbuild/ adapters introduced in #7228 + #7249. Metro is fundamentally
different — it only bundles the client (the RN app), while the Start
"server" lives elsewhere as a separately deployed Vite or Rsbuild Start
build. Function ids are deterministic given the same source tree + project
root, so the two builds agree without a manifest exchange.

Public surface:

- createMetroCompiler({ framework, root, ... }) — returns a handle with
  compile(), invalidate(), and getServerFns(). Wraps the bundler-agnostic
  createStartCompiler with Metro-shaped loadModule (fs.readFile) and
  resolveId (createRequire). Always env: 'client', mode: 'build', no
  provider env, no cross-build server-fn sharing.

- transformer.cjs / transformer-impl.ts — a Metro babelTransformerPath
  wrapper. The .cjs is what Metro require()s; it dynamic-imports the
  ESM impl on the first transform call. Pre-processes args.src through
  the StartCompiler, then optionally substitutes
  process.env.TSS_SERVER_FN_BASE / import.meta.env.TSS_SERVER_FN_BASE
  with the configured serverFnBase, then delegates to the original
  Metro Babel transformer (default @react-native/metro-babel-transformer)
  for the rest of the pipeline.

The Babel transforms inside StartCompiler are reused as-is from
start-compiler/. Skipped vs the rsbuild adapter: dev-server, post-build,
multi-environment planning, virtual-modules, RSC/SWC — none of which
have an analog when only the client is being bundled. The result is a
much smaller adapter (~5 files) than rsbuild/.

Wired into package.json exports (./metro and ./metro/transformer) and
build entries; publint + attw clean across module systems.
Public entry for React Native consumers of TanStack Start. Exports:

- createReactStartMetroCompiler(options) — thin wrapper over
  createMetroCompiler from start-plugin-core/metro that pins
  framework: 'react'. For advanced/custom integrations.

- withTanStackStart(metroConfig, { serverFnBase, root, ... }) — the
  drop-in metro.config.js helper. Resolves the transformer.cjs path,
  calls its setup() with the user's options, and rewrites
  metroConfig.transformer.babelTransformerPath to point at our wrapper.
  Returns Promise<MetroConfig> (Metro accepts that).

Ships both ESM (metro.ts → metro.js) and CJS (metro.cjs hand-crafted
shim that dynamic-imports the ESM build) so users can `require()` from
a stock CJS metro.config.js. metro.d.cts mirrors the type surface for
the CJS path.

Wired into package.json ./plugin/metro export with both import/require
conditions; publint + attw clean.
…ick partially dropped

The cherry-pick of the original 'introduce 3-example matrix' commit hit
a conflict that resulted in only the README + android/ landing in bare/,
losing the JS-side files (App.tsx, package.json, metro.config.js, src/,
.maestro/, etc.) and leaving the original basic/ folder un-renamed.

Recover by checking out the canonical state from feat/react-native and
removing the stale basic/ folder. Net result matches the matrix
described in the README.
Three drift points fixed where the rebase made them obvious:

- packages/react-native-router/vite.config.ts: @tanstack/config/vite
  was renamed to @tanstack/vite-config on main; updated import + added
  tsconfigPath: './tsconfig.build.json' to match the convention.
- packages/react-native-router/tsconfig.build.json: added (matches the
  pattern from router-plugin/start-plugin-core).
- packages/router-generator/src/template.ts (react-native target): main
  removed config.verboseFileRoutes; the RN target now uses the same
  serializeRoutePath() pattern as the react/solid/vue targets.

Remaining migration work in react-native-router (10 TS errors against
main's router-core):

- Matches.tsx uses RouterState.pendingMatches and RouterState.cachedMatches
  which no longer exist on main (state was refactored as part of the
  signal-based core in #6704 and follow-ups). Need to redesign the
  pending-matches rendering logic against the new state shape.
- useRouterState.tsx accesses router.__store directly; main moved this to
  router.stores.__store.
- Transitioner.tsx's getLocationChangeInfo signature changed.
- Router constructor now requires a getStoreConfig argument.

These are real engineering tasks (not mechanical drift) and belong to a
proper feat/react-native → main migration commit, not this batch.
…branch

Captures the post-rebase state: feat/react-native + matrix work + Phase 2
Start are all on this branch now. The remaining blocker is the
react-native-router → main router-core API migration (10 TS errors
across pendingMatches/cachedMatches/__store/getStoreConfig). Lists each
specific drift point with the file:line and the recommended fix pattern
to look at on main's react-router.
The feat/react-native branch was written against an older RouterCore API.
Main has since moved through the signal-based core refactor, and this
catches react-native-router up.

Changes:

- router.ts: pass getStoreFactory to RouterCore constructor (now required).
- routerStores.ts: new file mirroring react-router's getStoreFactory,
  using @tanstack/react-store's createAtom/batch on the client and
  createNonReactiveMutableStore/createNonReactiveReadonlyStore on the
  server.
- useRouterState.tsx: router.__store → router.stores.__store.
- Transitioner.tsx: getLocationChangeInfo now takes ParsedLocation
  arguments, not a full RouterState.
- Matches.tsx:
  - cloneRouterState no longer copies pendingMatches/cachedMatches
    (those are no longer on RouterState).
  - NativeScreenMatches reads pendingMatches from router.stores.pendingMatches
    via useStore, instead of from RouterState. The transition-rendering
    behavior (show pending matches mid-navigation) is preserved by combining
    the pendingMatches snapshot with the routerState in the select callback.

react-native-router now builds clean and the existing test suite (6/6)
passes against main's router-core.
…wire Start into bare example

Two changes that go together:

1. transformer.cjs now reads its options from process.env.TSR_START_METRO_OPTIONS
   instead of a setup()-stored module variable. Metro spawns transformers in
   a worker pool (jest-worker), and module-level state set in the main
   process doesn't propagate to workers — only env vars do. setup()
   serializes options to JSON and writes the env var; the worker reads
   it lazily on first transform call.

2. examples/react-native/bare:
   - package.json: add @tanstack/react-start workspace dep.
   - metro.config.js: compose withTanStackStart(...) inside withTanStackRouter
     with serverFnBase from process.env.TSR_SERVER_FN_BASE (default
     http://localhost:3050).
   - src/server-fns/posts.ts: declare listPosts + getPost via createServerFn.
     Handler bodies are throw stubs — they only matter on the server side
     and the Metro compiler discards them.
   - routes/posts.index.tsx: opt-in import of listPosts so the Metro Start
     compiler has a transform target. Still falls back to the public
     placeholder API if TSR_SERVER_FN_BASE isn't set, so the example runs
     either way.

Verified: a fresh metro bundle now contains createClientRpc references
and SHA-256 function ids for listPosts and getPost. Bundle size moved
from 11.5MB to 10MB (handler bodies replaced with stubs).
Mirror of the bare example's Start integration:
- @tanstack/react-start added as a workspace dep.
- metro.config.js wraps with withTanStackStart in addition to withTanStackRouter.
- src/server-fns/posts.ts declares listPosts and getPost (handler bodies
  are throw stubs — they only matter when the same source is built into
  the Start server).
- routes/posts.index.tsx imports listPostsRpc and uses it when
  TSR_SERVER_FN_BASE is set; falls back to the public placeholder API
  otherwise.

Verified: expo-dev-client's iOS dev bundle now contains createClientRpc
references and the same SHA-256 function ids
(c299b00d... and d4f35c53...) that bare produces from the same source.
The deterministic id hashing means a single deployed Start server can
serve both clients without any per-client manifest exchange.
…t integration complete

The unified branch is now fully working at the bundle level: react-native-router
builds against main's router-core, all 3 examples bundle cleanly, and
Start RPC transforms produce deterministic SHA-256 ids in both bare
and expo-dev-client. The doc lists what the rebase exposed, what's verified,
and what's left before merge (runtime RPC roundtrip + eslint cleanup).
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c3e97685-dce4-4118-ab9b-8f7f29f09cd8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/react-native

Comment @coderabbitai help to get the list of available commands and usage tips.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant