Skip to content

feat:migrate to standard navigation shared adapter - #581

Open
oleksandrzavarzin-callstack wants to merge 11 commits into
callstack:mainfrom
oleksandrzavarzin-callstack:feat/migrate-to-standard-navigation-shared-adapter
Open

feat:migrate to standard navigation shared adapter#581
oleksandrzavarzin-callstack wants to merge 11 commits into
callstack:mainfrom
oleksandrzavarzin-callstack:feat/migrate-to-standard-navigation-shared-adapter

Conversation

@oleksandrzavarzin-callstack

@oleksandrzavarzin-callstack oleksandrzavarzin-callstack commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

PR Description

Expo SDK 56 removed Expo Router's dependency on @react-navigation/* and replaced it with a
vendored fork. @bottom-tabs/react-navigation still 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-navigation
contract so one implementation serves both, each through its own public adapter

@bottom-tabs/navigation          the tab view, written once. No framework peers.
   (new package)
        │
        ├─ Expo Router           unstable_createStandardRouterNavigator(
        │                          NativeBottomTabsContent, TabRouter)
        │                        wired in app code, as the guide describes
        │
        └─ @bottom-tabs/react-navigation
                                 useNavigationBuilder + TabRouter, rendering the
                                 shared view

@bottom-tabs/react-navigation's public API is unchanged - same exports, same
TypedNavigator signature, same theme-derived tint defaults. It becomes a thin adapter over the
shared view.

It keeps its own useNavigationBuilder call rather than delegating to
createStandardNavigationFactories. The standard contract projects state and descriptors down to
what every framework can express, which drops state.key and the per-route navigation and
route that a custom tabBar reads.

Notable

  • New published package, @bottom-tabs/navigation, added to the changesets fixed group so it
    versions with the other two. Zero runtime dependencies, peers limited to react, react-native
    and react-native-bottom-tabs.
  • NativeBottomTabsContent carries 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 an Omit<…>, and TypeScript cannot read a type
    back 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, defaultTintColors and every native prop are type errors at
    the call site. The shape matches the carrier Expo Router reads; the properties never exist at
    runtime.
  • The Expo Router entry point is unstable_createStandardRouterNavigator, not
    unstable_integrateWithRouter. The latter takes an already-built navigator object, whose type has
    no carrier, so it cannot recover the props however the content is typed - annotating the navigator
    with an explicit StandardNavigator<…> still yields NavigatorProps = object. It calls
    unstable_integrateWithRouter internally, so the runtime path is identical.
  • @react-navigation/native peer raised to >=7.3.0 - the one change that can affect an
    existing consumer. Nothing we ship needs 7.3 at runtime any more; it is the floor for the
    createStandardNavigationFactories recipe in the docs.
  • color dependency removed from @bottom-tabs/react-navigation. It is ESM-only, so every
    consumer who writes tests had to extend transformIgnorePatterns before Jest would run at all.
    Replaced with React Native's own processColor, which parses every format React Navigation
    themes use. Unparseable values now fall back to the platform default instead of throwing.
  • The contract types are vendored rather than imported - standard-navigation is ESM-only and
    we ship CommonJS. It stays a devDependency backing a compile-time conformance assertion that
    fails the build if the local copy drifts.
  • turbo.json: test now dependsOn: ["^build"], since the Expo Router harness resolves
    workspace packages through their build output. The adapter's own tests map
    @bottom-tabs/navigation to source, so they cannot pass against a stale build.

Two things worth reviewing closely

  • unstable_createStandardRouterNavigator is marked unstable by Expo and may change between minor
    releases. We deliberately do not call it ourselves, so a break stays in app code.
  • Custom tab bars on the Expo Router path receive { state, descriptors, actions, emitter } rather
    than a React Navigation navigation object, so @react-navigation/bottom-tabs' BottomTabBar
    cannot be used there. The React Navigation path is unaffected - navigation, state.key and the
    full 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.tsx asserts every documented navigator prop and
    screen option, with @ts-expect-error on a wrong tint colour and an unknown screen option so the
    types cannot go loose silently. Dropping the carrier reproduces the original errors.
  • packages/react-navigation/src/__tests__/sharedNavigator.test.tsx renders the documented
    createStandardNavigationFactories recipe end to end. That recipe needs an explicit type bag:
    StandardNavigationFactories does not carry NavigatorProps at all, and the props reach
    Tab.Navigator only through the bag's Navigator field.

How to test?

yarn && yarn build
yarn test        # 96 tests across 5 workspaces
yarn lint && yarn typecheck

apps/expo-router-test runs one assertion suite against both adapters under a real Expo Router
tree, with a console spy that fails on any warning or error.

To verify as a consumer does, rather than through workspace symlinks:

yarn workspace react-native-bottom-tabs pack --out /tmp/core.tgz
yarn workspace @bottom-tabs/navigation pack --out /tmp/navigation.tgz
yarn workspace @bottom-tabs/react-navigation pack --out /tmp/react-navigation.tgz

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/navigation from the
registry, so no overrides entry is required. Install react-native-bottom-tabs into a scaffolded
SDK 57 app along with the react-native-bottom-tabs config plugin, then point the tab layout at
unstable_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):

  • iOS 26 (iPhone 17 Pro) - builds, bundles in 3.5s, Liquid Glass tab bar renders with the tint
    and badge, tapping routes between index and explore. No JS warnings or errors.
  • Android 17 (Pixel 10) - builds, bundles, Material 3 tab bar renders with the tint and badge,
    tapping routes between the two screens. No JS warnings or errors.
  • @react-navigation/* is absent from node_modules entirely, so there is no second copy of
    React Navigation to line up - the app resolves everything through Expo Router's vendored fork.
  • The same app on the previous unstable_integrateWithRouter wiring fails tsc with
    Property 'tabBarActiveTintColor' does not exist, which is the regression the carrier fixes.

Screenshots

iOS 26 Android 17
image image

@oleksandrzavarzin-callstack
oleksandrzavarzin-callstack marked this pull request as ready for review September 11, 2026 14:20
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