feat:migrate to standard navigation shared adapter - #581
Open
oleksandrzavarzin-callstack wants to merge 11 commits into
Open
Conversation
oleksandrzavarzin-callstack
marked this pull request as ready for review
September 11, 2026 14:20
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.
PR Description
Expo SDK 56 removed Expo Router's dependency on
@react-navigation/*and replaced it with avendored fork.
@bottom-tabs/react-navigationstill imports the real@react-navigation/native,so under Expo Router 56+ two copies of React Navigation core load and their contexts stop lining
up.
This moves the tab view into a new framework-agnostic package built on the
standard-navigationcontract so one implementation serves both, each through its own public adapter
@bottom-tabs/react-navigation's public API is unchanged - same exports, sameTypedNavigatorsignature, same theme-derived tint defaults. It becomes a thin adapter over theshared view.
It keeps its own
useNavigationBuildercall rather than delegating tocreateStandardNavigationFactories. The standard contract projects state and descriptors down towhat every framework can express, which drops
state.keyand the per-routenavigationandroutethat a customtabBarreads.Notable
@bottom-tabs/navigation, added to the changesets fixed group so itversions with the other two. Zero runtime dependencies, peers limited to
react,react-nativeand
react-native-bottom-tabs.NativeBottomTabsContentcarries its event map and navigator props on phantom properties.Neither is inferable from the contract on its own - the event map appears only as an argument to
emitter.emit, the navigator props only inside anOmit<…>, and TypeScript cannot read a typeback out of either position. Without the carrier an integrator falls back to the base shapes and
rejects every navigator prop: the tab bar still renders, but
tabBarActiveTintColor,tabBarInactiveTintColor,tabBar,defaultTintColorsand every native prop are type errors atthe call site. The shape matches the carrier Expo Router reads; the properties never exist at
runtime.
unstable_createStandardRouterNavigator, notunstable_integrateWithRouter. The latter takes an already-built navigator object, whose type hasno carrier, so it cannot recover the props however the content is typed - annotating the navigator
with an explicit
StandardNavigator<…>still yieldsNavigatorProps = object. It callsunstable_integrateWithRouterinternally, so the runtime path is identical.@react-navigation/nativepeer raised to>=7.3.0- the one change that can affect anexisting consumer. Nothing we ship needs 7.3 at runtime any more; it is the floor for the
createStandardNavigationFactoriesrecipe in the docs.colordependency removed from@bottom-tabs/react-navigation. It is ESM-only, so everyconsumer who writes tests had to extend
transformIgnorePatternsbefore Jest would run at all.Replaced with React Native's own
processColor, which parses every format React Navigationthemes use. Unparseable values now fall back to the platform default instead of throwing.
standard-navigationis ESM-only andwe ship CommonJS. It stays a devDependency backing a compile-time conformance assertion that
fails the build if the local copy drifts.
turbo.json:testnowdependsOn: ["^build"], since the Expo Router harness resolvesworkspace packages through their build output. The adapter's own tests map
@bottom-tabs/navigationto source, so they cannot pass against a stale build.Two things worth reviewing closely
unstable_createStandardRouterNavigatoris marked unstable by Expo and may change between minorreleases. We deliberately do not call it ourselves, so a break stays in app code.
{ state, descriptors, actions, emitter }ratherthan a React Navigation
navigationobject, so@react-navigation/bottom-tabs'BottomTabBarcannot be used there. The React Navigation path is unaffected -
navigation,state.keyand thefull descriptors are still passed through, and a test pins that shape.
On the type-level surface
Both integrations are pinned by compile-time assertions, since neither failure mode shows up at
runtime - the props reach the native view either way, only the call site stops type checking.
apps/expo-router-test/src/__tests__/types.test.tsxasserts every documented navigator prop andscreen option, with
@ts-expect-erroron a wrong tint colour and an unknown screen option so thetypes cannot go loose silently. Dropping the carrier reproduces the original errors.
packages/react-navigation/src/__tests__/sharedNavigator.test.tsxrenders the documentedcreateStandardNavigationFactoriesrecipe end to end. That recipe needs an explicit type bag:StandardNavigationFactoriesdoes not carryNavigatorPropsat all, and the props reachTab.Navigatoronly through the bag'sNavigatorfield.How to test?
apps/expo-router-testruns one assertion suite against both adapters under a real Expo Routertree, with a console spy that fails on any warning or error.
To verify as a consumer does, rather than through workspace symlinks:
The Expo Router path needs only the first two - the guide tells SDK 56+ apps not to install
@bottom-tabs/react-navigation, and without it nothing resolves@bottom-tabs/navigationfrom theregistry, so no
overridesentry is required. Installreact-native-bottom-tabsinto a scaffoldedSDK 57 app along with the
react-native-bottom-tabsconfig plugin, then point the tab layout atunstable_createStandardRouterNavigator.Verified that way on an app scaffolded from
create-expo-app --template default@sdk-57(
expo ~57.0.22,expo-router ~57.0.21, React Native 0.86.3, React 19.2.3):and badge, tapping routes between
indexandexplore. No JS warnings or errors.tapping routes between the two screens. No JS warnings or errors.
@react-navigation/*is absent fromnode_modulesentirely, so there is no second copy ofReact Navigation to line up - the app resolves everything through Expo Router's vendored fork.
unstable_integrateWithRouterwiring failstscwithProperty 'tabBarActiveTintColor' does not exist, which is the regression the carrier fixes.Screenshots