Skip to content

Instantly share code, notes, and snippets.

@natew
Created July 10, 2026 08:36
Show Gist options
  • Select an option

  • Save natew/c3262ea9ff4c98edd32dd4046f64baa0 to your computer and use it in GitHub Desktop.

Select an option

Save natew/c3262ea9ff4c98edd32dd4046f64baa0 to your computer and use it in GitHub Desktop.
rn.md

One framework — React Navigation forks, patches & workarounds

Audience: React Navigation maintainers. Purpose: a complete, cited catalog of every place the One framework stack (~/one — the One router + the vxrn libraries) forks, patches, vendors, aliases, or otherwise works around React Navigation and its directly-related navigation packages, so the divergences can be evaluated for upstreaming.

Author: air research worker (track #18). Draft for review, not committed.


0. TL;DR

One does not carry a patches/ entry for any @react-navigation/* package. Instead it works around React Navigation in four ways:

  1. Two install-time dependency patches to @react-navigation/core (declared in packages/vxrn/src/patches/builtInDepPatches.ts, applied by vxrn's own patch engine, not patch-package).
  2. A vendored fork of ~18 React Navigation source files under packages/one/src/fork/, copied from tag @react-navigation/core@7.1.2 and modified in place (every divergence marked // @modified).
  3. Forks of individual navigators / components outside fork/ (StackRouter, Link, useFocusEffect, the Tabs router, a web reimplementation of the native stack view).
  4. A web alias replacing react-native-safe-area-context with an in-house reimplementation, plus native/web file-split navigator factories.

The three headline drivers, in order of how much code they force One to fork:

  • SSR + hydration determinism. React Navigation generates route keys with nanoid() (non-deterministic), and its BaseNavigationContainer is built for a live client. One forks getStateFromPath to emit deterministic keys, forks NavigationContainer to add a server-only fast path, and reaches into three un-exported internal context modules to build a lean SSR container. This single theme cascades into a second fork (see the native deep-link bug below).
  • File-based routing semantics. Groups (group), dynamic [param], catch-all [...param], and per-group initialRouteName are not concepts React Navigation's linking layer has, so getStateFromPath / getPathFromState are heavily rewritten (59 and 36 marked edits respectively).
  • The native/web split. @react-navigation/native-stack and react-native-safe-area-context are native-first; One provides web replacements or aliases behind the same import.

Version context (read every "v8 fixes it" claim against this):

stack @react-navigation/native @react-navigation/core other
~/one (installed) 7.2.2 7.17.2 native-stack 7.14.10, routers 7.5.3, elements 2.9.14, bottom-tabs 7.15.9, drawer 7.9.8; screens 4.23.0, safe-area-context 5.6.2
~/soot 7.2.2 (via one) screens 4.23.0, safe-area 5.6.2no soot-specific nav patch
~/takeout 7.1.28 (via one) drawer 7.7.13no takeout patches dir at all
upstream main (clone) 8.0.0-alpha.34 8.0.0-alpha.25 v8 is alpha only, not stable-released (latest alpha dated 2026-07-08)

The fork files were copied from @react-navigation/core@7.1.2; the installed packages are 7.2.2 / 7.17.2. So One is already a few minors behind its own fork base, and two majors behind upstream main. v8 has not shipped stable, which matters: nothing here is "fixed by simply upgrading" today.

~/soot and ~/takeout add no navigation forks/patches of their own — they are pure consumers of One's versions. Everything below lives in ~/one.


1. Install-time dependency patches (@react-navigation/core)

Both are declared in packages/one/../vxrn/src/patches/builtInDepPatches.ts and applied to node_modules at install by vxrn's patch runner (packages/vxrn/src/utils/patches.ts). They rewrite files in the installed package, patch-package style.

1.1 — Expose three un-exported internal context modules

What it is. Patches @react-navigation/core's package.json exports to add subpath entries for three internal modules (builtInDepPatches.ts:8-32):

// expose internal contexts for SSR-optimized NavigationContainer
// validated against 7.x. when bumping past 7.x, add a new entry pinned to the
// new major and re-verify the lib/module/* paths still exist.
for (const path of [
  './lib/module/NavigationBuilderContext',
  './lib/module/NavigationStateContext',
  './lib/module/EnsureSingleNavigator',
]) {
  if (!exports[path]) { exports[path] = path + '.js'; changed = true }
}

Why it exists. One's SSR container (packages/one/src/fork/SSRNavigationContainer.tsx:10-16) imports those three contexts directly to provide static/no-op values during server render:

// @ts-ignore internal module (exports patched at install time)
import { NavigationBuilderContext } from '@react-navigation/core/lib/module/NavigationBuilderContext'
import { NavigationStateContext } from '@react-navigation/core/lib/module/NavigationStateContext'
import { SingleNavigatorContext } from '@react-navigation/core/lib/module/EnsureSingleNavigator'

@react-navigation/core's exports map only exposes . and ./package.json, so these modules are unreachable through the public API and Node's exports resolution would throw without the patch.

Ideal upstream fix. Either (a) officially export the low-level context objects (NavigationBuilderContext, NavigationStateContext, SingleNavigatorContext) under a stable ./contexts or unstable_ subpath, or (b) ship a first-class lightweight SSR container (see §2.5) so consumers never need the internals.

v8 status — NOT fixed (verified). In the clone, @react-navigation/core@8.0.0-alpha.25 package.json still exports only "." and "./package.json" (~/github/react-navigation/packages/core/package.json exports block). The internal context modules remain un-exported. The patch (or an equivalent) would still be required on v8.

1.2 — Re-alias createComponentForStaticNavigation

What it is. For @react-navigation/core >=7.17.0 <8.0.0, re-adds an export alias in lib/module/index.js and lib/commonjs/index.js (builtInDepPatches.ts:34-62):

// @react-navigation/core 7.17 renamed createComponentForStaticNavigation to
// createComponentForStaticConfigDeprecated, breaking @react-navigation/native
// which still imports the old name. re-export the old name as an alias.

Why it exists. A version-skew regression inside React Navigation's own monorepo: core@7.17 renamed the export, but a co-installed older @react-navigation/native still imported the previous name, producing an undefined import at runtime.

Nuance (honest). On the currently installed core@7.17.2 this patch is a defensive no-op: node_modules/@react-navigation/core/lib/module/index.js:20 already re-exports the old name (createComponentForStaticNavigationDeprecated as createComponentForStaticNavigation), and the patch's own guard (if (contents.includes('createComponentForStaticNavigation')) return) bails. It only bites on the specific 7.17.x sub-version that shipped the rename without a back-compat alias.

Ideal upstream fix. Keep core and native export names in lockstep within a minor line; never rename a public export that a same-generation sibling package imports without shipping the alias in the same release.

v8 status — resolved by design (verified). In v8 the symbol is uniformly createComponentForStaticConfig in both packages (packages/core/src/StaticNavigation.tsx:700, packages/core/src/createNavigatorFactory.tsx:7); the deprecated/renamed name churn is gone. The patch is unnecessary once the whole stack is on v8 (and is already inert on 7.17.2).

1.3 — Navigation-adjacent native patches (not @react-navigation/*, listed for completeness)

  • react-native-gesture-handler getShadowNodeFromRef null-guard (builtInDepPatches.ts:269-322). "fix RNGH crash on RN 0.81+ new architecture — getShadowNodeFromRef crashes when findHostInstance_DEPRECATED returns null." RNGH underpins Drawer and the gesture-driven stack, so it's in the navigation blast radius, but the bug and fix belong to react-native-gesture-handler, not React Navigation. Upstream ask goes to RNGH.
  • react-native-reanimated ['jsx'] transform (builtInDepPatches.ts:260-265) and @react-native-masked-view/masked-view (324-329) are build-time source transforms (Flow/JSX), not behavioral nav patches. Masked-view is a header/transition dependency; noted only so the list is exhaustive.

2. Vendored fork of React Navigation source (packages/one/src/fork/)

Every file here carries the banner "This file is copied from the react-navigation repo … @react-navigation/core@7.1.2 … Please refrain from making changes … All modifications except formatting should be marked with // @modified." The count of marked divergences per file (a proxy for how far each has drifted):

file @modified markers drift
getStateFromPath.ts (+ getStateFromPath-mods.ts) 59 heavy rewrite
useLinking.ts 53 heavy rewrite
getPathFromState.ts (+ getPathFromState-mods.ts) 36 heavy rewrite
createMemoryHistory.tsx 15 moderate
NavigationContainer.tsx 7 SSR fast-path graft
useLinking.native.ts ~4 targeted deep-link fix
validatePathConfig.ts 3 import-path only
findFocusedRoute.tsx 1 import type only
useThenable.tsx, useDocumentTitle(.native).tsx, useBackButton(.native).tsx 0 verbatim copies

2.0 — Why fork at all? (the umbrella reason)

Two structural reasons stack up before any single bug:

  1. Run the linking layer in Node without importing React Native. The verbatim copies state it plainly — findFocusedRoute.tsx:8: "No modifications currently, copied so we can access without importing any React Native code in Node.js environments." One executes getStateFromPath / path resolution during SSR and at build time in a Node/RSC context; pulling these through @react-navigation/native drags in react-native.
  2. File-based routing. One layers expo-router-style semantics (groups, dynamic, catch-all, per-group initialRouteName) that the stock linking functions don't model.

Ideal upstream fix for (1). Guarantee that the pure path<->state helpers (getStateFromPath, getPathFromState, getActionFromState, findFocusedRoute, validatePathConfig) are importable from @react-navigation/core with zero transitive react-native import, and are documented as Node-safe. They mostly already live in core; the guarantee (and a subpath that a bundler can prove is RN-free) is what's missing.

2.1 — getStateFromPath.ts — deterministic keys ("HYDRATION FIX") + file-based routing

What it is. Two distinct classes of change:

  • Deterministic route keys. getStateFromPath.ts:665-682:

    // HYDRATION FIX: Add deterministic keys to routes so react-navigation preserves them
    // instead of generating new ones with nanoid(). This prevents SSR/hydration key mismatch.
    let routeKeyCounter = 0
    function getRouteWithKey<T extends { name: string }>(route: T): T & { key: string } {
      const key = `${route.name}-${routeKeyCounter++}`
      return { ...route, key }
    }

    The counter is reset at the top of every call (getStateFromPath.ts:102-103, resetRouteKeyCounter()), and keys are attached in createStateObject (:695-717).

  • File-based routing rewrite. Group collapsing, catch-all/dynamic matching, per-group initial-route ranking, "params available at all levels," and Hermes-safe URL parsing all live in the sibling getStateFromPath-mods.ts (e.g. getUrlWithReactNavigationConcessions at :29, with // parse pathname and hash without URL constructor (Hermes doesn't support file: base URLs) at :38; the ranking comparator getRouteConfigSorter at :125-295).

Why it exists. React Navigation assigns route keys with nanoid() (non-deterministic). Server-rendered state and client-hydrated state then get different keys for the same route, so React Navigation treats them as different routes and hydration mismatches. One makes keys a deterministic function of name + ordinal so the server and client agree.

Ideal upstream fix. Make key generation injectable/deterministic — e.g. an optional getRouteKey/seeded generator on the linking options, or content-based keys derived from route name + position, used consistently in the routers and in getStateFromPath. That single change removes One's need to fork getStateFromPath for keys and removes the cascading native fix in §2.4.

v8 status — NOT fixed (verified). v8 routers still mint keys with nanoid(): packages/routers/src/BaseRouter.tsx:96 ({ ...route, key: \${route.name}-${nanoid()}` }) and multiple sites in packages/routers/src/StackRouter.tsx (:301,307,326,349,367,411,660). v8's getStateFromPath` still emits keyless routes. The hydration determinism problem — and this workaround — persist on v8. (The file-based-routing rewrite is inherent; see §5.)

2.2 — getPathFromState.ts — state→path for file-based routing

What it is. 36 marked edits (getPathFromState.ts + getPathFromState-mods.ts) that extract a getPathDataFromState so One can read both path and params (:79), swap in One's dynamic-segment/param-name helpers (:227-230, using sharedModUtils.getParamName), and union extra config (ConfigItemMods).

Why it exists. Same file-based-routing model as §2.1, in the reverse direction, plus the need to surface params separately from the path string.

Ideal upstream fix. Same as §2.1's file-based-routing half — this is inherent to One layering a different route-tree model on top of React Navigation. v8 is actively improving automatic path generation (CHANGELOG 8.0.0-alpha.19 "fix various issues with path generation with automatic paths"), which may shrink the diff but won't remove the fork.

v8 status — inherent (not a bug v8 "fixes").

2.3 — useLinking.ts (web) — SSR fast path, hash navigation, masked URLs, StrictMode

What it is. The most heavily forked file (53 edits). Distinct workarounds:

  • Hash navigation. useLinking.ts:429-434:

    // @modified: workaround to make react-navigation handle hash changes
    if (index > previousIndex ||
        (index === previousIndex &&
          (!record || `${record?.path}${location.hash}` === path))) {
      const action = getActionFromStateRef.current(state, configRef.current)
      ...

    Upstream's web useLinking won't dispatch when only the #hash changes on the same path, so hash navigation is inert; One forces an action.

  • SSR fast path. :169 // @modified - SSR fast path: skip all client-only linking logic.

  • Masked URLs / __tempLocation. :237-243, :303-305 — One tracks a maskedDisplayPathRef and restores a masked display path across onStateChange (a One feature: navigate to route X while showing URL Y).

  • React Strict Mode double-mount guard. :239 // @modified - Track if initial history setup is complete (handles React Strict Mode double-mount).

  • ServerContext replaced. :275-281 — upstream ServerContainer sets location via ServerContext; One uses an initialLocation prop instead and comments out the @react-navigation/web ServerContext import.

  • Deterministic-key import. :13,20 — imports One's forked getStateFromPath for the keys described in §2.1.

Why it exists. Web history integration, hash routing, URL masking, and SSR location injection that One needs and stock useLinking doesn't provide (or provides differently).

Ideal upstream fix.

  • Hash: dispatch a navigation (or a dedicated hash-change event) when the path is unchanged but the fragment changed, or expose a documented hook for it.
  • URL masking: first-class "navigate to state A, display URL B" support (expo-router and One both want this).
  • SSR location: allow the initial location to be passed as a prop to the container rather than only through context.

v8 status — PARTIAL / UNVERIFIED. v8's web useLinking (packages/native/src/useLinking.tsx:526-534) now preserves the hash when writing the outgoing path if the focused route key is unchanged (// Preserve the hash if the route didn't changepath = path + location.hash). That addresses the path-generation/write side. On the incoming side, v8's popstate handler (useLinking.tsx:281 history.listen(...), dispatch decision at :436 "We should only dispatch an action when going forward") keys off the history index delta, not a hash-only same-path change — the same shape One forked — so One's incoming-hash concern most likely still applies. But I could not fully trace it to a definitive verdict from static reading; mark unverified / likely-not-fixed. v8 also "rework[ed] server rendering API" (CHANGELOG 8.0.0-alpha.18, PR #13118), so the ServerContext/initialLocation divergence should be re-evaluated against the new API before assuming it's still needed. The masking and StrictMode guards appear to have no upstream equivalent — treat as not fixed.

2.4 — useLinking.native.ts — the stripRouteKeys deep-link bug (cascades from §2.1)

What it is. useLinking.native.ts:27-46 + :221-222:

// @modified: our fork of getStateFromPath assigns deterministic route keys
// (SSR/hydration stability), but upstream getActionFromState only produces a
// nested NAVIGATE for keyless routes — keyed routes degrade to a whole-tree
// RESET that the One router store immediately reverts, so incoming Linking
// 'url' events never navigated on native. Strip the keys before deriving the
// action; hydration keys only matter for web SSR, never for a live native
// deep link.
function stripRouteKeys(state) { /* recursively delete route.key */ }
...
// @modified: keys stripped so getActionFromState can produce NAVIGATE
const action = getActionFromStateRef.current(stripRouteKeys(state), configRef.current)

Plus a smaller edit at :100-115 suppressing the "linking configured in multiple places" console.error on Android, "where activity recreation causes remounts," and only warning when there is truly more than one handler.

Why it exists. Directly caused by §2.1. getActionFromState only emits a NAVIGATE action when routes are keyless; with keys present it falls back to a full-tree RESET, which One's router store then reverts — so native Linking url events silently did nothing. One strips the keys it added, just for action derivation.

Ideal upstream fix. Two independent options, either removes this:

  • Make getActionFromState produce a NAVIGATE for keyed routes too (use the key when present rather than bailing to RESET); and/or
  • Solve keys deterministically upstream (§2.1) so One never adds keys that break action derivation.

v8 status — NOT fixed (verified). v8's getActionFromState (packages/core/src/getActionFromState.tsx:73-81, and again :153-160) still gates the NAVIGATE/RESET decision on firstRoute.key == null / secondRoute?.key == null, emitting type: 'RESET' (:81) when keys are present. The exact limitation One's comment describes is intact on v8.

Android warning suppression: upstream still emits the multi-handler console.error; the false-positive-on-activity-recreation case is a real DX paper-cut. Ideal fix: don't warn (or dedupe handlers) across an Android activity recreation remount. v8 status: not fixed / unverified.

(Note: the getInitialURL 150ms Promise.race timeout at useLinking.native.ts:55-63 with // Workaround for https://github.com/facebook/react-native/issues/25675 is copied verbatim from upstream — it is React Navigation's own workaround for a React Native bug, not a One-added divergence. Listed so it isn't mistaken for One's.)

2.5 — NavigationContainer.tsx + SSRNavigationContainer.tsx — the SSR fast path

What it is. NavigationContainer.tsx:86-99 grafts a server-only branch onto the container:

// @modified - SSR fast path: bypass BaseNavigationContainer entirely
// BaseNavigationContainer has 32+ hooks and 7 providers that are all
// unnecessary on SSR (event emitters, child listeners, state sync, etc.)
if (typeof window === 'undefined') {
  return <SSRNavigationContainer initialState={initialState} theme={theme} linking={linking}>{children}</SSRNavigationContainer>
}

SSRNavigationContainer.tsx then hand-rolls the five contexts child navigators actually read during render, with static/no-op values — a no-op navigation ref (:41-67), a builder context whose scheduleUpdate runs the callback immediately (:29-31, "useNavigationBuilder calls this during render"), and a WeakMap cache of partial state keyed by state identity to "avoid deep clone thrashing under concurrent SSR requests with different URLs" (:81-93).

Why it exists. BaseNavigationContainer is designed for a live client (event emitters, useSyncExternalStore, child listener registries, back-button handling). On the server all of that is dead weight; One measured it as "32+ hooks and 8 providers" and replaced it with a 5-provider static shell. This is a performance workaround, not a correctness one — and it's the reason patch §1.1 exists (it needs the un-exported contexts).

Ideal upstream fix. Ship a first-class SSR/static render mode for the container — a BaseNavigationContainer variant (or a mode="static" prop) that renders the tree once with inert event/state machinery and no client-only hooks. That removes both this fork and the §1.1 export patch.

v8 status — NOT fixed for the perf path (verified). v8 does have a ServerContainer (packages/native/src/server/index.tsx) and reworked its server-rendering API (CHANGELOG 8.0.0-alpha.18, PR #13118) — but that ServerContainer is only a request-scoped location provider ({ location } on a context); it still renders the full BaseNavigationContainer underneath. It does not eliminate the hook/provider overhead One is avoiding. The lean static-render path One built has no upstream equivalent yet. (Re-verify against the reworked API before shipping, but the overhead concern stands.)

2.6 — createMemoryHistory.tsx — web history shim

What it is. 15 marked edits over React Navigation's web memory-history abstraction (the history.pushState/popstate bridge). Mostly retained upstream logic (the file still carries upstream's own comments like the "hacky timeout" at :262); One's edits adapt it to its history-record model.

Why it exists. One drives web history itself (masked URLs, history records consulted in useLinking.ts:400-407), so it needs a memory-history it controls.

Ideal upstream fix. Low priority. If the web history integration were more composable (pluggable history adapter), One could inject rather than fork. Largely inherent to One owning web history.

v8 status — inherent, not a targeted bug.

2.7 — extractPathFromURL.ts — native deep-link/URL parsing (expo-lineage, adjacent)

What it is. Native URL→path extraction for Expo Go / dev-client / universal links, including parsePathFromExpoGoLink and the "Major hack to support the makeshift expo-development-client system" (extractPathFromURL.ts:68).

Why it exists. Feeds cleaned paths into the forked getStateFromPath. This is expo-linking territory, not React Navigation — included because it lives in fork/ and is part of the native linking path, but the upstream owner is Expo, not React Navigation. No RN ask here.

2.8 — Verbatim copies (Node-safety only)

findFocusedRoute.tsx, useThenable.tsx, useDocumentTitle.tsx / .native.tsx, useBackButton.tsx / .native.tsx, and validatePathConfig.ts carry 0–3 edits (imports only). They exist purely to be importable without the @react-navigation/nativereact-native chain (see §2.0-1). ui/useComponent.tsx ("Copied from @react-navigation/core", :4) is the same story for the useComponent/NavigationContent helper.

Ideal upstream fix: the Node-safe subpath guarantee from §2.0-1 makes all of these unnecessary — One could import them instead of copying.


3. Forked navigators & components (outside fork/)

3.1 — views/OneStackRouter.tsx — forks StackRouter.getStateForAction

What it is. views/OneStackRouter.tsx:1-2 "forked from @react-navigation/routers/src/StackRouter.tsx — the only changes have @nate before them." It wraps StackRouter and overrides getStateForAction to special-case catch-all routes (:14-26):

// @nate
// fix for [...spread] routes not having a stable useId() causing issues
// this fixes it to be stable because we actually don't want to stack when its a ...spread route
if (action.type === 'NAVIGATE') {
  if (firstRoute.name.includes('[...') && outRoutes.every(x => x.name === firstRoute.name)) { ... }
}

Web-only (process.env.TAMAGUI_TARGET !== 'native').

Why it exists. Navigating between two instances of the same catch-all ([...spread]) route pushed a new stack entry with a fresh key, so useId() and mounted state weren't stable across what should be the same screen.

Caveats (honest). The override still contains several commented-out abandoned attempts (:30-45, "this isn't working, we need a deeper solution") — so this is an acknowledged partial fix, and it's partly entangled with One's own key/stacking semantics (§2.1), not a clean upstream bug.

Ideal upstream fix. Give the stack router a way to treat a navigation to the same route name with different params as a replace-or-update rather than a push (configurable "singleton"/getId-style de-dup at the router level for catch-all segments). v8's getId and retained/preloaded-route rework may already provide a cleaner hook.

v8 status — UNVERIFIED. v8 reworked stack preload/retain semantics (CHANGELOG 8.0.0-alpha.12 "add support for retaining screens", 8.0.0-alpha.13 "rework preload handling in stack" — a breaking change to state.routes) and added <Activity mode="hidden"> support (8.0.0-alpha.5). Whether any of that makes the spread-route id stable enough to drop this override needs a runtime test on v8; do not claim it's fixed.

3.2 — router/web/WebStackView.tsx + WebStackNavigator.tsx — web reimpl of native-stack

What it is. A file-split navigator factory:

  • layouts/stack-navigator.ts → native: createNativeStackNavigator (stock).
  • layouts/stack-navigator.web.ts → web: One's createWebStackNavigator.

The web view (router/web/WebStackView.tsx) reuses NativeStackView from @react-navigation/native-stack but adds a render prop and keepMounted option for overlay presentations (modal / formSheet / pageSheet), driven by ScreenRenderContext.tsx (an open/dismiss protocol the render component uses to animate overlays and then call StackActions.pop, :10-13).

Why it exists. @react-navigation/native-stack is native-first (it drives UINavigationController via react-native-screens). On web, One wants declaratively-controlled overlay presentations with custom animation and the ability to keep a route's subtree mounted while navigated away. Stock native-stack on web doesn't offer the render/keepMounted control One needs.

Ideal upstream fix. A documented, web-friendly overlay/presentation API on native-stack (or a @react-navigation/web-stack) with mount-retention and a custom-render hook. Partly overlaps v8's retained-screens work (§3.1).

v8 status — inherent to the native/web split; UNVERIFIED whether v8's retained-screens API narrows it.

3.3 — link/Link.tsx — forks @react-navigation/native's Link

What it is. link/Link.tsx:1-2 "Fork of @react-navigation/native Link.tsx with href and replace support added and to / action support removed."

Why it exists. React Navigation's Link navigates via to/action props; One wants a web-anchor-shaped API (href, replace, push, asChild via Radix Slot) that matches file-based routing and renders a real <a> on web.

Ideal upstream fix. Support an href-style target and a replace prop on the upstream Link (both are common asks). Note this is as much an API-design preference as a gap — may stay a fork regardless.

v8 status — not a bug; API divergence. UNVERIFIED that v8's Link adds href/replace.

3.4 — useFocusEffect.ts — forks useFocusEffect

What it is. useFocusEffect.ts:1-2 "A fork of useFocusEffect that waits for the navigation state to load before running the effect. This is especially useful for native redirects." Also defaults deps to [] so a one-arg useFocusEffect(useCallback(fn, [])) doesn't crash (:30).

Why it exists. One's routing resolves navigation state asynchronously (loaders, native redirects); the stock useFocusEffect can run before the state is ready. The deps default is a DX guard against a common crash.

Ideal upstream fix. (a) Default useFocusEffect's deps to []; (b) make it wait for navigation.isReady() (or expose a ready-gated variant). Both are small, self-contained upstream changes.

v8 status — UNVERIFIED.

3.5 — ui/TabRouter.tsx, ui/Tabs.tsx, ui/TabTrigger.tsx — headless tabs (expo-lineage)

What it is. ui/TabRouter.tsx extends React Navigation's TabRouter with a triggerMap and a custom JUMP_TO action (ExpoTabRouterOptions, ExpoTabActionType, :12-28) — the expo-router "headless Tabs" API.

Why it exists. A slot-based, fully custom tab-bar API on top of React Navigation's tab state machine. Derived from expo-router; inherent to offering that API surface.

Ideal upstream fix. None strictly required — React Navigation's TabRouter is already the extension point being used here. Listed for completeness.

3.6 — router/useScreens.tsxScreen/Group extraction hack

What it is. router/useScreens.tsx:33-34,75:

// `@react-navigation/core` does not expose the Screen or Group components directly, so we have to
// do this hack.
export const { Screen, Group } = createNavigatorFactory({} as any)()

One instantiates a throwaway navigator factory with {} as any solely to pull the Screen and Group components off the result.

Why it exists. @react-navigation/core returns Screen/Group only from a constructed navigator, never as standalone exports.

Ideal upstream fix. Export Screen and Group as standalone components from @react-navigation/core (they already exist as ./Screen and ./Group modules internally).

v8 status — NOT fixed (verified). In v8, createNavigatorFactory.tsx:75-76 and :110-111 still return Screen/Group only from the factory result; the core index (packages/core/src/index.tsx) exports createScreenFactory and the Screen/Group types, but not the bare runtime components. v8's new createScreenFactory does not help here — it's a static-config identity helper (packages/core/src/StaticNavigation.tsx:427, body ((config) => config)) that returns a screen config object, not the runtime Screen/Group components. The {} as any extraction hack still applies on v8.


4. Aliases & native/web split (directly-related packages)

4.1 — react-native-safe-area-context@vxrn/safe-area (web alias)

What it is. packages/vxrn/src/config/getBaseViteConfigOnly.ts:111-114 aliases react-native-safe-area-context to the in-house @vxrn/safe-area (packages/safe-area/), a from-scratch reimplementation of the safe-area-context API surface (SafeAreaProvider, SafeAreaInsetsContext, SafeAreaFrameContext, SafeAreaView, useSafeAreaInsets).

Why it exists. safe-area-context is native-first (reads insets from a native module). On web there are no device insets; One provides a web implementation (zero/env(safe-area-inset-*)-style) and controls bundle size. React Navigation's elements/headers depend on safe-area-context, so this sits directly under the nav stack.

Ideal upstream fix. None on React Navigation's side — this is a safe-area-context native/web concern. safe-area-context does ship a react-native-web build; One's alias is a deliberate replacement for behavior + bundle control. Inherent to the native/web split.

4.2 — Broad react-nativereact-native-web alias (context)

getBaseViteConfigOnly.ts:100-110 aliases react-nativereact-native-web (and empties react-native/Libraries/*). Not nav-specific, but it's why the native-first nav packages (native-stack, screens) need web handling at all. Listed as the substrate the native/web split rests on.

4.3 — react-native-screens feature flag (not a fork)

packages/one/src/screensFeatureFlags.ts toggles react-native-screens' synchronous-layout feature flag; ui/TabSlot.tsx uses Screen/ScreenContainer directly. This is normal API usage, not a fork/patch — included so the audit is exhaustive.


5. Version pins & prebundle config (not behavioral)

  • Root ~/one/package.json:99-100 overrides pin @react-navigation/native to 7.2.2 and react-native-reanimated to 4.2.1 (dedupe pins, not patches).
  • packages/vxrn/src/config/getBaseViteConfigOnly.ts:17-21 and getOptimizeDeps.ts:81-85 list the @react-navigation/* packages for Vite pre-bundling/interop (they ship mixed ESM/CJS). Build config, not a fork.

These change which version resolves and how it's bundled, never its behavior.


6. Adversarial v8 verification summary

Every "v8" cell was checked against the actual clone at ~/github/react-navigation (@react-navigation/core@8.0.0-alpha.25, native@8.0.0-alpha.34). v8 is alpha, not stable — so "fixed in v8" means "the alpha source no longer has the limitation," not "available in a release you can ship today."

# Workaround v8 status Evidence
1.1 Export internal contexts patch NOT fixed v8 core package.json still exports only . + ./package.json
1.2 createComponentForStaticNavigation alias Resolved by design (and already inert on 7.17.2) v8 uniformly uses createComponentForStaticConfig (StaticNavigation.tsx:700)
2.1 Deterministic keys / HYDRATION FIX NOT fixed v8 routers still nanoid() keys (BaseRouter.tsx:96, StackRouter.tsx)
2.2 getPathFromState file-based rewrite Inherent v8 improving auto-paths (CHANGELOG alpha.19) but no file-based model
2.3 useLinking hash navigation PARTIAL / unverified v8 added hash preservation on write (useLinking.tsx:526-534); incoming-dispatch not confirmed
2.3 useLinking masked URL / StrictMode / initialLocation NOT fixed (re-check vs reworked SSR API) no upstream equivalent found; SSR API reworked (alpha.18)
2.4 stripRouteKeys native deep link NOT fixed v8 getActionFromState.tsx:73-81 still gates NAVIGATE on key == null → RESET
2.5 SSR lean container NOT fixed (perf path) v8 ServerContainer only injects location; still renders full BaseNavigationContainer
2.8 Node-safe verbatim copies NOT fixed no documented RN-free subpath guarantee
3.1 Spread-route stable id (StackRouter fork) UNVERIFIED v8 reworked preload/retain (alpha.12/13, breaking) + Activity — needs runtime test
3.2 Web native-stack reimpl Inherent / unverified native/web split; v8 retained-screens may narrow it
3.3 Link href/replace Not a bug / unverified API-shape divergence
3.4 useFocusEffect ready-gating + deps default UNVERIFIED small upstream change if adopted
3.6 Screen/Group extraction hack NOT fixed v8 still returns them only from createNavigatorFactory()
4.1 safe-area-context web alias Inherent native/web concern, not RN

7. Prioritized asks for React Navigation maintainers

A. Removable today by upstream changes already in v8 (once One adopts v8)

  • §1.2 the createComponentForStaticNavigation rename patch — v8's consistent createComponentForStaticConfig naming makes it unnecessary (and it's already a no-op on core@7.17.2). This is the only workaround an as-is v8 upgrade clearly retires.

B. Needs a specific upstream change (highest leverage first)

  1. Deterministic route keys (fixes §2.1 and §2.4 together). Make key generation injectable/deterministic — an optional seeded generator or content-derived keys used consistently in the routers and getStateFromPath. This removes One's biggest fork and the native deep-link stripRouteKeys fork in one change. This is the single most valuable ask.
  2. getActionFromState should handle keyed routes. Emit a NAVIGATE for routes that carry keys instead of falling back to a whole-tree RESET (getActionFromState.tsx:73-81). Independently removes §2.4 even if keys stay non-deterministic.
  3. A real static/SSR render mode for the container. A BaseNavigationContainer variant (or mode="static") that renders once with inert event/state machinery and no client-only hooks. Removes §2.5 and the §1.1 export patch. v8's ServerContainer is location-injection only; the overhead problem is unaddressed.
  4. Export Screen and Group as standalone components from @react-navigation/core. Removes §3.6 (the {} as any factory hack). One-line change; the modules already exist.
  5. Guarantee a React-Native-free import path for the pure path/state helpers (getStateFromPath, getPathFromState, getActionFromState, findFocusedRoute, validatePathConfig), documented as Node/RSC-safe. Removes the §2.8 verbatim copies and reduces the reason to vendor at all.
  6. Hash navigation on web: dispatch (or emit a documented event) on a same-path fragment-only change. Partially in progress in v8 (write side) — confirm the read/dispatch side. Removes half of §2.3.
  7. useFocusEffect niceties: default deps to [] and gate on navigation.isReady() (or ship a ready-gated variant). Removes §3.4.
  8. First-class URL masking ("navigate to state A, display URL B") — wanted by both One and expo-router. Removes part of §2.3.
  9. Web-friendly overlay/presentation + mount-retention API on native-stack (or a dedicated web-stack) with a custom-render hook. Narrows §3.2; overlaps v8's retained-screens work.

C. Inherent to the One / native-web split (won't be removed by any upstream change)

  • File-based routing rewrites of getStateFromPath/getPathFromState (§2.1 file-based half, §2.2) — a different route-tree model layered on top.
  • The web reimplementation of native-stack (§3.2) and the safe-area-context web alias (§4.1) — native-first packages need web bodies.
  • createMemoryHistory (§2.6) — One owns web history integration.
  • extractPathFromURL (§2.7) and the Tabs router (§3.5) — Expo-lineage, not React Navigation's to fix.
  • The Link href/replace shape (§3.3) — an API-design preference, likely a permanent fork even if upstream adds href.

8. Sources

One (all paths under ~/one/):

  • packages/vxrn/src/patches/builtInDepPatches.ts (§1.1 :8-32, §1.2 :34-62, RNGH :269-322), packages/vxrn/src/utils/patches.ts (patch engine).
  • packages/one/src/fork/SSRNavigationContainer.tsx, NavigationContainer.tsx, getStateFromPath.ts (+ -mods.ts), getPathFromState.ts (+ -mods.ts), useLinking.ts, useLinking.native.ts, createMemoryHistory.tsx, extractPathFromURL.ts, findFocusedRoute.tsx, useThenable.tsx, useDocumentTitle(.native).tsx, useBackButton(.native).tsx, validatePathConfig.ts.
  • packages/one/src/views/OneStackRouter.tsx, link/Link.tsx, useFocusEffect.ts, ui/TabRouter.tsx, ui/useComponent.tsx, router/useScreens.tsx, router/web/WebStackView.tsx, router/web/WebStackNavigator.tsx, router/web/ScreenRenderContext.tsx, layouts/stack-navigator.ts / .web.ts, screensFeatureFlags.ts.
  • packages/vxrn/src/config/getBaseViteConfigOnly.ts (aliases), getOptimizeDeps.ts (prebundle), packages/safe-area/, root package.json overrides, packages/one/package.json deps.

Upstream (all under ~/github/react-navigation/, main @ 8.0.0-alpha):

  • packages/core/package.json (exports), packages/core/src/getActionFromState.tsx (:73-81), packages/routers/src/BaseRouter.tsx (:96) + StackRouter.tsx (key gen), packages/core/src/createNavigatorFactory.tsx (:75-76,110-111), packages/core/src/StaticNavigation.tsx (:700), packages/native/src/useLinking.tsx (:526-534), packages/native/src/server/index.tsx (ServerContainer), packages/core/CHANGELOG.md (alpha.5/12/13/18/19/25 entries).

Consumers checked, no nav forks found: ~/soot/package.json, ~/takeout/package.json (no patches/ dir).

@natew

natew commented Aug 4, 2026

Copy link
Copy Markdown
Author

@satya164

Thanks for the detailed response and for dealing with some slop analysis. You are right here:

  • createComponentForStaticNavigation - yes, noise.
  • safe-area-context - also right, we swapped just for behavior and bundle size reasons, no ask here.
  • Strict Mode - no bug in React Navigation. The guard protects code we added in our useLinking fork (an initial history.replace plus masked-URL restoration) from double-mount.

On the rest:

Deterministic keys

I think there's a real mechanism here worth looking at: The keys aren't rendered as text, but useDescriptors passes route.key as the JSX key on each screen's element. So when server and client generate different keys, React treats them as different elements during hydration and remounts, discarding the server-rendered tree. That's the actual failure mode we hit, full loss of hydration rather than a cosmetic mismatch.

On carrying keys through initialState: that works if you serialize the server's navigation state into the document and hydrate from it. We deliberately don't. Both server and client derive state independently from the URL via getStateFromPath, so there's no payload to carry keys in, and two independent nanoid() runs diverge by construction. Serializing is a legitimate alternative design, we just preferred keeping the document lean and having one way to derive state (the URL). Given that, some way to get stable keys is what we need, though it doesn't have to be deterministic globally.

The collision concern is fair. Even something like an injectable key generator on getStateFromPath / the container would let us scope determinism to the SSR case without changing default behavior for anyone else.

SSR fast path

Some context here: this came out of benchmarking One against TanStack Start (saved to this repo: https://github.com/onejs/bench, based on Platformatic's SSR benchmark methodology). When I profiled our SSR renders, the container setup was a significant share of per-request time, but granted these measure essentially very small pages. It's a microbenchmark.

That said the difference took us from something like 80% as fast to like 110% if I recall, it was pretty huge. In fact I really wouldn't have reached for it if it didn't get us so far, but it is also a microbench so honestly I'm not stuck on this one at all.

On the API part, what we need isn't the internal contexts just the behavior: a static/inert render mode, ideally on ServerContainer, that renders the tree once for a given state without setting up subscriptions, listeners, or mutable state. If that existed we'd delete both the fast-path fork and the patch that exposes NavigationBuilderContext / NavigationStateContext / EnsureSingleNavigator.

Happy to write up what the minimal surface would look like if that's useful.

Masking / hash

Will write up the URL masking use case properly (it's not quite the v8 two-screens-one-URL feature, it's showing a different URL than the route being rendered, e.g. modals over a feed), and an RFC for hash navigation sounds like the right venue. Will follow up on both.

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