Scope router store per ExpoRoot to enable concurrent multi-instance rendering (e.g. multi-user testing) #45897
HerrNiklasRaab
started this conversation in
Proposals and Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
Summary
renderRouter(andExpoRootmore broadly) cannot be used to render two concurrent app instances in a single Jest test, because the global router state is held in a module-level closure and last-mount-wins. I'd like to propose scoping the store perExpoRootso multiple instances can coexist on the same JS heap.Where the singleton lives
On
main, inpackages/expo-router/src/global-state:store.ts(L32) definesstoreRef = { current: {} as StoreRef }as a module-level closure.store.ts(L51+) exportsstore, whose every getter readsstoreRef.current(state,navigationRef,routeNode,linking,redirects,routeInfo, …).useStore.ts(L99) overwritesstoreRef.current = { navigationRef, routeNode, config, rootComponent, linking, redirects, state, context }on every mount.ExpoRoot→ContextNavigatorcallsuseStore(...)and wires the inner<NavigationContainer ref={store.navigationRef}>. TheStoreContextprovider passes the module-levelstoreas the context value — so even though consumers go through React context, the value behind it is global.The imperative API (
router.push/replace/back, etc.) reaches throughstoreand therefore also targetsstoreRef.current— i.e. the most recently mountedExpoRoot.Concrete failure mode
Calling
renderRouter('app', ...)twice in one test:storeRef.current.navigationRef= containerA's ref.storeRef.current.navigationRef= containerB's ref (containerA's ref is now unreachable fromstore).router.replace('/x')triggered from app A's_layouteffect (e.g. post-auth redirect) dispatches against containerB.onStateChangefrom either container writes to the samestoreRef.current.stateand notifies the sharedrouteInfoSubscribersSet — both rendered trees re-render against the latest path, even if the action originated in only one of them.Use case
We have a real-backend integration test for a 1v1 matching flow. We want to drive two users' onboarding + invitation creation flows through real UI inside one Jest test and assert each side sees the post-match state. Per-app dependencies (auth client, InstantDB client, store) are already clean to inject via React context — the router is the only piece that forces sequential rendering.
Approaches I've ruled out
jest.isolateModulesaround the tworenderRouterrequires: also isolates React / MobX / app code, which corrupts cross-instance shared state.expo-routeris a single module instance across both.Proposal
Move
storeRef(andsplashScreenAnimationFrame/hasAttemptedToHideSplash) off the module into the React tree:useStorereturns a freshRouterStoreinstance perExpoRoot(the hook already builds the right shape — it just needs to stop writing to a module-level slot).StoreContextcarries the instance (already exists; just needs the value to be the per-tree store rather than the shared one).storeobject becomes a thin proxy that reads from a context-set "active store" — falling back to the most-recently-mounted one for the imperativerouter.x()calls that have no React context (preserves today's behaviour for everyone outside React, e.g. deep link handlers and tests that only mount one root).useRouteInfoand the imperative API used inside React reach for the context-scoped store, not the global.Question for maintainers
Is there appetite for this direction? Happy to put up a PR if so. The trickiest design call is the imperative-API fallback for non-React callers — I'd lean on "last-mounted wins, with a warning in dev when there's more than one mounted root," but want a steer before I write it.
Environment:
expo-router@55.0.14,expo@55.0.23.Beta Was this translation helpful? Give feedback.
All reactions