feat: add experimental React Native router adapter#7622
Draft
tannerlinsley wants to merge 33 commits into
Draft
Conversation
# Conflicts: # pnpm-lock.yaml
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).
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
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.
Summary
Validation
Notes
Native simulator UI rendering is still pending on a machine with Xcode simctl or an Android emulator/device.