diff --git a/docs/pages/versions/unversioned/sdk/notifications.mdx b/docs/pages/versions/unversioned/sdk/notifications.mdx index 1ea576e19fe0b1..f2cd68ed01ff41 100644 --- a/docs/pages/versions/unversioned/sdk/notifications.mdx +++ b/docs/pages/versions/unversioned/sdk/notifications.mdx @@ -364,6 +364,12 @@ To configure `expo-notifications`, use the built-in [config plugin](/config-plug description: 'Local path to an image to use as the icon for push notifications. 96x96 all-white png with transparency.', }, + { + name: 'largeIcon', + platform: 'android', + description: + 'Local path to an image to use as the large icon for notifications. The image is resized to 64x64 dp and shown next to the notification text. A notification that carries its own image uses that image instead.', + }, { name: 'color', default: '#ffffff', @@ -401,6 +407,7 @@ Here is an example of using the config plugin in the app config file: "expo-notifications", { "icon": "./local/assets/notification_icon.png", + "largeIcon": "./local/assets/notification_large_icon.png", "color": "#ffffff", "defaultChannel": "default", "sounds": [ diff --git a/packages/expo-file-system/CHANGELOG.md b/packages/expo-file-system/CHANGELOG.md index 4e72af0f16fa68..8196fbb6930bd8 100644 --- a/packages/expo-file-system/CHANGELOG.md +++ b/packages/expo-file-system/CHANGELOG.md @@ -14,6 +14,7 @@ ### 🐛 Bug fixes +- Fix `File.readableStream()` returning zeroed bytes and writing past the requested region when a BYOB read targets a view that starts at a non-zero offset. ([#49234](https://github.com/expo/expo/pull/49234) by [@dennytosp](https://github.com/dennytosp)) - [Android][iOS] Fix `File.size` returning `null` for a missing or unreadable file. ([#49086](https://github.com/expo/expo/pull/49086)) by [@ACHP](https://github.com/ACHP)) - [iOS] Fix wrong permissions for text() and bytes(). ([#42422](https://github.com/expo/expo/pull/42422)) by [@simoneldevig](https://github.com/simoneldevig)) - Fixed `copyAsync` on iOS copying the unedited original when a `ph://` asset has edits applied in Photos. ([#48248](https://github.com/expo/expo/pull/48248) by [@CoffeeFlux](https://github.com/CoffeeFlux)) diff --git a/packages/expo-file-system/src/internal/__tests__/streams-test.ts b/packages/expo-file-system/src/internal/__tests__/streams-test.ts new file mode 100644 index 00000000000000..f730d729217564 --- /dev/null +++ b/packages/expo-file-system/src/internal/__tests__/streams-test.ts @@ -0,0 +1,106 @@ +import type { FileHandle } from '../../File.types'; +import { FileSystemReadableStreamSource } from '../streams'; + +const CONTENTS = new Uint8Array(64).map((_, index) => index + 1); + +/** A handle over an in-memory buffer, reading sequentially like a real file handle. */ +function createHandle(contents: Uint8Array = CONTENTS) { + let position = 0; + const requestedLengths: number[] = []; + const handle = { + readBytes: async (length: number) => { + requestedLengths.push(length); + const slice = contents.subarray(position, position + length); + position += slice.length; + return new Uint8Array(slice); + }, + close: () => {}, + }; + return { handle: handle as unknown as FileHandle, requestedLengths }; +} + +/** + * A `ReadableByteStreamController` stand-in. `byobRequest.view` covers the region of the + * caller's buffer the stream still has to fill, which is what the spec hands to `pull`. + * jsdom has no `ReadableStream`, so the source is driven directly. + */ +function createController(view: ArrayBufferView) { + const responded: number[] = []; + const controller = { + byobRequest: { view, respond: (bytesWritten: number) => responded.push(bytesWritten) }, + close: () => {}, + enqueue: () => {}, + }; + return { controller: controller as unknown as ReadableByteStreamController, responded }; +} + +describe(FileSystemReadableStreamSource, () => { + it('fills a BYOB view that starts at a non-zero offset in its buffer', async () => { + const { handle } = createHandle(); + const view = new Uint8Array(new ArrayBuffer(32), 8, 16); + const { controller, responded } = createController(view); + + await new FileSystemReadableStreamSource(handle).pull(controller); + + expect(responded).toEqual([16]); + expect(Array.from(view)).toEqual(Array.from(CONTENTS.subarray(0, 16))); + }); + + it('requests as many bytes as the BYOB view can hold', async () => { + const { handle, requestedLengths } = createHandle(); + const view = new Uint8Array(new ArrayBuffer(32), 8, 16); + const { controller } = createController(view); + + await new FileSystemReadableStreamSource(handle).pull(controller); + + expect(requestedLengths).toEqual([16]); + }); + + it('does not write outside the BYOB view', async () => { + const { handle } = createHandle(); + const buffer = new ArrayBuffer(32); + const view = new Uint8Array(buffer, 8, 16); + const { controller } = createController(view); + + await new FileSystemReadableStreamSource(handle).pull(controller); + + const whole = new Uint8Array(buffer); + expect(Array.from(whole.subarray(0, 8))).toEqual(new Array(8).fill(0)); + expect(Array.from(whole.subarray(24))).toEqual(new Array(8).fill(0)); + }); + + it('fills a BYOB view that starts at offset zero', async () => { + const { handle } = createHandle(); + const view = new Uint8Array(new ArrayBuffer(16)); + const { controller, responded } = createController(view); + + await new FileSystemReadableStreamSource(handle).pull(controller); + + expect(responded).toEqual([16]); + expect(Array.from(view)).toEqual(Array.from(CONTENTS.subarray(0, 16))); + }); + + it('fills a BYOB view that is not a Uint8Array', async () => { + const { handle } = createHandle(); + const buffer = new ArrayBuffer(32); + const view = new Uint16Array(buffer, 8, 8); + const { controller, responded } = createController(view); + + await new FileSystemReadableStreamSource(handle).pull(controller); + + expect(responded).toEqual([16]); + expect(Array.from(new Uint8Array(buffer, 8, 16))).toEqual(Array.from(CONTENTS.subarray(0, 16))); + }); + + it('closes the stream when the handle is exhausted', async () => { + const { handle } = createHandle(new Uint8Array(0)); + const view = new Uint8Array(new ArrayBuffer(32), 8, 16); + const { controller, responded } = createController(view); + const close = jest.spyOn(controller, 'close'); + + await new FileSystemReadableStreamSource(handle).pull(controller); + + expect(close).toHaveBeenCalled(); + expect(responded).toEqual([0]); + }); +}); diff --git a/packages/expo-file-system/src/internal/streams.ts b/packages/expo-file-system/src/internal/streams.ts index 132c676de3e244..52393840c224e2 100644 --- a/packages/expo-file-system/src/internal/streams.ts +++ b/packages/expo-file-system/src/internal/streams.ts @@ -26,20 +26,15 @@ export class FileSystemReadableStreamSource implements UnderlyingByteSource { } // TODO: Optimize by adding a native method that can write into a TypedArray at a given offset. - const bytes = await this.handle.readBytes(theView.byteLength - theView.byteOffset); + // `byteLength` is already the size of the region to fill, so `byteOffset` must not be + // subtracted from it, and `set` takes an offset relative to the view it is called on. + const bytes = await this.handle.readBytes(theView.byteLength); if (bytes.length === 0) { controller.close(); controller.byobRequest.respond(0); return; } - if (theView instanceof Uint8Array) { - theView.set(bytes, theView.byteOffset); - } else { - const array = new Uint8Array(theView.buffer); - for (let i = 0; i < bytes.length; i++) { - array[i + (theView.byteOffset ?? 0)] = bytes[i]!; - } - } + new Uint8Array(theView.buffer, theView.byteOffset, theView.byteLength).set(bytes); controller.byobRequest.respond(bytes.length); } } diff --git a/packages/expo-notifications/CHANGELOG.md b/packages/expo-notifications/CHANGELOG.md index c54d13eab2cc80..44b144e3685dfe 100644 --- a/packages/expo-notifications/CHANGELOG.md +++ b/packages/expo-notifications/CHANGELOG.md @@ -10,6 +10,7 @@ - [ios] Forward notification center calls to a `UNUserNotificationCenterDelegate` that another library set, so that both libraries keep working. ([#48313](https://github.com/expo/expo/pull/48313) by [@vonovak](https://github.com/vonovak)) - [ios] Add support for grouping notifications via `threadIdentifier`. ([#49429](https://github.com/expo/expo/pull/49429) by [@vonovak](https://github.com/vonovak)) +- [Android] Add a `largeIcon` config plugin property that sets the notification large icon. ([#49481](https://github.com/expo/expo/pull/49481) by [@expo-bot](https://github.com/expo-bot)) ### 🐛 Bug fixes diff --git a/packages/expo-notifications/plugin/src/__tests__/withNotificationsAndroid-test.ts b/packages/expo-notifications/plugin/src/__tests__/withNotificationsAndroid-test.ts index d8201dfcb7ca63..1574d0889d3a08 100644 --- a/packages/expo-notifications/plugin/src/__tests__/withNotificationsAndroid-test.ts +++ b/packages/expo-notifications/plugin/src/__tests__/withNotificationsAndroid-test.ts @@ -1,7 +1,11 @@ import { fs, vol } from 'memfs'; import * as path from 'path'; -import { setNotificationIconAsync, setNotificationSounds } from '../withNotificationsAndroid'; +import { + setNotificationIconAsync, + setNotificationLargeIconAsync, + setNotificationSounds, +} from '../withNotificationsAndroid'; export function getDirFromFS(fsJSON: Record, rootDir: string) { return Object.entries(fsJSON) @@ -40,6 +44,17 @@ const LIST_OF_GENERATED_NOTIFICATION_FILES = [ 'android/app/src/main/res/raw/notification_sound.wav', ]; +const LIST_OF_GENERATED_LARGE_ICON_FILES = [ + 'android/app/src/main/res/drawable-mdpi/notification_large_icon.png', + 'android/app/src/main/res/drawable-hdpi/notification_large_icon.png', + 'android/app/src/main/res/drawable-xhdpi/notification_large_icon.png', + 'android/app/src/main/res/drawable-xxhdpi/notification_large_icon.png', + 'android/app/src/main/res/drawable-xxxhdpi/notification_large_icon.png', + 'android/app/src/main/res/values/colors.xml', + 'assets/notificationIcon.png', + 'assets/notification_sound.wav', +]; + const iconPath = path.resolve(__dirname, './fixtures/icon.png'); const soundPath = path.resolve(__dirname, './fixtures/cat.wav'); @@ -76,6 +91,25 @@ describe('Android notifications configuration', () => { expect(Object.keys(after).sort()).toEqual(LIST_OF_GENERATED_NOTIFICATION_FILES.sort()); }); + it('writes the large icon files as expected', async () => { + await setNotificationLargeIconAsync(projectRoot, '/app/assets/notificationIcon.png'); + + const after = getDirFromFS(vol.toJSON(), projectRoot); + expect(Object.keys(after).sort()).toEqual(LIST_OF_GENERATED_LARGE_ICON_FILES.sort()); + }); + + it('Safely remove the large icon if it exists, and ignore if it doesnt', async () => { + const before = getDirFromFS(vol.toJSON(), projectRoot); + await setNotificationLargeIconAsync(projectRoot, '/app/assets/notificationIcon.png'); + + await setNotificationLargeIconAsync(projectRoot, null); + expect(getDirFromFS(vol.toJSON(), projectRoot)).toMatchObject(before); + + // now remove again to make sure we don't throw in that case + await setNotificationLargeIconAsync(projectRoot, null); + expect(getDirFromFS(vol.toJSON(), projectRoot)).toMatchObject(before); + }); + it('Safely remove icon if it exists, and ignore if it doesnt', async () => { const before = getDirFromFS(vol.toJSON(), projectRoot); // first set the icon diff --git a/packages/expo-notifications/plugin/src/withNotifications.ts b/packages/expo-notifications/plugin/src/withNotifications.ts index c4cd1cb4af9c91..19afac29f9a9cc 100644 --- a/packages/expo-notifications/plugin/src/withNotifications.ts +++ b/packages/expo-notifications/plugin/src/withNotifications.ts @@ -13,6 +13,13 @@ export type NotificationsPluginProps = { * @platform android */ icon?: string; + /** + * Local path to an image to use as the large icon for notifications. The image is resized to + * 64x64 dp and shown next to the notification text. A notification that carries its own image + * uses that image instead. + * @platform android + */ + largeIcon?: string; /** * Tint color for the push notification image when it appears in the notification tray. * @default '#ffffff' diff --git a/packages/expo-notifications/plugin/src/withNotificationsAndroid.ts b/packages/expo-notifications/plugin/src/withNotificationsAndroid.ts index f01d72b4c6a895..d4da308d6c9257 100644 --- a/packages/expo-notifications/plugin/src/withNotificationsAndroid.ts +++ b/packages/expo-notifications/plugin/src/withNotificationsAndroid.ts @@ -31,6 +31,8 @@ const { removeMetaDataItemFromMainApplication, } = AndroidConfig.Manifest; const BASELINE_PIXEL_SIZE = 24; +// Matches Android's own `notification_large_icon_width`/`notification_large_icon_height` of 64dp. +const BASELINE_LARGE_ICON_PIXEL_SIZE = 64; const ERROR_MSG_PREFIX = 'An error occurred while configuring Android notifications. '; export const META_DATA_FCM_NOTIFICATION_ICON = @@ -44,20 +46,25 @@ export const META_DATA_LOCAL_NOTIFICATION_ICON = 'expo.modules.notifications.default_notification_icon'; export const META_DATA_LOCAL_NOTIFICATION_ICON_COLOR = 'expo.modules.notifications.default_notification_color'; - -// TODO @vonovak add config for local notification large icon -// expo.modules.notifications.large_notification_icon +export const META_DATA_LOCAL_NOTIFICATION_LARGE_ICON = + 'expo.modules.notifications.large_notification_icon'; export const NOTIFICATION_ICON = 'notification_icon'; export const NOTIFICATION_ICON_RESOURCE = `@drawable/${NOTIFICATION_ICON}`; +export const NOTIFICATION_LARGE_ICON = 'notification_large_icon'; +export const NOTIFICATION_LARGE_ICON_RESOURCE = `@drawable/${NOTIFICATION_LARGE_ICON}`; export const NOTIFICATION_ICON_COLOR = 'notification_icon_color'; export const NOTIFICATION_ICON_COLOR_RESOURCE = `@color/${NOTIFICATION_ICON_COLOR}`; -export const withNotificationIcons: ConfigPlugin<{ icon: string | null }> = (config, { icon }) => { +export const withNotificationIcons: ConfigPlugin<{ + icon: string | null; + largeIcon: string | null; +}> = (config, { icon, largeIcon }) => { return withDangerousMod(config, [ 'android', async (config) => { await setNotificationIconAsync(config.modRequest.projectRoot, icon); + await setNotificationLargeIconAsync(config.modRequest.projectRoot, largeIcon); return config; }, ]); @@ -76,11 +83,15 @@ export const withNotificationIconColor: ConfigPlugin<{ color: string | null }> = export const withNotificationManifest: ConfigPlugin<{ icon: string | null; + largeIcon: string | null; color: string | null; defaultChannel: string | null; -}> = (config, { icon, color, defaultChannel }) => { +}> = (config, { icon, largeIcon, color, defaultChannel }) => { return withAndroidManifest(config, (config) => { - config.modResults = setNotificationConfig({ icon, color, defaultChannel }, config.modResults); + config.modResults = setNotificationConfig( + { icon, largeIcon, color, defaultChannel }, + config.modResults + ); return config; }); }; @@ -109,15 +120,41 @@ export function setNotificationIconColor( * Applies notification icon configuration for expo-notifications */ export async function setNotificationIconAsync(projectRoot: string, icon: string | null) { + await setDrawableIconAsync(projectRoot, icon, NOTIFICATION_ICON, BASELINE_PIXEL_SIZE); +} + +/** + * Applies notification large icon configuration for expo-notifications + */ +export async function setNotificationLargeIconAsync(projectRoot: string, largeIcon: string | null) { + await setDrawableIconAsync( + projectRoot, + largeIcon, + NOTIFICATION_LARGE_ICON, + BASELINE_LARGE_ICON_PIXEL_SIZE + ); +} + +async function setDrawableIconAsync( + projectRoot: string, + icon: string | null, + resourceName: string, + baselinePixelSize: number +) { if (icon) { - await writeNotificationIconImageFilesAsync(icon, projectRoot); + await writeNotificationIconImageFilesAsync(icon, projectRoot, resourceName, baselinePixelSize); } else { - removeNotificationIconImageFiles(projectRoot); + removeNotificationIconImageFiles(projectRoot, resourceName); } } function setNotificationConfig( - props: { icon: string | null; color: string | null; defaultChannel?: string | null }, + props: { + icon: string | null; + largeIcon?: string | null; + color: string | null; + defaultChannel?: string | null; + }, manifest: AndroidConfig.Manifest.AndroidManifest ) { const mainApplication = getMainApplicationOrThrow(manifest); @@ -138,6 +175,16 @@ function setNotificationConfig( removeMetaDataItemFromMainApplication(mainApplication, META_DATA_FCM_NOTIFICATION_ICON); removeMetaDataItemFromMainApplication(mainApplication, META_DATA_LOCAL_NOTIFICATION_ICON); } + if (props.largeIcon) { + addMetaDataItemToMainApplication( + mainApplication, + META_DATA_LOCAL_NOTIFICATION_LARGE_ICON, + NOTIFICATION_LARGE_ICON_RESOURCE, + 'resource' + ); + } else { + removeMetaDataItemFromMainApplication(mainApplication, META_DATA_LOCAL_NOTIFICATION_LARGE_ICON); + } if (props.color) { addMetaDataItemToMainApplication( mainApplication, @@ -172,7 +219,12 @@ function setNotificationConfig( return manifest; } -async function writeNotificationIconImageFilesAsync(icon: string, projectRoot: string) { +async function writeNotificationIconImageFilesAsync( + icon: string, + projectRoot: string, + resourceName: string, + baselinePixelSize: number +) { await Promise.all( Object.values(dpiValues).map(async ({ folderName, scale }) => { const drawableFolderName = folderName.replace('mipmap', 'drawable'); @@ -180,7 +232,7 @@ async function writeNotificationIconImageFilesAsync(icon: string, projectRoot: s if (!existsSync(dpiFolderPath)) { mkdirSync(dpiFolderPath, { recursive: true }); } - const iconSizePx = BASELINE_PIXEL_SIZE * scale; + const iconSizePx = baselinePixelSize * scale; try { const resizedIcon = ( @@ -195,7 +247,7 @@ async function writeNotificationIconImageFilesAsync(icon: string, projectRoot: s } ) ).source; - writeFileSync(resolve(dpiFolderPath, NOTIFICATION_ICON + '.png'), resizedIcon); + writeFileSync(resolve(dpiFolderPath, resourceName + '.png'), resizedIcon); } catch (e) { throw new Error( ERROR_MSG_PREFIX + 'Encountered an issue resizing Android notification icon: ' + e @@ -205,11 +257,11 @@ async function writeNotificationIconImageFilesAsync(icon: string, projectRoot: s ); } -function removeNotificationIconImageFiles(projectRoot: string) { +function removeNotificationIconImageFiles(projectRoot: string, resourceName: string) { Object.values(dpiValues).forEach(async ({ folderName }) => { const drawableFolderName = folderName.replace('mipmap', 'drawable'); const dpiFolderPath = resolve(projectRoot, ANDROID_RES_PATH, drawableFolderName); - const iconFile = resolve(dpiFolderPath, NOTIFICATION_ICON + '.png'); + const iconFile = resolve(dpiFolderPath, resourceName + '.png'); if (existsSync(iconFile)) { unlinkSync(iconFile); } @@ -260,11 +312,11 @@ function writeNotificationSoundFile(soundFileRelativePath: string, projectRoot: export const withNotificationsAndroid: ConfigPlugin = ( config, - { icon = null, color = null, sounds = [], defaultChannel = null } + { icon = null, largeIcon = null, color = null, sounds = [], defaultChannel = null } ) => { config = withNotificationIconColor(config, { color }); - config = withNotificationIcons(config, { icon }); - config = withNotificationManifest(config, { icon, color, defaultChannel }); + config = withNotificationIcons(config, { icon, largeIcon }); + config = withNotificationManifest(config, { icon, largeIcon, color, defaultChannel }); config = withNotificationSounds(config, { sounds }); return config; }; diff --git a/packages/expo-router/AGENTS.md b/packages/expo-router/AGENTS.md index a279fec0381a58..68207124d44fe8 100644 --- a/packages/expo-router/AGENTS.md +++ b/packages/expo-router/AGENTS.md @@ -21,7 +21,8 @@ File-based routing library for React Native and web applications. It provides au │ ├── matchers.tsx # Route segment pattern matching │ │ │ ├── global-state/ # State management -│ │ ├── router-store.tsx # Zustand store for router state +│ │ ├── routerConfigContext.ts # Static router configuration context +│ │ ├── navigationRef.ts # Imperative navigation ref │ │ ├── routing.ts # Navigation queue and routing functions │ │ ├── getRouteInfoFromState.ts, routeInfoCache.ts, useRouteInfo.ts # Current route information │ │ └── serverLocationContext.ts # Server-side location context @@ -259,7 +260,7 @@ const screenProps = MockedComponent.mock.calls[1][0]; ### State Management -- **RouterStore** (`global-state/router-store.tsx`): The global store managing navigation state, and making it accessible imperatively via the `store` object +- **Router state**: Use `RouterConfigContext`, `NavigationContainerRefContext`, and `RootNavigationStateContext` for in-tree reads, and `navigationRef` for the imperative `router.*` API - **Routing Queue** (`global-state/routing.ts`): Batches navigation actions and processes them sequentially ### Platform-Specific Code diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index a9423ee503395a..425a707151a338 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -4,6 +4,7 @@ ### 🛠 Breaking changes +- Remove `beforeRemove`, `__unsafe_action__`, `PreventRemoveContext`, and `usePreventRemoveContext` from `expo-router/react-navigation`. ([#49408](https://github.com/expo/expo/pull/49408) by [@Ubax](https://github.com/Ubax)) - Preserve the focused route when switching navigator types in a conditional layout. ([#49297](https://github.com/expo/expo/pull/49297) by [@Ubax](https://github.com/Ubax)) - Generate deterministic navigation states and route keys. Complete states from custom routers or persisted state must include `routeKeySeq`. ([#49297](https://github.com/expo/expo/pull/49297) by [@Ubax](https://github.com/Ubax)) - Defer `navigation.dispatch` and navigation helper actions until after commit. Use `navigation.dispatchSync` for synchronous dispatch; dispatch functions are no longer supported. ([#49297](https://github.com/expo/expo/pull/49297) by [@Ubax](https://github.com/Ubax)) @@ -80,6 +81,9 @@ ### 💡 Others +- Remove module-level mutable navigation state from Expo Router. ([#49403](https://github.com/expo/expo/pull/49403) by [@Ubax](https://github.com/Ubax)) +- Remove the dev-only `stack` field from the `__unsafe_action__` event in `expo-router/react-navigation`. ([#49431](https://github.com/expo/expo/pull/49431) by [@Ubax](https://github.com/Ubax)) +- Read navigation state from the React tree instead of imperative refs. ([#49433](https://github.com/expo/expo/pull/49433) by [@Ubax](https://github.com/Ubax)) - Scope routing queues to each router root and bind `useRouter()` to its owning container. ([#49351](https://github.com/expo/expo/pull/49351) by [@Ubax](https://github.com/Ubax)) - Derive `useIsFocused` from context. ([#49390](https://github.com/expo/expo/pull/49390) by [@Ubax](https://github.com/Ubax)) - Base `useNavigationState` on global state. ([#49381](https://github.com/expo/expo/pull/49381) by [@jakub-agent](https://github.com/jakub-agent)) diff --git a/packages/expo-router/src/ExpoRoot.tsx b/packages/expo-router/src/ExpoRoot.tsx index 2be4c4fc2086d3..ad779775592807 100644 --- a/packages/expo-router/src/ExpoRoot.tsx +++ b/packages/expo-router/src/ExpoRoot.tsx @@ -8,19 +8,20 @@ import { INTERNAL_SLOT_NAME, NOT_FOUND_ROUTE_NAME, SITEMAP_ROUTE_NAME } from './ import { useDomComponentNavigation } from './domComponents/useDomComponentNavigation'; import { NavigationContainer as UpstreamNavigationContainer } from './fork/NavigationContainer'; import type { ExpoLinkingOptions } from './getLinkingConfig'; -import { useStore } from './global-state/router-store'; +import { navigationRef } from './global-state/navigationRef'; +import { RemovalPreventionProvider } from './global-state/removalPrevention'; +import { RouterConfigContext } from './global-state/routerConfigContext'; import { RouterRegistryProvider } from './global-state/routerRegistry'; import { RoutingQueueProvider } from './global-state/routingQueueContext'; -import { maybeHideSplashScreen } from './global-state/store'; -import { StoreContext } from './global-state/storeContext'; +import { useRouterConfig } from './global-state/useStore'; import { shouldAppendNotFound, shouldAppendSitemap } from './global-state/utils'; import { LinkPreviewContextProvider } from './link/preview/LinkPreviewContext'; -import { handleNavigationOnReady } from './navigationEvents/navigation'; import { Screen } from './primitives'; import type { LinkingOptions } from './react-navigation/native'; import { StackRouter, useNavigationBuilder } from './react-navigation/native'; import { initScreensFeatureFlags } from './screensFeatureFlags'; import type { RequireContext } from './types'; +import { maybeHideSplashScreen } from './utils/splash'; import { parseUrlUsingCustomBase } from './utils/url'; import { RootUnmatched } from './views/RootUnmatched'; import { Sitemap } from './views/Sitemap'; @@ -95,7 +96,6 @@ const initialUrl = : undefined; function onNavigationReady() { - handleNavigationOnReady(); maybeHideSplashScreen(); } @@ -122,8 +122,8 @@ function ContextNavigator({ return undefined; }, []); - const storeValue = useStore(context, linking, serverUrl); - const { navigationRef, rootComponent, linking: linkingConfig, routeNode } = storeValue; + const { routerConfig, rootComponent } = useRouterConfig(context, linking, serverUrl); + const { linking: linkingConfig, routeNode } = routerConfig; useDomComponentNavigation(); @@ -144,19 +144,21 @@ function ContextNavigator({ } return ( - + - } - documentTitle={documentTitle} - onReady={onNavigationReady}> - - - - + + } + documentTitle={documentTitle} + onReady={onNavigationReady}> + + + + + - + ); } diff --git a/packages/expo-router/src/__tests__/experimental-stack.test.ios.tsx b/packages/expo-router/src/__tests__/experimental-stack.test.ios.tsx index d7cb362d7e1839..6b00401f5d6d8d 100644 --- a/packages/expo-router/src/__tests__/experimental-stack.test.ios.tsx +++ b/packages/expo-router/src/__tests__/experimental-stack.test.ios.tsx @@ -336,8 +336,7 @@ describe('ExperimentalStack — dismiss handlers', () => { } }); - // TODO(@ubax): Restore nested remove prevention after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 - it.skip('onNativeDismissPrevented dispatches a pop through nested prevention', () => { + it('onNativeDismissPrevented dispatches a pop through nested prevention', () => { const onPreventRemove = jest.fn(); const onGestureCancel = jest.fn(); const ProtectedScreen = () => { diff --git a/packages/expo-router/src/__tests__/hashs.test.ios.tsx b/packages/expo-router/src/__tests__/hashs.test.ios.tsx index 789e2075684239..18f54bdf4dd8d3 100644 --- a/packages/expo-router/src/__tests__/hashs.test.ios.tsx +++ b/packages/expo-router/src/__tests__/hashs.test.ios.tsx @@ -2,7 +2,7 @@ import { act, screen } from '@testing-library/react-native'; import { Text } from 'react-native'; import { router } from '../exports'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { renderRouter } from '../testing-library'; import { parseUrlUsingCustomBase } from '../utils/url'; import { expectCompleteStateToMatch } from './assertCompleteState'; @@ -23,7 +23,7 @@ it('can push a hash url', () => { act(() => router.push('/test#b')); act(() => router.push('/test#c')); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -208,7 +208,7 @@ it('navigating to the same route with a hash will only rerender the screen', () index: () => , }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -238,7 +238,7 @@ it('navigating to the same route with a hash will only rerender the screen', () act(() => router.navigate('/?#hash1')); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], diff --git a/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx b/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx index 08dbcb7e9aedf0..ad8a241a30c2ce 100644 --- a/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx +++ b/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx @@ -4,13 +4,13 @@ import React, { forwardRef, useEffect, useState } from 'react'; import type { ViewProps } from 'react-native'; import { View, Text, Button } from 'react-native'; -import { store } from '../global-state/router-store'; import { useLocalSearchParams } from '../hooks'; import { router } from '../imperative-api'; import { useGuardRedirect } from '../layouts/GuardContext'; import { Stack } from '../layouts/Stack'; import { Tabs as JSTabs } from '../layouts/Tabs'; import { Link, Redirect } from '../link/Link'; +import { unstable_navigationEvents } from '../navigationEvents'; import { useIsFocused } from '../react-navigation/native'; import { type RenderRouterOptions, renderRouter, waitFor } from '../testing-library'; import { TabList, TabSlot, TabTrigger, Tabs, useTabTrigger } from '../ui'; @@ -1190,6 +1190,14 @@ it('can reference a parent trigger from nested tabs', () => { expect(screen.getByTestId('current-parent')).toHaveProp('isFocused', true); expect(screen.getByTestId('goto-parent')).toHaveProp('isFocused', false); fireEvent.press(screen.getByTestId('goto-parent')); + expect(screen.getByTestId('current-parent', { includeHiddenElements: true })).toHaveProp( + 'isFocused', + false + ); + expect(screen.getByTestId('goto-parent', { includeHiddenElements: true })).toHaveProp( + 'isFocused', + true + ); expect(screen.getByTestId('index')).toBeVisible(); }); @@ -1388,10 +1396,8 @@ it('resets when focused tab is pressed again', async () => { expect(screen).toHaveSegments(['stack']); }); -// TODO(@ubax): Restore __unsafe_action__ events. https://linear.app/expo/issue/ENG-26123 -it.skip('dispatches only one action when re-tapping active tab with nested stack', async () => { - // Track all dispatched actions using a listener on the navigation container - const dispatchedActions: unknown[] = []; +it('dispatches only one action when re-tapping active tab with nested stack', async () => { + const dispatchedActions: string[] = []; renderRouter({ _layout: () => ( @@ -1431,9 +1437,9 @@ it.skip('dispatches only one action when re-tapping active tab with nested stack expect(screen.getByTestId('movies-nested-details')).toBeVisible(); // Set up listener to track dispatched actions before re-tapping - const unsubscribe = store.navigationRef.current!.addListener('__unsafe_action__', (e) => { - dispatchedActions.push(e.data.action); - }); + const unsubscribe = unstable_navigationEvents.addListener('actionDispatched', (event) => + dispatchedActions.push(event.actionType) + ); // Re-tap the movies tab await userEvent.press(screen.getByTestId('goto-movies')); @@ -1445,15 +1451,11 @@ it.skip('dispatches only one action when re-tapping active tab with nested stack expect(dispatchedActions).toHaveLength(1); - expect(dispatchedActions[0]).toMatchObject({ - type: 'POP_TO_TOP', - }); + expect(dispatchedActions[0]).toBe('POP_TO_TOP'); }); -// TODO(@ubax): Restore __unsafe_action__ events. https://linear.app/expo/issue/ENG-26123 -it.skip('JSTabs dispatches only one action when re-tapping active tab with nested stack', async () => { - // Track all dispatched actions using a listener on the navigation container - const dispatchedActions: unknown[] = []; +it('JSTabs dispatches only one action when re-tapping active tab with nested stack', async () => { + const dispatchedActions: string[] = []; renderRouter({ _layout: () => ( @@ -1486,9 +1488,9 @@ it.skip('JSTabs dispatches only one action when re-tapping active tab with neste expect(screen.getByTestId('movies-nested-details')).toBeVisible(); // Set up listener to track dispatched actions before re-tapping - const unsubscribe = store.navigationRef.current!.addListener('__unsafe_action__', (e) => { - dispatchedActions.push(e.data.action); - }); + const unsubscribe = unstable_navigationEvents.addListener('actionDispatched', (event) => + dispatchedActions.push(event.actionType) + ); // Re-tap the movies tab await userEvent.press(screen.getByLabelText('movies, tab, 2 of 2')); @@ -1500,9 +1502,7 @@ it.skip('JSTabs dispatches only one action when re-tapping active tab with neste expect(dispatchedActions).toHaveLength(1); - expect(dispatchedActions[0]).toMatchObject({ - type: 'POP_TO_TOP', - }); + expect(dispatchedActions[0]).toBe('POP_TO_TOP'); }); it('does not reset when focused tab is pressed again, but the press is prevented', async () => { diff --git a/packages/expo-router/src/__tests__/hmr-route-changes.test.ios.tsx b/packages/expo-router/src/__tests__/hmr-route-changes.test.ios.tsx index 15b9b99700f0cf..c20d9999486387 100644 --- a/packages/expo-router/src/__tests__/hmr-route-changes.test.ios.tsx +++ b/packages/expo-router/src/__tests__/hmr-route-changes.test.ios.tsx @@ -4,7 +4,7 @@ import { useCallback, type ReactElement } from 'react'; import { Text } from 'react-native'; import { ExpoRoot } from '../ExpoRoot'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { router } from '../imperative-api'; import Stack from '../layouts/Stack'; import Tabs from '../layouts/Tabs'; @@ -28,11 +28,11 @@ it('preserves live navigation state when the initial location changes on re-rend }; const result = renderRouter(routes, { initialUrl: '/' }); act(() => router.push('/second')); - const navigationState = store.state; + const navigationState = navigationRef.getRootState(); result.rerender(); - expect(store.state).toStrictEqual(navigationState); + expect(navigationRef.getRootState()).toStrictEqual(navigationState); expect(screen.getByTestId('second')).toBeVisible(); }); @@ -161,7 +161,7 @@ it('does not crash when a route file is renamed after navigation', () => { ).not.toThrow(); expect(screen.getByTestId('index')).toBeVisible(); - expect(store.navigationRef.current?.getRootState().routes[0]!.state!.routeNames).toStrictEqual([ + expect(navigationRef.current?.getRootState().routes[0]!.state!.routeNames).toStrictEqual([ 'index', 'third', ]); @@ -274,7 +274,7 @@ it('preserves surviving stack history when a route file is renamed', () => { expect(screen.getByTestId('details')).toBeVisible(); expect( - store.navigationRef.current?.getRootState().routes[0]!.state!.routes.map((route) => route.name) + navigationRef.current?.getRootState().routes[0]!.state!.routes.map((route) => route.name) ).toStrictEqual(['index', 'details']); act(() => router.back()); @@ -306,7 +306,7 @@ it('does not crash when a tab route file is renamed after navigation', () => { ).not.toThrow(); expect(screen.getByTestId('index')).toBeVisible(); - expect(store.navigationRef.current?.getRootState().routes[0]!.state!.routeNames).toStrictEqual([ + expect(navigationRef.current?.getRootState().routes[0]!.state!.routeNames).toStrictEqual([ 'index', 'third', ]); diff --git a/packages/expo-router/src/__tests__/initialRouteName.test.ios.tsx b/packages/expo-router/src/__tests__/initialRouteName.test.ios.tsx index 7c5689832b7946..2e760fa884fae2 100644 --- a/packages/expo-router/src/__tests__/initialRouteName.test.ios.tsx +++ b/packages/expo-router/src/__tests__/initialRouteName.test.ios.tsx @@ -1,7 +1,7 @@ import { screen, act } from '@testing-library/react-native'; import { Text } from 'react-native'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { useLocalSearchParams } from '../hooks'; import { router } from '../imperative-api'; import Stack from '../layouts/Stack'; @@ -101,7 +101,7 @@ it('push should include (group)/index as an anchor route when using withAnchor', }); // Initial complete state - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -131,7 +131,7 @@ it('push should include (group)/index as an anchor route when using withAnchor', act(() => router.push('/orange', { withAnchor: true })); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -198,7 +198,7 @@ it('push should ignore (group)/index as an initial route if no anchor is specifi }); // Initial complete state - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -228,7 +228,7 @@ it('push should ignore (group)/index as an initial route if no anchor is specifi act(() => router.push('/orange')); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], diff --git a/packages/expo-router/src/__tests__/navigation.test.ios.tsx b/packages/expo-router/src/__tests__/navigation.test.ios.tsx index bfc0e290eeeffa..55d218e2653751 100644 --- a/packages/expo-router/src/__tests__/navigation.test.ios.tsx +++ b/packages/expo-router/src/__tests__/navigation.test.ios.tsx @@ -11,7 +11,7 @@ import { Slot, usePathname, } from '../exports'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { Stack } from '../layouts/Stack'; import { Tabs } from '../layouts/Tabs'; import { Link, Redirect } from '../link'; @@ -1842,20 +1842,21 @@ it('multiple pushes to different stack are executed in order and added separatel expect(screen.queryByTestId('d')).toBeNull(); expect(screen).toHavePathname('/b/e'); - expect(store.state!.index).toBe(0); - expect(store.state!.routes).toHaveLength(1); - expect(store.state!.routes[0]!.name).toBe('__root'); + const rootState = navigationRef.getRootState(); + expect(rootState.index).toBe(0); + expect(rootState.routes).toHaveLength(1); + expect(rootState.routes[0]!.name).toBe('__root'); // Both pushes from 'c' will create new routes in root layout. This is because both pushes are happening on the same state, where there is no 'b' stack yet. - expect(store.state!.routes[0]!.state!.routes).toHaveLength(3); - expect(store.state!.routes[0]!.state!.routes[0]!.name).toBe('a'); - expect(store.state!.routes[0]!.state!.routes[0]!.state!.routes).toHaveLength(1); - expect(store.state!.routes[0]!.state!.routes[0]!.state!.routes[0]!.name).toBe('c'); - expect(store.state!.routes[0]!.state!.routes[1]!.name).toBe('b'); - expect(store.state!.routes[0]!.state!.routes[1]!.state!.routes).toHaveLength(1); - expect(store.state!.routes[0]!.state!.routes[1]!.state!.routes[0]!.name).toBe('d'); - expect(store.state!.routes[0]!.state!.routes[2]!.name).toBe('b'); - expect(store.state!.routes[0]!.state!.routes[2]!.state!.routes).toHaveLength(1); - expect(store.state!.routes[0]!.state!.routes[2]!.state!.routes[0]!.name).toBe('e'); + expect(rootState.routes[0]!.state!.routes).toHaveLength(3); + expect(rootState.routes[0]!.state!.routes[0]!.name).toBe('a'); + expect(rootState.routes[0]!.state!.routes[0]!.state!.routes).toHaveLength(1); + expect(rootState.routes[0]!.state!.routes[0]!.state!.routes[0]!.name).toBe('c'); + expect(rootState.routes[0]!.state!.routes[1]!.name).toBe('b'); + expect(rootState.routes[0]!.state!.routes[1]!.state!.routes).toHaveLength(1); + expect(rootState.routes[0]!.state!.routes[1]!.state!.routes[0]!.name).toBe('d'); + expect(rootState.routes[0]!.state!.routes[2]!.name).toBe('b'); + expect(rootState.routes[0]!.state!.routes[2]!.state!.routes).toHaveLength(1); + expect(rootState.routes[0]!.state!.routes[2]!.state!.routes[0]!.name).toBe('e'); act(() => router.back()); expect(screen.getByTestId('d')).toBeVisible(); diff --git a/packages/expo-router/src/__tests__/prefetch.test.ios.tsx b/packages/expo-router/src/__tests__/prefetch.test.ios.tsx index c70a4f0eaefaad..0f24e5f60efe8b 100644 --- a/packages/expo-router/src/__tests__/prefetch.test.ios.tsx +++ b/packages/expo-router/src/__tests__/prefetch.test.ios.tsx @@ -2,7 +2,7 @@ import { screen, act } from '@testing-library/react-native'; import { useEffect } from 'react'; import { Text } from 'react-native'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { router } from '../imperative-api'; import { Stack } from '../layouts/Stack'; import Tabs from '../layouts/Tabs'; @@ -36,7 +36,7 @@ it('prefetch a sibling route', () => { }, }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -68,7 +68,7 @@ it('prefetch a sibling route', () => { router.prefetch('/test'); }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -137,7 +137,7 @@ it('will prefetch the correct route within a group', () => { '(b)/test': () => null, }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -169,7 +169,7 @@ it('will prefetch the correct route within a group', () => { router.prefetch('/test'); }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -212,7 +212,7 @@ it('will prefetch the correct route within nested groups', () => { '(b)/test': () => null, }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -244,7 +244,7 @@ it('will prefetch the correct route within nested groups', () => { router.prefetch('/test'); }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -285,7 +285,7 @@ it('works with relative Href', () => { test: () => null, }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -317,7 +317,7 @@ it('works with relative Href', () => { router.prefetch('./test'); }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -358,7 +358,7 @@ it('works with params', () => { test: () => null, }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -440,7 +440,7 @@ it('ignores the current route', () => { } ); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -560,7 +560,7 @@ it('can prefetch a deeply nested route', () => { } ); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -707,7 +707,7 @@ it('can prefetch a parent route', () => { } ); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -961,7 +961,6 @@ it('can still use while prefetching in tabs', () => { 'Should only change after focus', 'index', 'Should only change after focus', - 'index', ]); }); diff --git a/packages/expo-router/src/__tests__/protected.test.ios.tsx b/packages/expo-router/src/__tests__/protected.test.ios.tsx index 4114b6f661fefe..f96642a361c423 100644 --- a/packages/expo-router/src/__tests__/protected.test.ios.tsx +++ b/packages/expo-router/src/__tests__/protected.test.ios.tsx @@ -3,7 +3,7 @@ import type { Dispatch, SetStateAction } from 'react'; import { createContext, use, useState } from 'react'; import { Text } from 'react-native'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { router } from '../imperative-api'; import Stack from '../layouts/Stack'; import Tabs from '../layouts/Tabs'; @@ -49,7 +49,12 @@ it('redirects a guarded route to the anchor default during the initial load', () expect(screen.getByTestId('a')).toBeVisible(); expect(screen).toHavePathname('/a'); - expect(store.state!.routes[0]!.state!.routeNames).toStrictEqual(['a', 'index', 'b', 'c']); + expect(navigationRef.getRootState().routes[0]!.state!.routeNames).toStrictEqual([ + 'a', + 'index', + 'b', + 'c', + ]); }); it('redirects nested guarded routes to the anchor and unlocks them as guards flip', () => { @@ -138,7 +143,12 @@ it('redirects nested guarded routes to the anchor and unlocks them as guards fli expect(screen.getByTestId('c')).toBeVisible(); expect(screen).toHavePathname('/c'); - expect(store.state!.routes[0]!.state!.routeNames).toStrictEqual(['a', 'b', 'c', 'index']); + expect(navigationRef.getRootState().routes[0]!.state!.routeNames).toStrictEqual([ + 'a', + 'b', + 'c', + 'index', + ]); }); it('defaults a guarded route to the navigator anchor', () => { @@ -187,7 +197,11 @@ it('defaults a guarded route to the navigator anchor', () => { expect(screen.getByTestId('a')).toBeVisible(); expect(screen).toHavePathname('/a'); - expect(store.state!.routes[0]!.state!.routeNames).toStrictEqual(['a', 'b', 'index']); + expect(navigationRef.getRootState().routes[0]!.state!.routeNames).toStrictEqual([ + 'a', + 'b', + 'index', + ]); }); it('redirects a guarded route to an explicit redirectTo target', () => { @@ -641,7 +655,7 @@ describe('all routes guarded', () => { expect(screen.getByTestId('second')).toBeVisible(); expect(screen).toHavePathname('/second'); - const stateBefore = store.state!.routes[0]!.state!; + const stateBefore = navigationRef.getRootState().routes[0]!.state!; const focusedKeyBefore = stateBefore.routes[stateBefore.index!]!.key; // Guard everything: content hides but the navigator must stay mounted. @@ -659,7 +673,7 @@ describe('all routes guarded', () => { expect(screen.getByTestId('second')).toBeVisible(); expect(screen).toHavePathname('/second'); - const stateAfter = store.state!.routes[0]!.state!; + const stateAfter = navigationRef.getRootState().routes[0]!.state!; expect(stateAfter.routes[stateAfter.index!]!.key).toBe(focusedKeyBefore); // Non-focused guarded history entries are pruned while the guard is down, // so only the focused route survives the flip. diff --git a/packages/expo-router/src/__tests__/push.test.ios.tsx b/packages/expo-router/src/__tests__/push.test.ios.tsx index 6147dca5a91019..b54084e015e225 100644 --- a/packages/expo-router/src/__tests__/push.test.ios.tsx +++ b/packages/expo-router/src/__tests__/push.test.ios.tsx @@ -1,7 +1,7 @@ import { act, screen } from '@testing-library/react-native'; import { Text, View } from 'react-native'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { useLocalSearchParams } from '../hooks'; import { router } from '../imperative-api'; import Stack from '../layouts/Stack'; @@ -22,7 +22,7 @@ it('stacks should always push a new route', () => { }); // Initial complete state - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -54,7 +54,7 @@ it('stacks should always push a new route', () => { act(() => router.push('/user/1')); act(() => router.push('/user/2')); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -302,7 +302,7 @@ it('works in a nested layout Stack->Tab->Stack', () => { testRouter.push('/d'); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -443,7 +443,7 @@ it('targets the correct Stack when pushing to a nested layout', () => { act(() => router.push('/a')); // Should push to the root stack - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -557,7 +557,7 @@ it('push should also add anchor routes', () => { }); // Initial complete state - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -587,7 +587,7 @@ it('push should also add anchor routes', () => { act(() => router.push('/orange', { withAnchor: true })); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], diff --git a/packages/expo-router/src/__tests__/redirects.test.ios.tsx b/packages/expo-router/src/__tests__/redirects.test.ios.tsx index a8630515ab9a89..f7a4596fb64e03 100644 --- a/packages/expo-router/src/__tests__/redirects.test.ios.tsx +++ b/packages/expo-router/src/__tests__/redirects.test.ios.tsx @@ -4,9 +4,9 @@ import { Text } from 'react-native'; import type { RedirectConfig } from '../exports'; import { router } from '../exports'; -import type { StoreRedirects } from '../global-state/router-store'; -import { store } from '../global-state/router-store'; -import { StoreContext } from '../global-state/storeContext'; +import { navigationRef } from '../global-state/navigationRef'; +import { RouterConfigContext } from '../global-state/routerConfigContext'; +import type { StoreRedirects } from '../global-state/types'; import Stack from '../layouts/Stack'; import { Tabs } from '../layouts/Tabs'; import { renderRouter } from '../testing-library'; @@ -59,7 +59,7 @@ it('exposes redirects and rewrites through the store context', () => { let contextRedirects: StoreRedirects[] | undefined; function Index() { - contextRedirects = use(StoreContext)!.redirects; + contextRedirects = use(RouterConfigContext)!.redirects; return null; } @@ -96,7 +96,7 @@ it('deep link to a redirect', () => { expect(screen.getByTestId('bar')).toBeTruthy(); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -143,7 +143,7 @@ it('deep link to a dynamic redirect', () => { } ); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -196,7 +196,7 @@ it('keeps extra params as query params', () => { } ); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -243,7 +243,7 @@ it('can redirect from single to catch all', () => { } ); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -291,7 +291,7 @@ it('can push to a redirect', () => { bar: () => , }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -321,7 +321,7 @@ it('can push to a redirect', () => { act(() => router.push('/foo')); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -511,7 +511,7 @@ it('not existing nested route redirects correctly', () => { act(() => router.push('/test/1234')); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], diff --git a/packages/expo-router/src/__tests__/routerActionState.test.ios.tsx b/packages/expo-router/src/__tests__/routerActionState.test.ios.tsx index 89fcd1bac94494..528ca34e6db914 100644 --- a/packages/expo-router/src/__tests__/routerActionState.test.ios.tsx +++ b/packages/expo-router/src/__tests__/routerActionState.test.ios.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, screen } from '@testing-library/react-native'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { useRootNavigationState } from '../hooks'; import { renderHook } from '../hooks/__tests__/renderHook'; import { router } from '../imperative-api'; @@ -34,12 +34,12 @@ it.each([ ['dismissTo', () => router.dismissTo('/(tabs)/deep/1')], ['prefetch', () => router.prefetch('/(tabs)/deep/1')], ['Link press', () => fireEvent.press(screen.getByTestId('deep-link'))], -])('store.state has no marker after %s', (_, navigate) => { +])('root state has no marker after %s', (_, navigate) => { renderRouter(routes); act(navigate); - expectNoMarker(store.state); + expectNoMarker(navigationRef.getRootState()); }); it('useRootNavigationState has no marker', () => { @@ -62,14 +62,14 @@ it('warns and ignores action state without the internal marker', () => { }); act(() => - store.navigationRef.current!.dispatch({ + navigationRef.current!.dispatch({ type: 'NAVIGATE', payload: { name: 'second', state: { routes: [{ name: 'nested' }] } }, }) ); expect(warning).toHaveBeenCalledWith(expect.stringContaining(MARKER)); - const layoutState = store.navigationRef.current!.getRootState().routes[0]!.state!; + const layoutState = navigationRef.current!.getRootState().routes[0]!.state!; expect(layoutState.routes.find((route) => route.name === 'second')?.state).toBeUndefined(); warning.mockRestore(); }); diff --git a/packages/expo-router/src/__tests__/search-params.test.ios.tsx b/packages/expo-router/src/__tests__/search-params.test.ios.tsx index eba40ce532141e..b45b64c5d6f065 100644 --- a/packages/expo-router/src/__tests__/search-params.test.ios.tsx +++ b/packages/expo-router/src/__tests__/search-params.test.ios.tsx @@ -1,6 +1,6 @@ import { screen, act } from '@testing-library/react-native'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { router } from '../imperative-api'; import { renderRouter, testRouter } from '../testing-library'; @@ -25,7 +25,7 @@ describe('push', () => { testRouter.push('/page'); // Duplicate pushes are allowed pushes the new '/page' testRouter.push('/page?c=true'); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -97,7 +97,7 @@ describe('push', () => { testRouter.back(); testRouter.back(); - expect(store.state).toEqual({ + expect(navigationRef.getRootState()).toEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -153,7 +153,7 @@ describe('navigate', () => { testRouter.navigate('/page'); // Will not create new screen are we are already on page testRouter.navigate('/page?c=true'); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -201,7 +201,7 @@ describe('navigate', () => { testRouter.navigate('/b'); testRouter.navigate('/c'); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -257,7 +257,7 @@ describe('navigate', () => { testRouter.dismissAll(); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -307,7 +307,7 @@ describe('replace', () => { testRouter.replace('/page?a=true'); // This will clear the previous route testRouter.push('/page?c=true'); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], diff --git a/packages/expo-router/src/__tests__/stacks.test.ios.tsx b/packages/expo-router/src/__tests__/stacks.test.ios.tsx index bfe1469c308602..995d399230cb28 100644 --- a/packages/expo-router/src/__tests__/stacks.test.ios.tsx +++ b/packages/expo-router/src/__tests__/stacks.test.ios.tsx @@ -2,7 +2,7 @@ import { act, screen } from '@testing-library/react-native'; import { expectTypeOf } from 'expect-type'; import { Text } from 'react-native'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { router } from '../imperative-api'; import Stack from '../layouts/Stack'; import Tabs from '../layouts/Tabs'; @@ -189,7 +189,7 @@ test('dismissAll nested', () => { // The last route should include a sub-state for /one/_layout // It will have three routes (/one/index, /one/page, /one/two) // The last route should include a sub-state for /one/two/_layout - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -304,7 +304,7 @@ test('dismissAll nested', () => { // This should only dismissing the sub-state for /one/two/_layout testRouter.dismissAll(); expect(screen).toHavePathname('/one/two'); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -407,7 +407,7 @@ test('dismissAll nested', () => { // This should only dismissing the sub-state for /one/_layout testRouter.dismissAll(); expect(screen).toHavePathname('/one'); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -593,7 +593,7 @@ describe('singular', () => { } ); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], diff --git a/packages/expo-router/src/__tests__/tabs.test.ios.tsx b/packages/expo-router/src/__tests__/tabs.test.ios.tsx index 1a4103fa74a76f..3e5198802e9b5a 100644 --- a/packages/expo-router/src/__tests__/tabs.test.ios.tsx +++ b/packages/expo-router/src/__tests__/tabs.test.ios.tsx @@ -2,7 +2,7 @@ import { fireEvent, act, screen } from '@testing-library/react-native'; import { Text, View } from 'react-native'; import { router } from '../exports'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { useLocalSearchParams, useSegments } from '../hooks'; import { Stack } from '../layouts/Stack'; import { Tabs } from '../layouts/Tabs'; @@ -390,7 +390,7 @@ it('can use replace navigation', () => { act(() => router.replace('/two')); expect(screen.getByTestId('two')).toBeVisible(); expect(screen.getByLabelText('two, tab, 2 of 2')).toBeVisible(); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], diff --git a/packages/expo-router/src/fork/NavigationContainer.tsx b/packages/expo-router/src/fork/NavigationContainer.tsx index 6a1c086fe8a8a4..71da25ad6a0bd1 100644 --- a/packages/expo-router/src/fork/NavigationContainer.tsx +++ b/packages/expo-router/src/fork/NavigationContainer.tsx @@ -1,9 +1,8 @@ import React from 'react'; import { I18nManager } from 'react-native'; +import { RouterConfigContext } from '../global-state/routerConfigContext'; import { RoutingQueueApiContext, RoutingQueueProvider } from '../global-state/routingQueueContext'; -import { syncStoreNavigationState } from '../global-state/store'; -import { StoreContext } from '../global-state/storeContext'; import type { DocumentTitleOptions, LinkingOptions, @@ -75,7 +74,7 @@ function NavigationContainerInner( }: Props, ref?: React.Ref | null> ) { - const store = React.use(StoreContext); + const routerConfig = React.use(RouterConfigContext); if (linking?.config) { validatePathConfig(linking.config); @@ -147,22 +146,6 @@ function NavigationContainerInner( }); const [isResolved, initialState] = useThenable(getInitialState); - if ( - store && - // Linking state remains the initial state forever. Once navigation is ready, - // `onStateChange` owns the store and this must not restore stale state. - !refContainer.current?.isReady() && - // Async linking may not have produced its initial state yet. - initialState && - // Avoid recalculating route info when the store already has this exact state. - initialState !== store.state - ) { - // TODO(@ubax): remove this render-phase global write with store ownership teardown. - // https://linear.app/expo/issue/ENG-26124 - // Children read route info during this render, so an effect would update the store too late. - syncStoreNavigationState(initialState); - } - React.useImperativeHandle(ref, () => refContainer.current!); if (!isResolved) { @@ -187,8 +170,7 @@ function NavigationContainerInner( onReady={onReadyForLinkingHandling} onStateChange={onStateChangeForLinkingHandling} initialState={initialState} - UNSTABLE_routeNode={store?.routeNode ?? undefined} - UNSTABLE_onStateChangeInsertion={store ? syncStoreNavigationState : undefined} + UNSTABLE_routeNode={routerConfig?.routeNode ?? undefined} ref={refContainer} /> diff --git a/packages/expo-router/src/fork/__tests__/__fixtures__/store.tsx b/packages/expo-router/src/fork/__tests__/__fixtures__/store.tsx index 02fd81e72e467d..c4ca667966177a 100644 --- a/packages/expo-router/src/fork/__tests__/__fixtures__/store.tsx +++ b/packages/expo-router/src/fork/__tests__/__fixtures__/store.tsx @@ -9,27 +9,22 @@ import { RoutingQueueProvider, } from '../../../global-state/routingQueueContext'; import type { RoutingIntent } from '../../../global-state/routingQueue'; -import { storeRef } from '../../../global-state/store'; -import { StoreContext, type StoreContextValue } from '../../../global-state/storeContext'; +import type { RouteNode } from '../../../Route'; +import { defaultRouteInfo, getRouteInfoFromState } from '../../../global-state/getRouteInfoFromState'; +import { RouteInfoContext } from '../../../global-state/routeInfoContext'; +import { RouterConfigContext } from '../../../global-state/routerConfigContext'; +import type { NavigationState } from '../../../react-navigation/routers'; -function EmptyScreen() { - return null; +let routeNode: RouteNode | null = null; +let navigationState: NavigationState | undefined; + +export function setRouteNode(value: RouteNode | null) { + routeNode = value; } -export const storeValue: StoreContextValue = { - get navigationRef() { - return storeRef.current.navigationRef; - }, - linking: undefined, - get state() { - return storeRef.current.state; - }, - rootComponent: EmptyScreen, - get routeNode() { - return storeRef.current.routeNode; - }, - redirects: [], -}; +export function setNavigationState(value: NavigationState | undefined) { + navigationState = value; +} let pendingIntents: RoutingIntent[] = []; @@ -43,10 +38,16 @@ export function getPendingIntents() { } export function StoreProvider({ children }: { children: ReactNode }) { + const routeInfo = + navigationState?.routes[0]?.name === '__root' + ? getRouteInfoFromState(navigationState) + : defaultRouteInfo; return ( - {children} - + + {children} + + ); } diff --git a/packages/expo-router/src/fork/__tests__/useLinking.test.ios.tsx b/packages/expo-router/src/fork/__tests__/useLinking.test.ios.tsx index 67d17909c5abd4..4bf125c2eec136 100644 --- a/packages/expo-router/src/fork/__tests__/useLinking.test.ios.tsx +++ b/packages/expo-router/src/fork/__tests__/useLinking.test.ios.tsx @@ -3,11 +3,15 @@ import { act, type RenderAPI } from '@testing-library/react-native'; import { Text } from 'react-native'; import { node } from '../../global-state/__tests__/__fixtures__/routeNode'; -import { store, storeRef as mockStoreRef } from '../../global-state/store'; +import { completeParsedState } from '../../global-state/createSeededNavigationState'; +import { getRouteInfoFromState } from '../../global-state/getRouteInfoFromState'; +import { getStateFromPath } from '../../link/linking'; import { createNavigationContainerRef, type ParamListBase } from '../../react-navigation/core'; +import { ROOT_CHAIN } from '../../react-navigation/routers/stateKeys'; +import { getMockConfig } from '../../testing-library/mock-config'; import { NavigationContainer } from '../NavigationContainer'; import { useLinking } from '../useLinking'; -import { getPendingIntents, render, renderHook } from './__fixtures__/store'; +import { getPendingIntents, render, renderHook, setRouteNode } from './__fixtures__/store'; let errorSpy: jest.SpiedFunction | undefined; @@ -23,8 +27,7 @@ function getParsedHomeState() { } beforeEach(() => { - mockStoreRef.current.routeNode = node('root', [node('home', [node('[id]')])]); - mockStoreRef.current.state = undefined; + setRouteNode(node('root', [node('home', [node('[id]')])])); }); afterEach(() => { @@ -35,7 +38,7 @@ test('queues an incoming deep link using its extracted app path', () => { const ref = createNavigationContainerRef(); // Only `getRootState` is used by the linking subscription. ref.current = { - getRootState: () => ({ routeNames: ['home'] }), + getRootState: () => ({ routeNames: ['home'], routes: [{ name: '__root' }] }), } as typeof ref.current; let listener: ((url: string) => void) | undefined; const getStateFromPath = jest.fn(() => ({ routes: [{ name: 'home' }] })); @@ -72,11 +75,51 @@ test('queues an incoming deep link using its extracted app path', () => { ]); }); +test('keeps the current route group when parsing an incoming deep link', () => { + const config = getMockConfig(['(a)/shared', '(b)/shared', '(a)/index', '(b)/other']); + const currentState = completeParsedState( + getStateFromPath('/other', config, ['(b)', 'other']), + ROOT_CHAIN + ); + expect(getRouteInfoFromState(currentState).segments).toEqual(['(b)', 'other']); + const ref = createNavigationContainerRef(); + ref.current = { + getRootState: () => currentState, + } as typeof ref.current; + let listener: ((url: string) => void) | undefined; + const parsePath = jest.fn(getStateFromPath); + + function Sample() { + useLinking( + ref, + { + prefixes: ['example://'], + config, + getStateFromPath: parsePath, + subscribe: (nextListener) => { + listener = nextListener; + return () => {}; + }, + }, + () => {} + ); + return null; + } + + render(); + act(() => listener?.('example://shared')); + + expect(parsePath).toHaveBeenCalledWith('shared', config, ['(b)', 'other']); + expect( + getRouteInfoFromState(getStateFromPath('/shared', config, ['(b)', 'other'])).segments + ).toEqual(['(b)', 'shared']); +}); + test('reports an incoming deep link using its extracted app path', () => { const ref = createNavigationContainerRef(); // Only `getRootState` is used by the linking subscription. ref.current = { - getRootState: () => ({ routeNames: ['home'] }), + getRootState: () => ({ routeNames: ['home'], routes: [{ name: '__root' }] }), } as typeof ref.current; let listener: ((url: string) => void) | undefined; const onUnhandledLinking = jest.fn(); @@ -106,7 +149,7 @@ test('reports an incoming deep link using its extracted app path', () => { }); }); -test('resolves a completed state from an async initial URL without writing to the store', async () => { +test('resolves a completed state from an async initial URL', async () => { const ref = createNavigationContainerRef(); const getStateFromPath = jest.fn(() => ({ routes: [ @@ -145,14 +188,13 @@ test('resolves a completed state from an async initial URL without writing to th key: expect.any(String), routeNames: ['[id]'], }); - expect(mockStoreRef.current.state).toBeUndefined(); }); test('resubscribes on re-render and cleans up the previous subscription', () => { const ref = createNavigationContainerRef(); // Only `getRootState` is used by the linking subscription. ref.current = { - getRootState: () => ({ routeNames: ['home'] }), + getRootState: () => ({ routeNames: ['home'], routes: [{ name: '__root' }] }), } as typeof ref.current; const listeners: ((url: string) => void)[] = []; const unsubscribes = [jest.fn(), jest.fn()]; @@ -215,9 +257,11 @@ test('async initial URL is parsed with first-render options', async () => { expect(secondGetStateFromPath).not.toHaveBeenCalled(); }); -test('does not reseed the store when it already holds the seeded state', () => { +test('preserves seeded state on rerender', () => { + const ref = createNavigationContainerRef(); const element = render( 'example://home', @@ -226,10 +270,11 @@ test('does not reseed the store when it already holds the seeded state', () => { {null} ); - const seededState = mockStoreRef.current.state; + const seededState = ref.getRootState(); element.rerender( 'example://home', @@ -239,7 +284,7 @@ test('does not reseed the store when it already holds the seeded state', () => { ); - expect(mockStoreRef.current.state).toBe(seededState); + expect(ref.getRootState()).toBe(seededState); }); test('renders children on first paint with a synchronous initial URL and no initialState prop', () => { @@ -280,16 +325,18 @@ test('shows fallback then content for an async initial URL', async () => { expect(element.getByTestId('content')).toBeTruthy(); }); -test('seeds the store when a synchronous initial URL is absent', () => { +test('seeds navigation state when a synchronous initial URL is absent', () => { + const ref = createNavigationContainerRef(); render( null }}> {null} ); - expect(mockStoreRef.current.state).toMatchObject({ + expect(ref.getRootState()).toMatchObject({ stale: false, routeKeySeq: expect.any(Number), routeNames: ['__root', '+not-found', '_sitemap'], @@ -300,11 +347,11 @@ test('seeds the store when a synchronous initial URL is absent', () => { }, ], }); - expect(store.getRouteInfo().pathname).toBe('/home'); + expect(getRouteInfoFromState(ref.getRootState()).pathname).toBe('/home'); }); test('throws when linking does not produce an initial state', () => { - mockStoreRef.current.routeNode = null; + setRouteNode(null); expect(() => render( diff --git a/packages/expo-router/src/fork/__tests__/useLinking.test.web.tsx b/packages/expo-router/src/fork/__tests__/useLinking.test.web.tsx index 2df7ba96f2b33b..048b054e4dbb8f 100644 --- a/packages/expo-router/src/fork/__tests__/useLinking.test.web.tsx +++ b/packages/expo-router/src/fork/__tests__/useLinking.test.web.tsx @@ -6,7 +6,6 @@ import { node } from '../../global-state/__tests__/__fixtures__/routeNode'; import { completeParsedState } from '../../global-state/createSeededNavigationState'; import { getRouteInfoFromState } from '../../global-state/getRouteInfoFromState'; import { RouterRegistryProvider } from '../../global-state/routerRegistry'; -import { storeRef as mockStoreRef } from '../../global-state/store'; import { getRootStackRouteNames } from '../../global-state/utils'; import { getStateFromPath } from '../../link/linking'; import { Screen } from '../../react-navigation/core/Screen'; @@ -18,7 +17,7 @@ import { getMockConfig } from '../../testing-library/mock-config'; import { NavigationContainer } from '../NavigationContainer'; import { createMemoryHistory } from '../createMemoryHistory'; import { useLinking } from '../useLinking'; -import { getPendingIntents, render } from './__fixtures__/store'; +import { getPendingIntents, render, setNavigationState, setRouteNode } from './__fixtures__/store'; jest.mock('../createMemoryHistory'); let mockNavigationRef: ReturnType; @@ -44,8 +43,8 @@ function EmptyScreen() { } beforeEach(() => { - mockStoreRef.current.state = undefined; - mockStoreRef.current.routeNode = null; + setNavigationState(undefined); + setRouteNode(null); jest.mocked(getRootStackRouteNames).mockReturnValue(['home']); jest.mocked(createMemoryHistory).mockReturnValue(history); Object.defineProperty(globalThis, 'location', { @@ -75,7 +74,14 @@ function renderHistoryListener({ }); const navigation = { addListener: jest.fn(() => () => {}), - getRootState: jest.fn(() => ({ key: 'root' })), + getRootState: jest.fn(() => ({ + stale: false as const, + routeKeySeq: 0, + key: 'root', + index: 0, + routeNames: ['home'], + routes: [{ key: '__root', name: '__root' }], + })), }; // The hook only reads these two methods from the navigation ref in these tests. const ref = { current: navigation } as unknown as Parameters[0]; @@ -97,7 +103,7 @@ function renderHistoryListener({ } test('queues forward history navigation', () => { - mockStoreRef.current.routeNode = mockRouteNode; + setRouteNode(mockRouteNode); const getStateFromPath = jest.fn(() => ({ routes: [{ name: 'home' }] })); const { emitPopState } = renderHistoryListener({ initialIndex: 3, getStateFromPath }); @@ -145,7 +151,7 @@ test('restores saved history state without parsing its path', () => { }); test('restores state parsed from a history path', () => { - mockStoreRef.current.routeNode = mockRouteNode; + setRouteNode(mockRouteNode); const parsedState = { routes: [{ name: 'home' }] }; const getStateFromPath = jest.fn(() => parsedState); const { emitPopState } = renderHistoryListener({ initialIndex: 3, getStateFromPath }); @@ -210,14 +216,14 @@ test('keeps the current route group when parsing a popstate path', () => { jest .mocked(getRootStackRouteNames) .mockReturnValue(parsedSharedState?.routes.map((route) => route.name) ?? []); - mockStoreRef.current.state = completeParsedState( + const currentState = completeParsedState( getStateFromPath('/other', config, ['(b)', 'other']), ROOT_CHAIN ); - expect(getRouteInfoFromState(mockStoreRef.current.state).segments).toEqual(['(b)', 'other']); + expect(getRouteInfoFromState(currentState).segments).toEqual(['(b)', 'other']); const navigation = { addListener: jest.fn(() => () => {}), - getRootState: jest.fn(() => ({ key: 'root' })), + getRootState: jest.fn(() => currentState), }; // The hook only reads these two methods from the navigation ref in this test. const ref = { current: navigation } as unknown as Parameters[0]; @@ -250,8 +256,8 @@ test('keeps the current route group when parsing a popstate path', () => { expect(getRouteInfoFromState(parsedState as NavigationState).segments).toEqual(['(b)', 'shared']); }); -test('parses the initial URL instead of returning the existing store state', async () => { - mockStoreRef.current.routeNode = mockRouteNode; +test('parses the initial URL instead of returning existing navigation state', async () => { + setRouteNode(mockRouteNode); const existingState = { stale: false as const, routeKeySeq: 0, @@ -261,7 +267,7 @@ test('parses the initial URL instead of returning the existing store state', asy routes: [{ key: 'home', name: 'home' }], }; mockNavigationRef = createNavigationContainerRef(); - mockStoreRef.current.state = existingState; + setNavigationState(existingState); Object.assign(globalThis.location, { pathname: '/home', search: '', hash: '' }); let getInitialState: ReturnType['getInitialState'] | undefined; const getStateFromPath = jest.fn(() => ({ routes: [{ name: 'home' }] })); @@ -289,7 +295,7 @@ test('parses the initial URL instead of returning the existing store state', asy }); test('getInitialState is computed once with first-render options', async () => { - mockStoreRef.current.routeNode = mockRouteNode; + setRouteNode(mockRouteNode); mockNavigationRef = createNavigationContainerRef(); Object.assign(globalThis.location, { pathname: '/home', search: '', hash: '' }); const firstGetStateFromPath = jest.fn(() => ({ routes: [{ name: 'home' }] })); @@ -335,14 +341,6 @@ test('does not add browser history when preloading a stack route', async () => { }; const ref = createNavigationContainerRef(); mockNavigationRef = ref; - mockStoreRef.current.state = { - stale: false, - routeKeySeq: 0, - key: 'root', - index: 0, - routeNames: ['home', 'details'], - routes: [{ key: 'home', name: 'home' }], - }; const onStateChange = jest.fn(); render( diff --git a/packages/expo-router/src/fork/useLinking.native.ts b/packages/expo-router/src/fork/useLinking.native.ts index 9a001717f4f94f..f080dad2119638 100644 --- a/packages/expo-router/src/fork/useLinking.native.ts +++ b/packages/expo-router/src/fork/useLinking.native.ts @@ -6,8 +6,8 @@ import { createSeededRootState, } from '../global-state/createSeededNavigationState'; import { getRouteInfoFromState } from '../global-state/getRouteInfoFromState'; +import { RouterConfigContext } from '../global-state/routerConfigContext'; import { useEnqueueRoutingIntent } from '../global-state/routingQueueContext'; -import { StoreContext } from '../global-state/storeContext'; import { type LinkingOptions, getStateFromPath as getStateFromPathDefault, @@ -59,7 +59,7 @@ export function useLinking( }: Options, onUnhandledLinking: (lastUnhandledLining: string | undefined) => void ) { - const store = use(StoreContext); + const routerConfig = use(RouterConfigContext); const enqueue = useEnqueueRoutingIntent(); useEffect(() => { @@ -105,21 +105,23 @@ export function useLinking( getStateFromPathRef.current = getStateFromPath; }); - const getStateFromURL = useCallback((url: string | null | undefined) => { - if (!url || (filterRef.current && !filterRef.current(url))) { - return undefined; - } - - const path = extractExpoPathFromURL(prefixesRef.current, url); + const getStateFromURL = useCallback( + (url: string | null | undefined) => { + if (!url || (filterRef.current && !filterRef.current(url))) { + return undefined; + } - return path !== undefined - ? getStateFromPathRef.current( - path, - configRef.current, - getRouteInfoFromState(store?.state).segments - ) - : undefined; - }, []); + const path = extractExpoPathFromURL(prefixesRef.current, url); + if (path !== undefined) { + // TODO(@ubax): check if this is performant + // TODO(@ubax): check if ref.current?.getRootState() can be replaced with the context read + const segments = getRouteInfoFromState(ref.current?.getRootState()).segments; + return getStateFromPathRef.current(path, configRef.current, segments); + } + return undefined; + }, + [ref] + ); const getInitialState = useCallback(() => { const url = getInitialURL(); @@ -130,7 +132,7 @@ export function useLinking( parsedState = getStateFromPath(path, config); } - const routeNode = store?.routeNode; + const routeNode = routerConfig?.routeNode; return routeNode ? createSeededRootState(parsedState, routeNode) : completeParsedState(parsedState, ROOT_CHAIN); @@ -165,7 +167,7 @@ export function useLinking( }; return thenable as PromiseLike; - }, [config, filter, getInitialURL, getStateFromPath, onUnhandledLinking, prefixes, store]); + }, [config, filter, getInitialURL, getStateFromPath, onUnhandledLinking, prefixes, routerConfig]); useEffect(() => { const listener = (url: string) => { diff --git a/packages/expo-router/src/fork/useLinking.ts b/packages/expo-router/src/fork/useLinking.ts index 53b4432d1adca7..aeab01aebc25b3 100644 --- a/packages/expo-router/src/fork/useLinking.ts +++ b/packages/expo-router/src/fork/useLinking.ts @@ -6,9 +6,9 @@ import { createSeededRootState, } from '../global-state/createSeededNavigationState'; import { getRouteInfoFromState } from '../global-state/getRouteInfoFromState'; +import { RouterConfigContext } from '../global-state/routerConfigContext'; import type { RoutingIntent } from '../global-state/routingQueue'; import { useEnqueueRoutingIntent } from '../global-state/routingQueueContext'; -import { StoreContext } from '../global-state/storeContext'; import { getRootStackRouteNames } from '../global-state/utils'; import { type LinkingOptions, @@ -44,7 +44,7 @@ export function useLinking( }: Options, onUnhandledLinking: (lastUnhandledLining: string | undefined) => void ) { - const store = use(StoreContext); + const routerConfig = use(RouterConfigContext); useEffect(() => { if (process.env.NODE_ENV === 'production') { @@ -85,7 +85,7 @@ export function useLinking( } const parsedState = path ? getStateFromPath(path, config) : undefined; - const routeNode = store?.routeNode; + const routeNode = routerConfig?.routeNode; const state = routeNode ? createSeededRootState(parsedState, routeNode) : completeParsedState(parsedState, ROOT_CHAIN); @@ -180,7 +180,7 @@ function useBrowserHistorySync({ getPathFromState: GetPathFromState; onUnhandledLinking: (path: string | undefined) => void; }) { - const store = use(StoreContext); + const routerConfig = use(RouterConfigContext); const enqueue = useEnqueueRoutingIntent(); const [history] = useState(createMemoryHistory); const configRef = useRef(config); @@ -241,19 +241,17 @@ function useBrowserHistorySync({ return; } - const parsedState = getStateFromPathRef.current( - path, - configRef.current, - getRouteInfoFromState(store?.state).segments - ); + // TODO(@ubax): check if navigation.getRootState() can be replaced with the context read + const segments = getRouteInfoFromState(navigation.getRootState()).segments; + const parsedState = getStateFromPathRef.current(path, configRef.current, segments); if (parsedState) { onUnhandledLinking(path); const routeNames = getRootStackRouteNames(); if (parsedState.routes.some((route) => !routeNames.includes(route.name))) { return; } - const state = store?.routeNode - ? createSeededRootState(parsedState, store.routeNode) + const state = routerConfig?.routeNode + ? createSeededRootState(parsedState, routerConfig.routeNode) : completeParsedState(parsedState, ROOT_CHAIN); if (!state) { return; @@ -326,13 +324,12 @@ function useBrowserHistorySync({ }; if (ref.current) { + // TODO(@ubax): check if navigation.getRootState() can be replaced with the context read const rootState = ref.current.getRootState(); - const state = store?.state as NavigationState | undefined; - - if (state) { - const path = getPathForRoute(findFocusedRoute(state), state); + if (rootState) { + const path = getPathForRoute(findFocusedRoute(rootState), rootState); previousStateRef.current ??= rootState; - history.replace({ path, state }); + history.replace({ path, state: rootState }); } } @@ -344,14 +341,13 @@ function useBrowserHistorySync({ } const previousState = previousStateRef.current; + // TODO(@ubax): check if navigation.getRootState() can be replaced with the context read const rootState = navigation.getRootState(); - const state = store?.state as NavigationState | undefined; - - if (!state) { + if (!rootState) { return; } - const path = getPathForRoute(findFocusedRoute(state), state); + const path = getPathForRoute(findFocusedRoute(rootState), rootState); let pendingOperation: { path: string } | undefined; // React may batch multiple queued actions into one state event, so use the latest match. @@ -368,14 +364,14 @@ function useBrowserHistorySync({ } previousStateRef.current = rootState; - const [previousFocusedState, focusedState] = findMatchingState(previousState, state); + const [previousFocusedState, focusedState] = findMatchingState(previousState, rootState); if (previousFocusedState && focusedState && !pendingOperation) { const historyDelta = getHistoryLength(focusedState) - getHistoryLength(previousFocusedState); if (historyDelta > 0) { - history.push({ path, state }); + history.push({ path, state: rootState }); } else if (historyDelta < 0) { const nextIndex = history.backIndex({ path }); const currentIndex = history.index; @@ -391,15 +387,15 @@ function useBrowserHistorySync({ await history.go(historyDelta); } - history.replace({ path, state }); + history.replace({ path, state: rootState }); } catch { // The navigation was interrupted. } } else { - history.replace({ path, state }); + history.replace({ path, state: rootState }); } } else { - history.replace({ path, state }); + history.replace({ path, state: rootState }); } }; diff --git a/packages/expo-router/src/getLinkingConfig.ts b/packages/expo-router/src/getLinkingConfig.ts index 52b7526a5cffc1..aea7837fda7969 100644 --- a/packages/expo-router/src/getLinkingConfig.ts +++ b/packages/expo-router/src/getLinkingConfig.ts @@ -5,7 +5,7 @@ import { INTERNAL_SLOT_NAME, NOT_FOUND_ROUTE_NAME, SITEMAP_ROUTE_NAME } from './ import type { State } from './fork/getPathFromState'; import { getReactNavigationConfig } from './getReactNavigationConfig'; import { applyRedirects } from './getRoutesRedirects'; -import type { StoreRedirects } from './global-state/router-store'; +import type { StoreRedirects } from './global-state/types'; import { getInitialURL, getPathFromState, getStateFromPath, subscribe } from './link/linking'; import type { LinkingOptions } from './react-navigation/native'; import type { NativeIntent, RequireContext } from './types'; diff --git a/packages/expo-router/src/getRoutesRedirects.tsx b/packages/expo-router/src/getRoutesRedirects.tsx index 5acb171a5c67ec..0e292b17690167 100644 --- a/packages/expo-router/src/getRoutesRedirects.tsx +++ b/packages/expo-router/src/getRoutesRedirects.tsx @@ -3,7 +3,7 @@ import { createElement, useEffect } from 'react'; import { cleanPath } from './fork/getStateFromPath-forks'; import type { RedirectConfig } from './getRoutesCore'; -import type { StoreRedirects } from './global-state/router-store'; +import type { StoreRedirects } from './global-state/types'; import { matchDynamicName } from './matchers'; import { shouldLinkExternally } from './utils/url'; diff --git a/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx b/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx index 1540f984b2bf3f..8191adcfcd411d 100644 --- a/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx +++ b/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx @@ -27,11 +27,7 @@ export function RoutingQueueDrainer({ ready, processIntent }: Props) { // during the next render, so errors from it surface there, not here. try { intent.onDispatch?.(intent.metadata); - if (intent.type === 'NAVIGATOR_ACTION') { - intent.payload.dispatchSync(intent.payload.action); - } else { - processIntent(intent); - } + processIntent(intent); } catch (error) { const message = typeof error === 'object' && error != null && 'message' in error ? error.message : error; diff --git a/packages/expo-router/src/global-state/__tests__/RoutingQueueDrainer.test.ios.tsx b/packages/expo-router/src/global-state/__tests__/RoutingQueueDrainer.test.ios.tsx index 2159ef37590620..50037c2e079010 100644 --- a/packages/expo-router/src/global-state/__tests__/RoutingQueueDrainer.test.ios.tsx +++ b/packages/expo-router/src/global-state/__tests__/RoutingQueueDrainer.test.ios.tsx @@ -89,22 +89,16 @@ it('keeps intents queued until ready', () => { it('processes a queued batch in FIFO order', () => { const calls: string[] = []; const processIntent = jest.fn((intent: RoutingIntent) => calls.push(actionType(intent))); - const dispatchSync = jest.fn(() => calls.push('NAVIGATOR_ACTION')); const onDispatch = jest.fn(() => calls.push('onDispatch')); const result = renderDrainer(true, processIntent); act(() => { result.enqueue(actionIntent('FIRST')); - result.enqueue({ - type: 'NAVIGATOR_ACTION', - payload: { action: { type: 'SECOND' }, dispatchSync }, - onDispatch, - }); + result.enqueue({ ...actionIntent('SECOND'), onDispatch }); result.enqueue(actionIntent('THIRD')); }); - expect(calls).toEqual(['FIRST', 'onDispatch', 'NAVIGATOR_ACTION', 'THIRD']); - expect(dispatchSync).toHaveBeenCalledWith({ type: 'SECOND' }); + expect(calls).toEqual(['FIRST', 'onDispatch', 'SECOND', 'THIRD']); }); it('does not process a batch twice in Strict Mode', () => { diff --git a/packages/expo-router/src/global-state/__tests__/__fixtures__/routerEntry.ts b/packages/expo-router/src/global-state/__tests__/__fixtures__/routerEntry.ts index c9f6153073f927..e9e2a0c16e5b0f 100644 --- a/packages/expo-router/src/global-state/__tests__/__fixtures__/routerEntry.ts +++ b/packages/expo-router/src/global-state/__tests__/__fixtures__/routerEntry.ts @@ -28,7 +28,5 @@ export function entry false, getStateForRouteFocus: (state) => state, - shouldPreventRemove: () => false, - emitBeforeRemove: () => {}, }; } diff --git a/packages/expo-router/src/global-state/__tests__/removalPrevention.test.ios.tsx b/packages/expo-router/src/global-state/__tests__/removalPrevention.test.ios.tsx new file mode 100644 index 00000000000000..30f5873a42870a --- /dev/null +++ b/packages/expo-router/src/global-state/__tests__/removalPrevention.test.ios.tsx @@ -0,0 +1,98 @@ +import { act, render } from '@testing-library/react-native'; +import * as React from 'react'; +import { use } from 'react'; + +import { + GlobalRoutesWithRemovalPreventedContext, + GlobalRemovalEventEmitterRegistryContext, + isRouteRemovalPrevented, + PreventRemovalProvider, + RemovalPreventionProvider, + ScreenRemovalPreventionSetterContext, +} from '../removalPrevention'; + +test('aggregates prevention across routes', () => { + const setters = new Map void>(); + const routes: ReadonlySet[] = []; + function Capture({ routeKey }: { routeKey: string }) { + setters.set(routeKey, use(ScreenRemovalPreventionSetterContext)!); + return null; + } + function RoutesCapture() { + routes.push(use(GlobalRoutesWithRemovalPreventedContext)!); + return null; + } + render( + + + + + + + + + + ); + + act(() => { + setters.get('a')!('first', true); + setters.get('b')!('first', true); + }); + expect(routes.at(-1)).toEqual(new Set(['a', 'b'])); + + act(() => setters.get('a')!('first', false)); + expect(routes.at(-1)).toEqual(new Set(['b'])); + + act(() => setters.get('b')!('first', false)); + expect(routes.at(-1)).toEqual(new Set()); +}); + +test('detects prevention in an active descendant but not a preloaded route', () => { + const route = { + key: 'parent', + name: 'parent', + state: { + stale: false as const, + type: 'stack', + key: 'stack', + routeKeySeq: 0, + index: 0, + routeNames: ['active', 'preloaded'], + routes: [ + { key: 'active', name: 'active' }, + { key: 'preloaded', name: 'preloaded' }, + ], + }, + }; + + expect(isRouteRemovalPrevented(route, new Set(['active']))).toBe(true); + expect(isRouteRemovalPrevented(route, new Set(['preloaded']))).toBe(false); + expect(isRouteRemovalPrevented(route, new Set(['parent']))).toBe(true); +}); + +test('keeps a route emitter until the end of the task after its provider unmounts', async () => { + const action = { type: 'POP' }; + const emitRemovalEvent = jest.fn(); + let registry = null as React.ContextType; + function CaptureRegistry() { + registry = use(GlobalRemovalEventEmitterRegistryContext); + return null; + } + function Tree({ mounted }: { mounted: boolean }) { + return ( + + + {mounted && } + + ); + } + const result = render(); + + result.rerender(); + registry!.emitRemovalEvent('x', 'removed', action); + expect(emitRemovalEvent).toHaveBeenCalledWith('x', 'removed', action); + + await act(() => Promise.resolve()); + registry!.emitRemovalEvent('x', 'removed', action); + expect(emitRemovalEvent).toHaveBeenCalledTimes(1); +}); diff --git a/packages/expo-router/src/global-state/__tests__/router.test.ios.ts b/packages/expo-router/src/global-state/__tests__/router.test.ios.ts index 4cf91de23cb0ce..608a9a18c93b3c 100644 --- a/packages/expo-router/src/global-state/__tests__/router.test.ios.ts +++ b/packages/expo-router/src/global-state/__tests__/router.test.ios.ts @@ -1,6 +1,7 @@ import * as Linking from 'expo-linking'; import { emitDomDismiss, emitDomDismissAll, emitDomGoBack } from '../../domComponents/emitDomEvent'; +import { navigationRef } from '../navigationRef'; import { canDismiss, canGoBack, @@ -18,25 +19,18 @@ import { router, setParams, } from '../router'; -import { store } from '../store'; - -jest.mock('../store', () => ({ - store: { - assertIsReady: jest.fn(), - navigationRef: { - isReady: jest.fn(() => true), - current: { - canGoBack: jest.fn(), - setParams: jest.fn(), - goBack: jest.fn(), - getRootState: jest.fn(), - dispatch: jest.fn(), - }, + +jest.mock('../navigationRef', () => ({ + navigationRef: { + isReady: jest.fn(() => true), + getRootState: jest.fn(), + current: { + canGoBack: jest.fn(), + setParams: jest.fn(), + goBack: jest.fn(), + getRootState: jest.fn(), + dispatch: jest.fn(), }, - state: undefined as any, - linking: { getStateFromPath: jest.fn(), config: {} }, - getRouteInfo: jest.fn(() => ({ pathname: '/', segments: [], params: {} })), - redirects: [], }, })); @@ -66,7 +60,8 @@ const mockEmitDomDismissAll = emitDomDismissAll as jest.Mock; const mockEmitDomGoBack = emitDomGoBack as jest.Mock; beforeEach(() => { jest.clearAllMocks(); - (store as any).state = undefined; + (navigationRef.isReady as jest.Mock).mockReturnValue(true); + (navigationRef.getRootState as jest.Mock).mockReturnValue(undefined); }); it('throws before the module-level router is installed', () => { @@ -77,30 +72,30 @@ it('throws before the module-level router is installed', () => { describe('canDismiss', () => { it('returns false when state is undefined', () => { - (store as any).state = undefined; + (navigationRef.isReady as jest.Mock).mockReturnValue(false); expect(canDismiss()).toBe(false); }); it('returns false for single-route stack', () => { - (store as any).state = { + (navigationRef.getRootState as jest.Mock).mockReturnValue({ type: 'stack', routes: [{ name: 'home' }], index: 0, - }; + }); expect(canDismiss()).toBe(false); }); it('returns true for stack with >1 routes', () => { - (store as any).state = { + (navigationRef.getRootState as jest.Mock).mockReturnValue({ type: 'stack', routes: [{ name: 'home' }, { name: 'detail' }], index: 1, - }; + }); expect(canDismiss()).toBe(true); }); it('traverses nested navigators (tab → stack with 2 routes → true)', () => { - (store as any).state = { + (navigationRef.getRootState as jest.Mock).mockReturnValue({ type: 'tab', routes: [ { @@ -113,20 +108,20 @@ describe('canDismiss', () => { }, ], index: 0, - }; + }); expect(canDismiss()).toBe(true); }); it('returns false when index is undefined in state', () => { - (store as any).state = { + (navigationRef.getRootState as jest.Mock).mockReturnValue({ type: 'tab', routes: [{ name: 'tab1' }], - }; + }); expect(canDismiss()).toBe(false); }); it('returns false for non-stack navigator with single route', () => { - (store as any).state = { + (navigationRef.getRootState as jest.Mock).mockReturnValue({ type: 'tab', routes: [ { @@ -139,12 +134,12 @@ describe('canDismiss', () => { }, ], index: 0, - }; + }); expect(canDismiss()).toBe(false); }); it('traverses deeply nested navigators (tab → stack → tab → stack with 2 routes)', () => { - (store as any).state = { + (navigationRef.getRootState as jest.Mock).mockReturnValue({ type: 'tab', routes: [ { @@ -175,7 +170,7 @@ describe('canDismiss', () => { }, ], index: 0, - }; + }); expect(canDismiss()).toBe(true); }); }); @@ -214,7 +209,7 @@ describe('linkTo', () => { type: 'ACTION', payload: { action: { type: 'GO_BACK' } }, }); - expect(store.navigationRef.current!.goBack).not.toHaveBeenCalled(); + expect(navigationRef.current!.goBack).not.toHaveBeenCalled(); }); it('queues GO_BACK for ../ href', () => { @@ -224,7 +219,7 @@ describe('linkTo', () => { type: 'ACTION', payload: { action: { type: 'GO_BACK' } }, }); - expect(store.navigationRef.current!.goBack).not.toHaveBeenCalled(); + expect(navigationRef.current!.goBack).not.toHaveBeenCalled(); }); it('resolves object hrefs via resolveHref', () => { @@ -337,7 +332,7 @@ describe('router action functions', () => { it('goBack enqueues GO_BACK without requiring the container to be ready', () => { goBack(); - expect(store.navigationRef.isReady).not.toHaveBeenCalled(); + expect(navigationRef.isReady).not.toHaveBeenCalled(); expect(mockAdd).toHaveBeenCalledWith({ type: 'ACTION', payload: { action: { type: 'GO_BACK' } }, @@ -349,23 +344,23 @@ describe('router action functions', () => { }); it('canGoBack returns false when navigation not ready', () => { - (store.navigationRef.isReady as jest.Mock).mockReturnValueOnce(false); + (navigationRef.isReady as jest.Mock).mockReturnValueOnce(false); expect(canGoBack()).toBe(false); }); it('canGoBack delegates to navigationRef.current.canGoBack()', () => { - (store.navigationRef.current!.canGoBack as jest.Mock).mockReturnValueOnce(true); + (navigationRef.current!.canGoBack as jest.Mock).mockReturnValueOnce(true); expect(canGoBack()).toBe(true); - expect(store.navigationRef.current!.canGoBack).toHaveBeenCalled(); + expect(navigationRef.current!.canGoBack).toHaveBeenCalled(); }); it('setParams checks navigation readiness', () => { setParams({ name: 'test' }); - expect(store.navigationRef.isReady).toHaveBeenCalled(); - expect(store.navigationRef.current!.setParams).toHaveBeenCalledWith({ name: 'test' }); + expect(navigationRef.isReady).toHaveBeenCalled(); + expect(navigationRef.current!.setParams).toHaveBeenCalledWith({ name: 'test' }); }); }); @@ -395,6 +390,6 @@ describe('DOM short-circuit paths', () => { expect(mockEmitDomGoBack).toHaveBeenCalled(); expect(mockAdd).not.toHaveBeenCalled(); - expect(store.navigationRef.isReady).not.toHaveBeenCalled(); + expect(navigationRef.isReady).not.toHaveBeenCalled(); }); }); diff --git a/packages/expo-router/src/global-state/__tests__/routerRegistry.test.ios.tsx b/packages/expo-router/src/global-state/__tests__/routerRegistry.test.ios.tsx index 3757d376fc325b..560914526d498c 100644 --- a/packages/expo-router/src/global-state/__tests__/routerRegistry.test.ios.tsx +++ b/packages/expo-router/src/global-state/__tests__/routerRegistry.test.ios.tsx @@ -8,7 +8,7 @@ import { router } from '../../imperative-api'; import Stack from '../../layouts/Stack'; import { StackActions, type NavigationState } from '../../react-navigation/native'; import { getMockContext, renderRouter } from '../../testing-library'; -import { store } from '../router-store'; +import { navigationRef } from '../navigationRef'; import { RouterRegistryProvider, RouterRegistryContext, @@ -46,7 +46,7 @@ function collectStateKeys(state: NavigationState): string[] { } function getLayoutState(): NavigationState { - const layoutState = store.navigationRef.current!.getRootState().routes[0]!.state; + const layoutState = navigationRef.current!.getRootState().routes[0]!.state; if (layoutState?.stale !== false) { throw new Error('Expected initialized layout state'); @@ -190,7 +190,7 @@ describe('navigation builder registration', () => { index: Probe, }); - const rootState = store.navigationRef.current!.getRootState(); + const rootState = navigationRef.current!.getRootState(); const layoutState = rootState.routes[0]!.state!; expect([...registry.keys()]).toEqual(expect.arrayContaining([rootState.key, layoutState.key])); @@ -289,12 +289,12 @@ describe('navigation builder registration', () => { ); - const initialKeys = collectStateKeys(store.navigationRef.current!.getRootState()); + const initialKeys = collectStateKeys(navigationRef.current!.getRootState()); const initialMounts = mounts; act(() => rerenderLayout()); - expect(collectStateKeys(store.navigationRef.current!.getRootState())).toEqual(initialKeys); + expect(collectStateKeys(navigationRef.current!.getRootState())).toEqual(initialKeys); expect(mounts).toBe(initialMounts); }); diff --git a/packages/expo-router/src/global-state/__tests__/store.test.ios.ts b/packages/expo-router/src/global-state/__tests__/store.test.ios.ts deleted file mode 100644 index f9fc4918b22748..00000000000000 --- a/packages/expo-router/src/global-state/__tests__/store.test.ios.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { INTERNAL_SLOT_NAME } from '../../constants'; -import { store, storeRef, syncStoreNavigationState } from '../store'; -import type { ReactNavigationState } from '../types'; - -const state: ReactNavigationState = { - stale: false, - routeKeySeq: 0, - key: 'root', - index: 0, - routeNames: [INTERNAL_SLOT_NAME], - routes: [ - { - key: 'slot', - name: INTERNAL_SLOT_NAME, - state: { - stale: false, - routeKeySeq: 0, - key: 'layout', - index: 0, - routeNames: ['index'], - routes: [{ key: 'index', name: 'index', path: '/' }], - }, - }, - ], -}; - -const secondState: ReactNavigationState = { - ...state, - key: 'second-root', - routes: [ - { - key: 'second-slot', - name: INTERNAL_SLOT_NAME, - state: { - stale: false, - routeKeySeq: 0, - key: 'second-layout', - index: 0, - routeNames: ['second'], - routes: [{ key: 'second', name: 'second', path: '/second' }], - }, - }, - ], -}; - -afterEach(() => { - storeRef.current.state = undefined; -}); - -it('reads route info from the live store ref', () => { - syncStoreNavigationState(state); - storeRef.current.state = secondState; - - expect(store.getRouteInfo().pathname).toBe('/second'); -}); - -it('memoizes route info for the current state reference', () => { - syncStoreNavigationState(state); - - const first = store.getRouteInfo(); - expect(store.getRouteInfo()).toBe(first); - - syncStoreNavigationState({ ...state }); - expect(store.getRouteInfo()).not.toBe(first); -}); - -it('logs an error for stale focused state', () => { - const error = jest.spyOn(console, 'error').mockImplementation(() => {}); - const nodeEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'development'; - // `ReactNavigationState` permits partial input, so this creates invalid runtime state deliberately. - const staleState = { - ...state, - routes: [ - { - ...state.routes[0]!, - state: { ...state.routes[0]!.state!, stale: true }, - }, - ], - } as ReactNavigationState; - - syncStoreNavigationState(staleState); - - expect(error).toHaveBeenCalledWith('Detected stale state. This is likely a bug in Expo Router.'); - process.env.NODE_ENV = nodeEnv; - error.mockRestore(); -}); diff --git a/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx b/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx index 93f1e0552fa047..ee53822838a57a 100644 --- a/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx +++ b/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx @@ -28,43 +28,311 @@ const initialState: NavigationState = { }; function renderReducer({ + state = initialState, registry, - onStateChangeInsertion = jest.fn(), + routesWithRemovalPrevented = new Set(), }: { + state?: NavigationState; registry: RouterRegistry; - onStateChangeInsertion?: (state: NavigationState) => void; + routesWithRemovalPrevented?: ReadonlySet; }) { - return renderHook, { registry: RouterRegistry }>( - ({ registry }) => - useNavigationTreeReducer({ - initialState, + const reports: NonNullable['report']>[] = []; + const result = renderHook< + ReturnType, + { + registry: RouterRegistry; + routesWithRemovalPrevented: ReadonlySet; + } + >( + ({ registry, routesWithRemovalPrevented }) => { + const reducer = useNavigationTreeReducer({ + initialState: state, registry, - onStateChangeInsertion, - }), - { initialProps: { registry } } + routesWithRemovalPrevented, + }); + if (reducer.report) { + reports.push(reducer.report); + } + return reducer; + }, + { initialProps: { registry, routesWithRemovalPrevented } } ); + return { ...result, reports }; } +test('reports and vetoes removal of a prevented route', () => { + const action = { type: 'REMOVE' }; + const result = renderReducer({ + registry: new Map([ + [ + 'root', + entry((state) => ({ + state: { ...state, index: 0, routes: state.routes.slice(0, 1) }, + affectedRouteKey: state.routes[0]!.key, + })), + ], + ]), + routesWithRemovalPrevented: new Set(['third']), + }); + + act(() => result.result.current.handleAction(action)); + + expect(result.result.current.state).toBe(initialState); + expect(result.reports.at(-1)).toMatchObject({ + events: [{ type: 'prevented-routes', routeKeys: ['third'], action }], + }); + expect(result.result.current.report).toMatchObject({ + events: [{ type: 'prevented-routes', routeKeys: ['third'], action }], + }); +}); + +test('commits removal and reports removed routes when none are prevented', () => { + const action = { type: 'REMOVE' }; + const result = renderReducer({ + registry: new Map([ + [ + 'root', + entry((state) => ({ + state: { ...state, index: 0, routes: state.routes.slice(0, 1) }, + affectedRouteKey: state.routes[0]!.key, + })), + ], + ]), + }); + + act(() => result.result.current.handleAction(action)); + + expect(result.result.current.state.routes).toHaveLength(1); + expect(result.reports.at(-1)).toMatchObject({ + events: [ + { type: 'removed-routes', routeKeys: ['third', 'second'], action }, + { type: 'action-dispatched', action }, + ], + }); +}); + +test('does not let a preloaded stack route prevent removal', () => { + const stackState: NavigationState = { + ...initialState, + type: 'stack', + index: 0, + }; + const result = renderReducer({ + state: stackState, + registry: new Map([ + [ + 'root', + entry((state) => ({ + state: { ...state, routes: state.routes.slice(0, 1) }, + affectedRouteKey: state.routes[0]!.key, + })), + ], + ]), + routesWithRemovalPrevented: new Set(['second']), + }); + + act(() => result.result.current.handleAction({ type: 'REMOVE_PRELOAD' })); + + expect(result.result.current.state.routes).toHaveLength(1); + expect(result.reports.at(-1)?.events).toEqual([ + expect.objectContaining({ + type: 'action-dispatched', + action: { type: 'REMOVE_PRELOAD' }, + }), + ]); +}); + +test('prevents moving an active route into the preloaded region', () => { + const stackState: NavigationState = { + ...initialState, + type: 'stack', + index: 2, + }; + const result = renderReducer({ + state: stackState, + registry: new Map([ + [ + 'root', + entry((state) => ({ + state: { ...state, index: 0 }, + affectedRouteKey: state.routes[0]!.key, + })), + ], + ]), + routesWithRemovalPrevented: new Set(['second']), + }); + + act(() => result.result.current.handleAction({ type: 'RESET_INDEX' })); + + expect(result.result.current.state).toBe(stackState); + expect(result.reports.at(-1)?.events).toEqual([ + { + id: 0, + type: 'prevented-routes', + routeKeys: ['second'], + action: { type: 'RESET_INDEX' }, + }, + ]); +}); + +test('does not veto route name changes', () => { + const action = { type: 'ROUTE_NAMES_CHANGED' }; + const result = renderReducer({ + registry: new Map([ + [ + 'root', + entry((state) => ({ + state: { ...state, index: 0, routes: state.routes.slice(0, 1) }, + affectedRouteKey: state.routes[0]!.key, + })), + ], + ]), + routesWithRemovalPrevented: new Set(['third']), + }); + + act(() => result.result.current.handleAction(action)); + + expect(result.result.current.state.routes).toHaveLength(1); + expect(result.reports.at(-1)?.events).toEqual([ + { id: 0, type: 'removed-routes', routeKeys: ['third', 'second'], action }, + expect.objectContaining({ id: 1, type: 'action-dispatched', action }), + ]); +}); + it('reduces consecutive actions against accumulated state with one committed update', () => { const reduce = jest.fn((state: NavigationState) => ({ state: { ...state, index: state.index + 1 }, affectedRouteKey: state.routes[state.index + 1]!.key, })); - const onStateChangeInsertion = jest.fn(); const result = renderReducer({ registry: new Map([['root', entry(reduce)]]), - onStateChangeInsertion, }); + const firstAction = { type: 'NEXT_FIRST' }; + const secondAction = { type: 'NEXT_SECOND' }; act(() => { - result.result.current.handleAction({ type: 'NEXT' }); - result.result.current.handleAction({ type: 'NEXT' }); + result.result.current.handleAction(firstAction); + result.result.current.handleAction(secondAction); }); expect(reduce).toHaveBeenCalledTimes(2); expect(reduce.mock.calls[1]![0].index).toBe(1); expect(result.result.current.state.index).toBe(2); - expect(onStateChangeInsertion).toHaveBeenCalledTimes(2); + expect(result.reports.at(-1)?.events).toEqual([ + expect.objectContaining({ + id: 0, + type: 'action-dispatched', + action: firstAction, + }), + expect.objectContaining({ + id: 1, + type: 'action-dispatched', + action: secondAction, + }), + ]); +}); + +it('assigns increasing ids to events across actions', () => { + const result = renderReducer({ + registry: new Map([ + [ + 'root', + entry((state) => ({ + state: { ...state, index: state.index + 1 }, + affectedRouteKey: state.routes[state.index + 1]!.key, + })), + ], + ]), + }); + + act(() => result.result.current.handleAction({ type: 'FIRST' })); + act(() => result.result.current.handleAction({ type: 'SECOND' })); + + expect(result.result.current.report?.events.map((event) => event.id)).toEqual([0, 1]); +}); + +it('consumes only the listed report events', () => { + const result = renderReducer({ + registry: new Map([ + [ + 'root', + entry((state) => ({ + state: { ...state, index: state.index + 1 }, + affectedRouteKey: state.routes[state.index + 1]!.key, + })), + ], + ]), + }); + + act(() => { + result.result.current.handleAction({ type: 'FIRST' }); + result.result.current.handleAction({ type: 'SECOND' }); + }); + act(() => result.result.current.consumeReportEvents([0])); + + expect(result.result.current.report?.events.map((event) => event.id)).toEqual([1]); + + const report = result.result.current.report; + act(() => result.result.current.consumeReportEvents([99])); + expect(result.result.current.report).toBe(report); + + act(() => result.result.current.consumeReportEvents([1])); + expect(result.result.current.report).toBeUndefined(); +}); + +it('logs an error for stale focused state after commit', () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + const nodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + const reduce = jest.fn((state: NavigationState) => { + // `NavigationState` excludes stale committed state, which this test deliberately creates. + const staleState = { + ...state, + routes: [{ ...state.routes[0]!, state: { ...state, stale: true as const } }], + } as unknown as NavigationState; + return { state: staleState, affectedRouteKey: state.routes[0]!.key }; + }); + const result = renderReducer({ + registry: new Map([['root', entry(reduce)]]), + }); + + act(() => result.result.current.handleAction({ type: 'STALE' })); + + expect(error).toHaveBeenCalledWith('Detected stale state. This is likely a bug in Expo Router.'); + process.env.NODE_ENV = nodeEnv; + error.mockRestore(); +}); + +it('logs an error for focused state without an index after commit', () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + const nodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + const reduce = jest.fn((state: NavigationState) => { + // `NavigationState` excludes incomplete committed state, which this test deliberately creates. + const incompleteState = { + ...state, + routes: [ + { + ...state.routes[0]!, + state: { + stale: false, + routeKeySeq: state.routeKeySeq, + key: state.key, + routeNames: state.routeNames, + routes: state.routes, + }, + }, + ], + } as unknown as NavigationState; + return { state: incompleteState, affectedRouteKey: state.routes[0]!.key }; + }); + const result = renderReducer({ registry: new Map([['root', entry(reduce)]]) }); + + act(() => result.result.current.handleAction({ type: 'INCOMPLETE' })); + + expect(error).toHaveBeenCalledWith('Detected stale state. This is likely a bug in Expo Router.'); + process.env.NODE_ENV = nodeEnv; + error.mockRestore(); }); it('reduces consecutive queued intents against accumulated state', () => { @@ -96,7 +364,13 @@ it('warns for direct navigation actions carrying a screen param', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const result = renderReducer({ registry: new Map([ - ['root', entry((state) => ({ state, affectedRouteKey: state.routes[state.index]!.key }))], + [ + 'root', + entry((state) => ({ + state, + affectedRouteKey: state.routes[state.index]!.key, + })), + ], ]), }); @@ -150,9 +424,14 @@ it('resets a state slice when its router unregisters', () => { const routeNode = node('root', [node('first'), node('second'), node('third')]); routeNode.initialRouteName = 'second'; const registryEntry = { ...entry(() => null), routeNode }; - const result = renderReducer({ registry: new Map([['root', registryEntry]]) }); + const result = renderReducer({ + registry: new Map([['root', registryEntry]]), + }); - result.rerender({ registry: new Map() }); + result.rerender({ + registry: new Map(), + routesWithRemovalPrevented: new Set(), + }); expect(result.result.current.state).toMatchObject({ index: 0, @@ -190,7 +469,10 @@ it('does not reset a state slice when its router entry is replaced', () => { registry: new Map([['root', entry(() => null)]]), }); - result.rerender({ registry: new Map([['root', entry(() => null)]]) }); + result.rerender({ + registry: new Map([['root', entry(() => null)]]), + routesWithRemovalPrevented: new Set(), + }); expect(result.result.current.state).toBe(initialState); }); @@ -209,7 +491,11 @@ describe('NAVIGATE_TO_HREF', () => { function navigateToHref( result: ReturnType, - payload: { href?: string; options?: LinkToOptions; originalHref?: string } = {} + payload: { + href?: string; + options?: LinkToOptions; + originalHref?: string; + } = {} ) { act(() => result.result.current.processIntent({ @@ -232,7 +518,10 @@ describe('NAVIGATE_TO_HREF', () => { }); it('warns with the resolved href when the href is invalid', () => { - mockGetNavigateAction.mockReturnValue({ status: 'invalid', href: '/resolved' }); + mockGetNavigateAction.mockReturnValue({ + status: 'invalid', + href: '/resolved', + }); const result = renderReducer({ registry: new Map() }); navigateToHref(result); @@ -242,7 +531,10 @@ describe('NAVIGATE_TO_HREF', () => { }); it('warns with the original href when the href is invalid after a redirect', () => { - mockGetNavigateAction.mockReturnValue({ status: 'invalid', href: '/resolved' }); + mockGetNavigateAction.mockReturnValue({ + status: 'invalid', + href: '/resolved', + }); const result = renderReducer({ registry: new Map() }); navigateToHref(result, { originalHref: 'myapp://original' }); @@ -283,6 +575,7 @@ describe('NAVIGATE_TO_HREF', () => { routeNode: undefined, linking: undefined, redirects: undefined, + routesWithRemovalPrevented: new Set(), }, 'PUSH', true, @@ -303,23 +596,3 @@ it('throws for an incomplete initial state', () => { ) ).toThrow('incomplete initial state'); }); - -it('reads the latest root state by key', () => { - const result = renderReducer({ - registry: new Map([ - [ - 'root', - entry((state) => ({ - state: { ...state, index: 1 }, - affectedRouteKey: state.routes[1]!.key, - })), - ], - ]), - }); - - act(() => result.result.current.handleAction({ type: 'NEXT' })); - - expect(result.result.current.getState()).toBe(result.result.current.state); - expect(result.result.current.getStateForKey('root')).toBe(result.result.current.state); - expect(result.result.current.getStateForKey('missing')).toBeUndefined(); -}); diff --git a/packages/expo-router/src/global-state/__tests__/useNavigationTreeReportEvents.test.ios.tsx b/packages/expo-router/src/global-state/__tests__/useNavigationTreeReportEvents.test.ios.tsx new file mode 100644 index 00000000000000..f684f1fd503d02 --- /dev/null +++ b/packages/expo-router/src/global-state/__tests__/useNavigationTreeReportEvents.test.ios.tsx @@ -0,0 +1,134 @@ +import { renderHook } from '@testing-library/react-native'; +import * as React from 'react'; +import type { PropsWithChildren } from 'react'; + +import { unstable_navigationEvents } from '../../navigationEvents'; +import type { NavigationState } from '../../react-navigation/routers'; +import { PreventRemovalProvider, RemovalPreventionProvider } from '../removalPrevention'; +import type { NavigationTreeReport } from '../useNavigationTreeReducer'; +import { useNavigationTreeReportEvents } from '../useNavigationTreeReportEvents'; + +const state: NavigationState = { + stale: false, + key: 'root', + routeKeySeq: 0, + index: 0, + routeNames: ['index'], + routes: [{ key: 'index', name: 'index' }], +}; + +function wrapper({ children }: PropsWithChildren) { + return {children}; +} + +test('emits and consumes only new report events', () => { + const actions: string[] = []; + const consumeReportEvents = jest.fn(); + const unsubscribe = unstable_navigationEvents.addListener('actionDispatched', (event) => + actions.push(event.actionType) + ); + const firstEvent = { + id: 0, + type: 'action-dispatched' as const, + action: { type: 'FIRST' }, + state, + }; + const report: NavigationTreeReport = { events: [firstEvent] }; + const result = renderHook( + ({ report }: { report: NavigationTreeReport }) => + useNavigationTreeReportEvents(report, consumeReportEvents), + { wrapper, initialProps: { report } } + ); + + result.rerender({ + report: { + events: [firstEvent, { id: 1, type: 'action-dispatched', action: { type: 'SECOND' }, state }], + }, + }); + + expect(actions).toEqual(['FIRST', 'SECOND']); + expect(consumeReportEvents).toHaveBeenNthCalledWith(1, [0]); + expect(consumeReportEvents).toHaveBeenNthCalledWith(2, [1]); + unsubscribe(); +}); + +test('emits removePrevented and removed to the registered route emitters', () => { + const emitRemovalEvent = jest.fn(); + const consumeReportEvents = jest.fn(); + const action = { type: 'POP' }; + const report: NavigationTreeReport = { + events: [ + { id: 0, type: 'prevented-routes', routeKeys: ['a'], action }, + { id: 1, type: 'removed-routes', routeKeys: ['a'], action }, + ], + }; + + const result = renderHook( + ({ report }: { report: NavigationTreeReport | undefined }) => + useNavigationTreeReportEvents(report, consumeReportEvents), + { + initialProps: { report: undefined }, + wrapper: ({ children }: PropsWithChildren) => ( + + + {children} + + + ), + } + ); + result.rerender({ report }); + + expect(emitRemovalEvent).toHaveBeenNthCalledWith(1, 'a', 'removePrevented', action); + expect(emitRemovalEvent).toHaveBeenNthCalledWith(2, 'a', 'removed', action); + expect(consumeReportEvents).toHaveBeenCalledWith([0, 1]); +}); + +test('does not emit twice in StrictMode', () => { + const actions: string[] = []; + const consumeReportEvents = jest.fn(); + const unsubscribe = unstable_navigationEvents.addListener('actionDispatched', (event) => + actions.push(event.actionType) + ); + const report: NavigationTreeReport = { + events: [{ id: 0, type: 'action-dispatched', action: { type: 'FIRST' }, state }], + }; + + renderHook(() => useNavigationTreeReportEvents(report, consumeReportEvents), { + wrapper: ({ children }: PropsWithChildren) => ( + + {children} + + ), + }); + + expect(actions).toEqual(['FIRST']); + expect(consumeReportEvents).toHaveBeenCalledTimes(1); + unsubscribe(); +}); + +test('keeps emitting the remaining events when a listener throws', () => { + const actions: string[] = []; + const consumeReportEvents = jest.fn(); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const unsubscribe = unstable_navigationEvents.addListener('actionDispatched', (event) => { + actions.push(event.actionType); + if (event.actionType === 'FIRST') { + throw new Error('listener failed'); + } + }); + const report: NavigationTreeReport = { + events: [ + { id: 0, type: 'action-dispatched', action: { type: 'FIRST' }, state }, + { id: 1, type: 'action-dispatched', action: { type: 'SECOND' }, state }, + ], + }; + + renderHook(() => useNavigationTreeReportEvents(report, consumeReportEvents), { wrapper }); + + expect(actions).toEqual(['FIRST', 'SECOND']); + expect(warn).toHaveBeenCalledTimes(1); + expect(consumeReportEvents).toHaveBeenCalledWith([0, 1]); + unsubscribe(); + warn.mockRestore(); +}); diff --git a/packages/expo-router/src/global-state/navigationRef.ts b/packages/expo-router/src/global-state/navigationRef.ts new file mode 100644 index 00000000000000..2072d26812d18c --- /dev/null +++ b/packages/expo-router/src/global-state/navigationRef.ts @@ -0,0 +1,4 @@ +import { createNavigationContainerRef } from '../react-navigation/core/createNavigationContainerRef'; + +// TODO(@ubax): scope this module-level mutable state to each navigation container +export const navigationRef = createNavigationContainerRef(); diff --git a/packages/expo-router/src/global-state/removalPrevention.tsx b/packages/expo-router/src/global-state/removalPrevention.tsx new file mode 100644 index 00000000000000..3c7087971f115c --- /dev/null +++ b/packages/expo-router/src/global-state/removalPrevention.tsx @@ -0,0 +1,190 @@ +'use client'; + +import * as React from 'react'; +import { createContext, use, useMemo, useState, type PropsWithChildren } from 'react'; + +import { useClientLayoutEffect } from '../react-navigation/core/useClientLayoutEffect'; +import type { NavigationAction, NavigationState, PartialState } from '../react-navigation/routers'; + +type RemovalEventType = 'removePrevented' | 'removed'; +type RemovalEventEmitter = (type: RemovalEventType, action: NavigationAction) => void; +type RouteRemovalEventEmitter = ( + routeKey: string, + type: RemovalEventType, + action: NavigationAction +) => void; +type RemovalEventEmitterRegistry = { + registerRouteEmitter: (routeKey: string, emitter: RemovalEventEmitter) => void; + unregisterRouteEmitter: (routeKey: string, emitter: RemovalEventEmitter) => void; + emitRemovalEvent: (routeKey: string, type: RemovalEventType, action: NavigationAction) => void; +}; + +/** Provides the route keys that currently prevent removal across the navigation tree. */ +export const GlobalRoutesWithRemovalPreventedContext = createContext< + ReadonlySet | undefined +>(undefined); + +/** Publishes whether a route currently prevents removal. */ +const GlobalRouteRemovalPreventionSetterContext = createContext< + ((routeKey: string, id: string, isPrevented: boolean) => void) | null +>(null); + +/** Registers route emitters and delivers their post-commit removal events. */ +export const GlobalRemovalEventEmitterRegistryContext = + createContext(null); + +/** Registers independent prevention requests with the nearest route provider. */ +export const ScreenRemovalPreventionSetterContext = createContext< + ((id: string, isPrevented: boolean) => void) | undefined +>(undefined); + +function RemovalEventEmitterRegistryProvider({ children }: PropsWithChildren) { + const emitters = React.useRef(new Map()); + const emitterRegistry = useMemo( + () => ({ + registerRouteEmitter(routeKey, emitter) { + emitters.current.set(routeKey, emitter); + }, + unregisterRouteEmitter(routeKey, emitter) { + // Route providers unmount before post-commit `removed` delivery. Keep this emitter through + // the current task, unless another provider has already registered for the same route. + queueMicrotask(() => { + if (emitters.current.get(routeKey) === emitter) { + emitters.current.delete(routeKey); + } + }); + }, + emitRemovalEvent(routeKey, type, action) { + emitters.current.get(routeKey)?.(type, action); + }, + }), + [] + ); + + return ( + + {children} + + ); +} + +function RoutesWithRemovalPreventedProvider({ children }: PropsWithChildren) { + const [preventedRoutes, setPreventedRoutes] = useState>>( + () => new Map() + ); + const preventionSetter = React.useCallback( + (routeKey: string, id: string, isPrevented: boolean) => { + setPreventedRoutes((previous) => { + const previousIds = previous.get(routeKey) ?? new Set(); + if (previousIds.has(id) === isPrevented) { + return previous; + } + const nextIds = new Set(previousIds); + if (isPrevented) { + nextIds.add(id); + } else { + nextIds.delete(id); + } + const next = new Map(previous); + if (nextIds.size > 0) { + next.set(routeKey, nextIds); + } else { + next.delete(routeKey); + } + return next; + }); + }, + [] + ); + const preventedRouteKeys = useMemo(() => new Set(preventedRoutes.keys()), [preventedRoutes]); + + return ( + + + {children} + + + ); +} + +/** Owns the global prevented-route list and route removal-event registry. */ +export function RemovalPreventionProvider({ children }: PropsWithChildren) { + return ( + + {children} + + ); +} + +function useRegisterRouteEmitter(routeKey: string, emitRemovalEvent?: RouteRemovalEventEmitter) { + const emitterRegistry = use(GlobalRemovalEventEmitterRegistryContext); + const routeEmitter = React.useCallback( + (type, action) => emitRemovalEvent?.(routeKey, type, action), + [emitRemovalEvent, routeKey] + ); + + useClientLayoutEffect(() => { + if (!emitRemovalEvent || !emitterRegistry) { + return; + } + emitterRegistry.registerRouteEmitter(routeKey, routeEmitter); + return () => emitterRegistry.unregisterRouteEmitter(routeKey, routeEmitter); + }, [emitRemovalEvent, emitterRegistry, routeEmitter, routeKey]); +} + +function useRouteRemovalPreventionSetter(routeKey: string) { + const preventionSetter = use(GlobalRouteRemovalPreventionSetterContext); + return React.useCallback( + (id: string, isPrevented: boolean) => preventionSetter?.(routeKey, id, isPrevented), + [preventionSetter, routeKey] + ); +} + +/** Binds prevention requests and removal events to one route. */ +export function PreventRemovalProvider({ + routeKey, + emitRemovalEvent, + children, +}: PropsWithChildren<{ + routeKey: string; + emitRemovalEvent?: RouteRemovalEventEmitter; +}>) { + useRegisterRouteEmitter(routeKey, emitRemovalEvent); + const setPrevented = useRouteRemovalPreventionSetter(routeKey); + + return ( + + {children} + + ); +} + +export function useRoutesWithRemovalPrevented() { + return use(GlobalRoutesWithRemovalPreventedContext) ?? EMPTY_SET; +} + +const EMPTY_SET: ReadonlySet = new Set(); + +export function isRouteRemovalPrevented( + route: { + key: string | undefined; + state?: NavigationState | PartialState; + }, + preventedRouteKeys: ReadonlySet +): boolean { + if (route.key !== undefined && preventedRouteKeys.has(route.key)) { + return true; + } + + const visitState = (state: NavigationState): boolean => { + // TODO(@ubax): Add more generic way of filtering preloaded routes + const routes = state.type === 'stack' ? state.routes.slice(0, state.index + 1) : state.routes; + return routes.some( + (route) => + preventedRouteKeys.has(route.key) || + (route.state?.stale === false && visitState(route.state)) + ); + }; + + return route.state?.stale === false && visitState(route.state); +} diff --git a/packages/expo-router/src/global-state/router-store.tsx b/packages/expo-router/src/global-state/router-store.tsx deleted file mode 100644 index 590892711d9491..00000000000000 --- a/packages/expo-router/src/global-state/router-store.tsx +++ /dev/null @@ -1,8 +0,0 @@ -// Re-export shim — preserves all existing import paths. -// TODO: Refactor consumers to import directly from the new modules, then delete this file. - -export { store } from './store'; -export type { RouterStore } from './store'; -export { useStore } from './useStore'; -export { useRouteInfo } from './useRouteInfo'; -export type { StoreRedirects, ReactNavigationState, FocusedRouteState } from './types'; diff --git a/packages/expo-router/src/global-state/router.ts b/packages/expo-router/src/global-state/router.ts index 41c91de0152a41..dd508a83a2e448 100644 --- a/packages/expo-router/src/global-state/router.ts +++ b/packages/expo-router/src/global-state/router.ts @@ -13,12 +13,13 @@ import { resolveHref } from '../link/href'; import type { Href, RoutePath, RouteInputParams } from '../types'; import { getHistoryLength } from '../utils/stack'; import { shouldLinkExternally } from '../utils/url'; +import { navigationRef } from './navigationRef'; import type { RoutingIntent } from './routingQueue'; -import { store } from './store'; import type { LinkToOptions, NavigationOptions } from './types'; function assertIsReady() { - if (!store.navigationRef.isReady()) { + // TODO(@ubax): check whether this is still needed + if (!navigationRef.isReady()) { throw new Error( 'Attempted to navigate before mounting the Root Layout component. Ensure the Root Layout component is rendering a Slot, or other navigator on the first render.' ); @@ -108,10 +109,11 @@ export function canGoBack(): boolean { // before mounting a navigator. This behavior exists due to React Navigation being dynamically // constructed at runtime. We can get rid of this in the future if we use // the static configuration internally. - if (!store.navigationRef.isReady()) { + // TODO(@ubax): check whether this is still needed + if (!navigationRef.isReady()) { return false; } - return store.navigationRef?.current?.canGoBack() ?? false; + return navigationRef.current?.canGoBack() ?? false; } export function canDismiss(): boolean { @@ -120,7 +122,12 @@ export function canDismiss(): boolean { 'canDismiss imperative method is not supported. Pass the property to the DOM component instead.' ); } - let state = store.state; + // TODO(@ubax): check whether this is still needed + if (!navigationRef.isReady()) { + return false; + } + // TODO(@ubax): check whether this is still needed + let state = navigationRef.getRootState(); // Keep traversing down the state tree until we find a stack navigator that we can pop while (state) { @@ -144,7 +151,7 @@ export function setParams( return; } assertIsReady(); - return (store.navigationRef?.current?.setParams as any)(params); + return (navigationRef.current?.setParams as any)(params); } function linkToImpl( diff --git a/packages/expo-router/src/global-state/routerConfigContext.ts b/packages/expo-router/src/global-state/routerConfigContext.ts new file mode 100644 index 00000000000000..34c547db323d7e --- /dev/null +++ b/packages/expo-router/src/global-state/routerConfigContext.ts @@ -0,0 +1,15 @@ +'use client'; + +import { createContext } from 'react'; + +import type { RouteNode } from '../Route'; +import type { ExpoLinkingOptions } from '../getLinkingConfig'; +import type { StoreRedirects } from './types'; + +export type RouterConfig = { + routeNode: RouteNode | null; + linking: ExpoLinkingOptions | undefined; + redirects: StoreRedirects[]; +}; + +export const RouterConfigContext = createContext(null); diff --git a/packages/expo-router/src/global-state/routerRegistry.tsx b/packages/expo-router/src/global-state/routerRegistry.tsx index d86aebacfeb731..10d9007a85d91a 100644 --- a/packages/expo-router/src/global-state/routerRegistry.tsx +++ b/packages/expo-router/src/global-state/routerRegistry.tsx @@ -17,16 +17,6 @@ export type RouterRegistryEntry = { ) => RouterActionResult | null; shouldActionChangeFocus?: (action: NavigationAction) => boolean; getStateForRouteFocus?: (state: NavigationState, routeKey: string) => NavigationState; - shouldPreventRemove?: ( - prev: NavigationState, - next: NavigationState, - action: NavigationAction - ) => boolean; - emitBeforeRemove?: ( - prev: NavigationState, - next: NavigationState, - action: NavigationAction - ) => void; routeNode?: RouteNode; }; diff --git a/packages/expo-router/src/global-state/routingQueue.ts b/packages/expo-router/src/global-state/routingQueue.ts index 0b8aa2650aff4e..c8631bd88defac 100644 --- a/packages/expo-router/src/global-state/routingQueue.ts +++ b/packages/expo-router/src/global-state/routingQueue.ts @@ -20,15 +20,6 @@ interface RoutingIntentMetadata { export type RoutingIntent = | NavigateToHrefIntent - | { - type: 'NAVIGATOR_ACTION'; - payload: { - action: NavigationAction; - dispatchSync: (action: NavigationAction) => void; - }; - metadata?: RoutingIntentMetadata; - onDispatch?: (metadata: RoutingIntentMetadata | undefined) => void; - } | { type: 'ACTION'; payload: { action: NavigationAction; originKey?: string }; diff --git a/packages/expo-router/src/global-state/sort-routes.ts b/packages/expo-router/src/global-state/sort-routes.ts deleted file mode 100644 index d02b8a4c5de84e..00000000000000 --- a/packages/expo-router/src/global-state/sort-routes.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { sortRoutes } from '../Route'; -import type { RouterStore } from './store'; - -export function getSortedRoutes(this: RouterStore) { - if (!this.routeNode) { - throw new Error('No routes found'); - } - - return this.routeNode.children.filter((route) => !route.internal).sort(sortRoutes); -} diff --git a/packages/expo-router/src/global-state/store.ts b/packages/expo-router/src/global-state/store.ts deleted file mode 100644 index 03b779dd29da53..00000000000000 --- a/packages/expo-router/src/global-state/store.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { RouteNode } from '../Route'; -import type { ExpoLinkingOptions } from '../getLinkingConfig'; -import type { NavigationContainerRefWithCurrent } from '../react-navigation/native'; -import * as SplashScreen from '../views/Splash'; -import { defaultRouteInfo, getRouteInfoFromState, type UrlObject } from './getRouteInfoFromState'; -import type { ReactNavigationState, StoreRedirects } from './types'; - -export type RouterStore = typeof store; - -type StoreRef = { - owner?: object; - navigationRef: NavigationContainerRefWithCurrent; - routeNode: RouteNode | null; - state?: ReactNavigationState; - linking?: ExpoLinkingOptions; - redirects?: StoreRedirects[]; -}; - -export const storeRef = { - current: {} as StoreRef, -}; - -export function syncStoreNavigationState(state: ReactNavigationState) { - storeRef.current.state = state; - - if (process.env.NODE_ENV === 'development') { - let isStale: boolean | undefined = false; - let focusedState: ReactNavigationState | undefined = state; - - while (!isStale && focusedState) { - isStale = focusedState.stale; - focusedState = - focusedState.routes?.[ - 'index' in focusedState && typeof focusedState.index === 'number' - ? focusedState.index - : focusedState.routes.length - 1 - ]?.state; - } - if (isStale) { - console.error('Detected stale state. This is likely a bug in Expo Router.'); - } - } -} - -let splashScreenAnimationFrame: number | undefined; -let hasAttemptedToHideSplash = false; - -export function getSplashScreenAnimationFrame() { - return splashScreenAnimationFrame; -} - -export function setSplashScreenAnimationFrame(value: number | undefined) { - splashScreenAnimationFrame = value; -} - -function setHasAttemptedToHideSplash(value: boolean) { - hasAttemptedToHideSplash = value; -} - -export function maybeHideSplashScreen() { - if (!hasAttemptedToHideSplash) { - setHasAttemptedToHideSplash(true); - setSplashScreenAnimationFrame( - requestAnimationFrame(() => { - SplashScreen._internal_maybeHideAsync?.(); - }) - ); - } -} - -let routeInfoState: ReactNavigationState | undefined; -let routeInfo = defaultRouteInfo; - -export const store = { - get state() { - return storeRef.current.state; - }, - get navigationRef() { - return storeRef.current.navigationRef; - }, - // TODO: Rename this to `rootRouteNode`; it represents the root node of the app's route tree. - get routeNode() { - return storeRef.current.routeNode; - }, - getRouteInfo(): UrlObject { - const state = storeRef.current.state; - if (state !== routeInfoState) { - routeInfoState = state; - routeInfo = state ? getRouteInfoFromState(state) : defaultRouteInfo; - } - return routeInfo; - }, - get linking() { - return storeRef.current.linking; - }, - get redirects() { - return storeRef.current.redirects || []; - }, -}; diff --git a/packages/expo-router/src/global-state/storeContext.ts b/packages/expo-router/src/global-state/storeContext.ts deleted file mode 100644 index a01716fee6ca13..00000000000000 --- a/packages/expo-router/src/global-state/storeContext.ts +++ /dev/null @@ -1,19 +0,0 @@ -'use client'; -import { createContext } from 'react'; -import type { ComponentType } from 'react'; - -import type { RouteNode } from '../Route'; -import type { ExpoLinkingOptions } from '../getLinkingConfig'; -import type { NavigationContainerRefWithCurrent } from '../react-navigation/native'; -import type { ReactNavigationState, StoreRedirects } from './types'; - -export type StoreContextValue = { - navigationRef: NavigationContainerRefWithCurrent; - linking: ExpoLinkingOptions | undefined; - state: ReactNavigationState | undefined; - rootComponent: ComponentType; - routeNode: RouteNode | null; - redirects: StoreRedirects[]; -}; - -export const StoreContext = createContext(null); diff --git a/packages/expo-router/src/global-state/useNavigationTreeReducer.ts b/packages/expo-router/src/global-state/useNavigationTreeReducer.ts index f490f843d7c44f..97ab8c479646d6 100644 --- a/packages/expo-router/src/global-state/useNavigationTreeReducer.ts +++ b/packages/expo-router/src/global-state/useNavigationTreeReducer.ts @@ -23,6 +23,7 @@ import type { StoreRedirects } from './types'; type ReducerConfig = { registry: RouterRegistry; + routesWithRemovalPrevented: ReadonlySet; routeNode?: RouteNode; linking?: ExpoLinkingOptions; redirects?: StoreRedirects[]; @@ -39,18 +40,69 @@ type TreeOperation = type: 'NAVIGATOR_CHANGED'; stateKey: string; routerType: string | undefined; + } + | { + type: 'REPORT_CONSUMED'; + eventIds: readonly number[]; }; type Options = { initialState: InitialState | undefined; routeNode?: RouteNode; registry: RouterRegistry; + routesWithRemovalPrevented?: ReadonlySet; linking?: ExpoLinkingOptions; redirects?: StoreRedirects[]; - onStateChangeInsertion?: (state: NavigationState) => void; +}; + +export type NavigationTreeReport = { + events: NavigationTreeReportEvent[]; +}; + +type NavigationTreeReportEventData = + | { + type: 'removed-routes'; + routeKeys: readonly string[]; + action: NavigationAction; + } + | { + type: 'prevented-routes'; + routeKeys: readonly string[]; + action: NavigationAction; + } + | { + type: 'action-dispatched'; + action: NavigationAction; + state: NavigationState; + }; + +export type NavigationTreeReportEvent = NavigationTreeReportEventData & { + id: number; +}; + +type NavigationTreeResult = { + state: NavigationState; + report: NavigationTreeReport | undefined; + eventSeq: number; }; const warnedActions = new WeakSet(); +const ACTIONS_WITHOUT_REMOVAL_PREVENTION = new Set(['ROUTE_NAMES_CHANGED']); + +function warnIfStaleState(state: NavigationState) { + if (process.env.NODE_ENV !== 'development') { + return; + } + + let focusedState: NavigationState | undefined = state; + while (focusedState) { + if (focusedState.stale || focusedState.index === undefined) { + console.error('Detected stale state. This is likely a bug in Expo Router.'); + return; + } + focusedState = focusedState.routes[focusedState.index]?.state as NavigationState | undefined; + } +} function warnUnhandledAction(action: NavigationAction) { if (process.env.NODE_ENV === 'production' || warnedActions.has(action)) { @@ -93,9 +145,11 @@ function warnUnhandledAction(action: NavigationAction) { } function navigationTreeReducer( - state: NavigationState, + result: NavigationTreeResult, { operation, config }: { operation: TreeOperation; config: ReducerConfig } -): NavigationState { +): NavigationTreeResult { + const state = result.state; + switch (operation.type) { case 'NAVIGATE_TO_HREF': { const { href, options } = operation.payload; @@ -118,7 +172,7 @@ function navigationTreeReducer( console.warn( `An error occurred when trying to handle navigation action ${JSON.stringify(operation)}: ${message}` ); - return state; + return result; } if (resolution.status === 'invalid') { const invalidHref = operation.payload.originalHref ?? resolution.href; @@ -126,9 +180,9 @@ function navigationTreeReducer( console.warn( `Could not generate a valid navigation state for the given path: ${invalidHref}` ); - return state; + return result; } - return navigationTreeReducer(state, { + return navigationTreeReducer(result, { operation: { type: 'ACTION', payload: { action: resolution.action } }, config, }); @@ -145,29 +199,74 @@ function navigationTreeReducer( // TODO(@ubax): move console side effects out of the reducer and restore `onUnhandledAction`. // https://linear.app/expo/issue/ENG-26123 warnUnhandledAction(operation.payload.action); - return state; + return result; } - const result = reduceNavigationTree(operation.payload.action, config.registry, { + const reduction = reduceNavigationTree(operation.payload.action, config.registry, { origin, tree, }); - if (!result.handled) { + if (!reduction.handled) { // TODO(@ubax): move console side effects out of the reducer and restore `onUnhandledAction`. // https://linear.app/expo/issue/ENG-26123 warnUnhandledAction(operation.payload.action); - return state; + return result; } const nextState = config.routeNode - ? completeNavigationState(result.nextState, config.routeNode) - : result.nextState; - return nextState === state ? state : deepFreeze(nextState); + ? completeNavigationState(reduction.nextState, config.routeNode) + : reduction.nextState; + if (nextState === state) { + return result; + } + + const removedRoutes = getRemovedRouteKeys(state, nextState); + const preventedRoutes = ACTIONS_WITHOUT_REMOVAL_PREVENTION.has(operation.payload.action.type) + ? [] + : removedRoutes.filter((routeKey) => config.routesWithRemovalPrevented.has(routeKey)); + const committedState = preventedRoutes.length > 0 ? state : deepFreeze(nextState); + // TODO(@ubax): add dev-only diagnostics to events for dev-tools. + const eventsWithoutIds: NavigationTreeReportEventData[] = + preventedRoutes.length > 0 + ? [ + { + type: 'prevented-routes', + routeKeys: preventedRoutes, + action: operation.payload.action, + }, + ] + : [ + ...(removedRoutes.length > 0 + ? ([ + { + type: 'removed-routes', + routeKeys: removedRoutes, + action: operation.payload.action, + }, + ] satisfies NavigationTreeReportEventData[]) + : []), + { + type: 'action-dispatched', + action: operation.payload.action, + state: committedState, + }, + ]; + const events: NavigationTreeReportEvent[] = eventsWithoutIds.map((event, index) => ({ + ...event, + id: result.eventSeq + index, + })); + const report: NavigationTreeReport = { + events: result.report ? [...result.report.events, ...events] : events, + }; + + return { + state: committedState, + report, + eventSeq: result.eventSeq + events.length, + }; } - case 'NAVIGATOR_ACTION': - throw new Error('NAVIGATOR_ACTION must be dispatched through its navigator.'); case 'NAVIGATOR_UNMOUNTED': { if (!findStateByKey(state, operation.stateKey)) { - return state; + return result; } const replacement = createSeededNavigationState( undefined, @@ -178,19 +277,30 @@ function navigationTreeReducer( const completeState = config.routeNode ? completeNavigationState(nextState, config.routeNode) : nextState; - return deepFreeze(completeState); + return { ...result, state: deepFreeze(completeState) }; } case 'NAVIGATOR_CHANGED': { const navigatorState = findStateByKey(state, operation.stateKey); if (!navigatorState) { - return state; + return result; } const replacement = resetNavigatorState(navigatorState, operation.routerType); const nextState = replaceNavigationState(state, operation.stateKey, replacement); const completeState = config.routeNode ? completeNavigationState(nextState, config.routeNode) : nextState; - return deepFreeze(completeState); + return { ...result, state: deepFreeze(completeState) }; + } + case 'REPORT_CONSUMED': { + if (!result.report) { + return result; + } + const consumedIds = new Set(operation.eventIds); + const events = result.report.events.filter((event) => !consumedIds.has(event.id)); + if (events.length === result.report.events.length) { + return result; + } + return { ...result, report: events.length > 0 ? { events } : undefined }; } } } @@ -199,34 +309,39 @@ export function useNavigationTreeReducer({ initialState, routeNode, registry, + routesWithRemovalPrevented = EMPTY_SET, linking, redirects, - onStateChangeInsertion, }: Options) { - const [state, reactDispatch] = React.useReducer( + const [result, reactDispatch] = React.useReducer( navigationTreeReducer, initialState, - (value): NavigationState => { - validateInitialState(value == null ? undefined : value); + (value): NavigationTreeResult => { + validateInitialState(value); if (value == null) { throw new Error( 'The navigation container is missing its initial state. Expo Router always seeds a complete initial state before rendering the navigation container, so this is most likely a bug in expo-router. Please report it at https://github.com/expo/expo/issues.' ); } - // Validation above proves the recursively partial public type is complete. - return deepFreeze(value as NavigationState); + // TODO(@ubax): check if deepFreeze is needed here. + return { state: deepFreeze(value), report: undefined, eventSeq: 0 }; } ); - const config = React.useMemo( - () => ({ registry, routeNode, linking, redirects }), - [registry, routeNode, linking, redirects] - ); - const stateRef = React.useRef(state); const previousRegistryRef = React.useRef(registry); const processAction = React.useCallback( - (operation: TreeOperation) => reactDispatch({ operation, config }), - [config] + (operation: TreeOperation) => + reactDispatch({ + operation, + config: { + registry, + routesWithRemovalPrevented, + routeNode, + linking, + redirects, + }, + }), + [linking, redirects, registry, routeNode, routesWithRemovalPrevented] ); const process = React.useEffectEvent(processAction); const processIntent = React.useCallback( @@ -244,43 +359,80 @@ export function useNavigationTreeReducer({ ? payload.params : undefined; warnIfScreenParam(params); - process({ type: 'ACTION', payload: { action, originKey } }); + processAction({ type: 'ACTION', payload: { action, originKey } }); }); - const getState = React.useCallback(() => stateRef.current, []); - const getStateForKey = React.useCallback( - (key: string) => findStateByKey(stateRef.current, key), - [] - ); const resetNavigator = useLatestCallback((stateKey: string, routerType: string | undefined) => { - process({ type: 'NAVIGATOR_CHANGED', stateKey, routerType }); + processAction({ type: 'NAVIGATOR_CHANGED', stateKey, routerType }); + }); + const consumeReportEvents = useLatestCallback((eventIds: readonly number[]) => { + processAction({ type: 'REPORT_CONSUMED', eventIds }); }); React.useInsertionEffect(() => { - stateRef.current = state; - onStateChangeInsertion?.(state); - }, [onStateChangeInsertion, state]); + warnIfStaleState(result.state); + }, [result.state]); useClientLayoutEffect(() => { const previousRegistry = previousRegistryRef.current; previousRegistryRef.current = registry; for (const [stateKey, entry] of previousRegistry) { if (!registry.has(stateKey) && entry.routeNode) { - process({ type: 'NAVIGATOR_UNMOUNTED', stateKey, routeNode: entry.routeNode }); + process({ + type: 'NAVIGATOR_UNMOUNTED', + stateKey, + routeNode: entry.routeNode, + }); } } }, [registry]); return { - state, - getState, - getStateForKey, + state: result.state, + report: result.report, + consumeReportEvents, resetNavigator, handleAction, processIntent, }; } -function validateInitialState(state: InitialState | undefined): void { +const EMPTY_SET: ReadonlySet = new Set(); + +function getRemovedRouteKeys(current: NavigationState, next: NavigationState): string[] { + const nextRouteKeys = new Set(); + visitRoutes(next, true, (routeKey) => nextRouteKeys.add(routeKey)); + + const removedRoutes: string[] = []; + visitRoutes(current, true, (routeKey) => { + if (!nextRouteKeys.has(routeKey)) { + removedRoutes.push(routeKey); + } + }); + return removedRoutes; +} + +function visitRoutes( + state: NavigationState, + excludePreloaded: boolean, + visit: (routeKey: string) => void +) { + // TODO(@ubax): find a universal way to exclude preloaded routes. + const routes = + excludePreloaded && state.type === 'stack' + ? state.routes.slice(0, state.index + 1) + : state.routes; + for (let index = routes.length - 1; index >= 0; index--) { + const route = routes[index]!; + if (route.state?.stale === false) { + visitRoutes(route.state, excludePreloaded, visit); + } + visit(route.key); + } +} + +function validateInitialState( + state: InitialState | undefined +): asserts state is NavigationState | undefined { if (state === undefined) { return; } diff --git a/packages/expo-router/src/global-state/useNavigationTreeReportEvents.ts b/packages/expo-router/src/global-state/useNavigationTreeReportEvents.ts new file mode 100644 index 00000000000000..d5ff01dde0b242 --- /dev/null +++ b/packages/expo-router/src/global-state/useNavigationTreeReportEvents.ts @@ -0,0 +1,69 @@ +'use client'; + +import * as React from 'react'; + +import { unstable_navigationEvents } from '../navigationEvents'; +import { useClientLayoutEffect } from '../react-navigation/core/useClientLayoutEffect'; +import { GlobalRemovalEventEmitterRegistryContext } from './removalPrevention'; +import type { NavigationTreeReport } from './useNavigationTreeReducer'; + +export function useNavigationTreeReportEvents( + report: NavigationTreeReport | undefined, + consumeReportEvents: (eventIds: readonly number[]) => void +) { + const emitterRegistry = React.use(GlobalRemovalEventEmitterRegistryContext)!; + const consumedIds = React.useRef(new Set()); + + useClientLayoutEffect(() => { + const reportIds = new Set(report?.events.map((event) => event.id)); + for (const id of consumedIds.current) { + if (!reportIds.has(id)) { + consumedIds.current.delete(id); + } + } + if (report === undefined) { + return; + } + + const ids: number[] = []; + for (const event of report.events) { + if (consumedIds.current.has(event.id)) { + continue; + } + consumedIds.current.add(event.id); + ids.push(event.id); + // A listener that throws must not stop the remaining events from being delivered. + try { + switch (event.type) { + case 'prevented-routes': + for (const routeKey of event.routeKeys) { + emitterRegistry.emitRemovalEvent(routeKey, 'removePrevented', event.action); + } + break; + case 'removed-routes': + for (const routeKey of event.routeKeys) { + emitterRegistry.emitRemovalEvent(routeKey, 'removed', event.action); + } + break; + case 'action-dispatched': + // TODO(@ubax): emit an event when the action is enqueued. + unstable_navigationEvents.emit('actionDispatched', { + actionType: event.action.type, + payload: event.action.payload, + state: event.state, + }); + break; + } + } catch (error) { + const message = + typeof error === 'object' && error != null && 'message' in error ? error.message : error; + console.warn( + `An error occurred in a navigation event listener while handling ${event.type}: ${message}` + ); + } + } + if (ids.length > 0) { + consumeReportEvents(ids); + } + }, [consumeReportEvents, emitterRegistry, report]); +} diff --git a/packages/expo-router/src/global-state/useStore.ts b/packages/expo-router/src/global-state/useStore.ts index 9395edda9ff17a..35275e6e69892f 100644 --- a/packages/expo-router/src/global-state/useStore.ts +++ b/packages/expo-router/src/global-state/useStore.ts @@ -2,31 +2,27 @@ import Constants from 'expo-constants'; import type { ComponentType } from 'react'; -import { Fragment, useEffect, useMemo, useState } from 'react'; +import { Fragment, useEffect, useMemo } from 'react'; import { Platform } from 'react-native'; -import type { RouteNode } from '../Route'; -import { extractExpoPathFromURL } from '../fork/extractPathFromURL'; import { routePatternToRegex } from '../fork/getStateFromPath-forks'; import type { ExpoLinkingOptions, LinkingConfigOptions } from '../getLinkingConfig'; import { getLinkingConfig } from '../getLinkingConfig'; import { parseRouteSegments } from '../getReactNavigationConfig'; import { getRoutes } from '../getRoutes'; -import { type NavigationState, useNavigationContainerRef } from '../react-navigation/native'; import type { RequireContext } from '../types'; import { getQualifiedRouteComponent } from '../useScreens'; +import { cancelSplashScreenAnimationFrame } from '../utils/splash'; import { shouldLinkExternally } from '../utils/url'; -import { createSeededRootState } from './createSeededNavigationState'; -import { storeRef, getSplashScreenAnimationFrame, setSplashScreenAnimationFrame } from './store'; -import type { StoreContextValue } from './storeContext'; +import type { RouterConfig } from './routerConfigContext'; import type { StoreRedirects } from './types'; -export function useStore( +// TODO(@ubax): rename this file to useRouterConfig.ts +export function useRouterConfig( context: RequireContext, linkingConfigOptions: LinkingConfigOptions, serverUrl?: string -): StoreContextValue { - const navigationRef = useNavigationContainerRef(); +): { routerConfig: RouterConfig; rootComponent: ComponentType } { const config = Constants.expoConfig?.extra?.router; const configValue = useMemo(() => { let linking: ExpoLinkingOptions | undefined; @@ -71,72 +67,12 @@ export function useStore( rootComponent = Fragment; } - return { linking, rootComponent, redirects, routeNode }; + return { routerConfig: { linking, redirects, routeNode }, rootComponent }; }, [config, context, linkingConfigOptions, serverUrl]); - const { linking, rootComponent, redirects, routeNode } = configValue; - - // One object per mount: identity marks store ownership, and state is seeded once from the URL - // (or left undefined when the URL is asynchronous). - const [owner] = useState(() => ({ state: seedInitialState(linking, routeNode) })); - const isFirstRender = storeRef.current.owner !== owner; - const state = isFirstRender ? owner.state : storeRef.current.state; - - // TODO(@ubax): move ownership to commit/teardown so concurrent roots cannot clobber this ref. - // https://linear.app/expo/issue/ENG-26124 - storeRef.current = { - owner, - navigationRef, - routeNode, - linking, - redirects, - state, - }; - - const storeValue = useMemo( - () => ({ - navigationRef, - linking, - get state() { - return storeRef.current.state; - }, - rootComponent, - redirects, - routeNode, - }), - [navigationRef, linking, rootComponent, redirects, routeNode] - ); - useEffect(() => { - return () => { - const animationFrame = getSplashScreenAnimationFrame(); - if (animationFrame) { - cancelAnimationFrame(animationFrame); - setSplashScreenAnimationFrame(undefined); - } - }; - }); - - return storeValue; -} - -function seedInitialState( - linking: ExpoLinkingOptions | undefined, - routeNode: RouteNode | null -): NavigationState | undefined { - // Static rendering only gets one pass, so synchronously available URLs are seeded immediately. - if (!linking || !routeNode) { - return undefined; - } - - const initialURL = linking.getInitialURL?.(); - if (typeof initialURL !== 'string') { - return undefined; - } - - let initialPath = extractExpoPathFromURL(linking.prefixes, initialURL); - // It does not matter if the path starts with a `/`, but this keeps parsing consistent. - if (!initialPath.startsWith('/')) initialPath = '/' + initialPath; + return cancelSplashScreenAnimationFrame; + }, []); - return createSeededRootState(linking.getStateFromPath!(initialPath, linking.config), routeNode); + return configValue; } diff --git a/packages/expo-router/src/hooks/__tests__/useRootNavigation.test.ios.tsx b/packages/expo-router/src/hooks/__tests__/useRootNavigation.test.ios.tsx new file mode 100644 index 00000000000000..df0f958894c98c --- /dev/null +++ b/packages/expo-router/src/hooks/__tests__/useRootNavigation.test.ios.tsx @@ -0,0 +1,30 @@ +import { act } from '@testing-library/react-native'; +import { useState } from 'react'; + +import { useRootNavigation } from '../useRootNavigation'; +import { renderHook } from './renderHook'; + +let error: jest.SpyInstance; + +beforeEach(() => { + error = jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + error.mockRestore(); +}); + +it('returns the navigation container for its own router root', () => { + let rerenderFirstRoot: () => void = () => {}; + const first = renderHook(() => { + const [, setRenderCount] = useState(0); + rerenderFirstRoot = () => setRenderCount((count) => count + 1); + return useRootNavigation(); + }); + const second = renderHook(() => useRootNavigation()); + + act(rerenderFirstRoot); + + expect(first.result.current).not.toBeNull(); + expect(first.result.current).not.toBe(second.result.current); +}); diff --git a/packages/expo-router/src/hooks/__tests__/useRootNavigationState.test.ios.tsx b/packages/expo-router/src/hooks/__tests__/useRootNavigationState.test.ios.tsx index dae33cde49321b..9cf217cadb7a1a 100644 --- a/packages/expo-router/src/hooks/__tests__/useRootNavigationState.test.ios.tsx +++ b/packages/expo-router/src/hooks/__tests__/useRootNavigationState.test.ios.tsx @@ -1,5 +1,7 @@ +import { act, renderHook as renderNativeHook } from '@testing-library/react-native'; import { Text } from 'react-native'; +import { router } from '../../imperative-api'; import Stack from '../../layouts/Stack'; import Tabs from '../../layouts/Tabs'; import { renderRouter } from '../../testing-library'; @@ -7,6 +9,32 @@ import { useRootNavigationState } from '../useRootNavigationState'; import { renderHook } from './renderHook'; describe(useRootNavigationState, () => { + it('throws outside a navigation container', () => { + expect(() => renderNativeHook(() => useRootNavigationState())).toThrow( + 'useRootNavigationState was called from a generated route. This is likely a bug in Expo Router.' + ); + }); + + it('returns the updated root state after navigation', () => { + const states: ReturnType[] = []; + + renderRouter({ + _layout: () => , + index: function Index() { + states.push(useRootNavigationState()); + return Index; + }, + second: () => Second, + }); + + const initialState = states[states.length - 1]; + + act(() => router.push('/second')); + + expect(states[states.length - 1]).not.toBe(initialState); + expect(states[states.length - 1]?.routes[0]?.state?.routes.at(-1)?.name).toBe('second'); + }); + it('returns the root navigation state', () => { const { result } = renderHook(() => useRootNavigationState(), ['index'], { initialUrl: '/?test=1&test=2', diff --git a/packages/expo-router/src/hooks/useNavigationContainerRef.ts b/packages/expo-router/src/hooks/useNavigationContainerRef.ts index 166aa636d7a09c..87ad6629057d7b 100644 --- a/packages/expo-router/src/hooks/useNavigationContainerRef.ts +++ b/packages/expo-router/src/hooks/useNavigationContainerRef.ts @@ -1,11 +1,12 @@ 'use client'; -import { store } from '../global-state/store'; +import { navigationRef } from '../global-state/navigationRef'; /** * @return The root `` ref for the app. The `ref.current` may be `null` * if the `` hasn't mounted yet. */ export function useNavigationContainerRef() { - return store.navigationRef; + // TODO(@ubax): migrate this to NavigationContainerRefContext without changing the public return type + return navigationRef; } diff --git a/packages/expo-router/src/hooks/useRootNavigation.ts b/packages/expo-router/src/hooks/useRootNavigation.ts index cd95f108f84e9a..7ddb8075841e7e 100644 --- a/packages/expo-router/src/hooks/useRootNavigation.ts +++ b/packages/expo-router/src/hooks/useRootNavigation.ts @@ -1,11 +1,13 @@ 'use client'; -import { store } from '../global-state/store'; +import { use } from 'react'; + +import { NavigationContainerRefContext } from '../react-navigation/native'; /** * @deprecated Use [`useNavigationContainerRef`](#usenavigationcontainerref) instead, * which returns a React `ref`. */ export function useRootNavigation() { - return store.navigationRef.current; + return use(NavigationContainerRefContext) ?? null; } diff --git a/packages/expo-router/src/hooks/useRootNavigationState.ts b/packages/expo-router/src/hooks/useRootNavigationState.ts index b7036cf23c2229..9540f285dd8beb 100644 --- a/packages/expo-router/src/hooks/useRootNavigationState.ts +++ b/packages/expo-router/src/hooks/useRootNavigationState.ts @@ -1,8 +1,9 @@ 'use client'; -import { INTERNAL_SLOT_NAME } from '../constants'; -import type { NavigationProp, NavigationState } from '../react-navigation/native'; -import { useNavigation } from '../react-navigation/native'; +import { use } from 'react'; + +import { RootNavigationStateContext } from '../react-navigation/core/RootNavigationStateContext'; +import type { NavigationState } from '../react-navigation/native'; /** * Returns the navigation state of the root navigator — the top-level navigator that @@ -25,14 +26,11 @@ import { useNavigation } from '../react-navigation/native'; * reference for the shape of the returned object. */ export function useRootNavigationState(): NavigationState { - const parent = - // We assume that this is called from routes in __root - // Users cannot customize the generated Sitemap or NotFound routes, so we should be safe - useNavigation>().getParent(INTERNAL_SLOT_NAME); - if (!parent) { + const state = use(RootNavigationStateContext); + if (state === undefined) { throw new Error( 'useRootNavigationState was called from a generated route. This is likely a bug in Expo Router.' ); } - return parent.getState(); + return state; } diff --git a/packages/expo-router/src/layouts/__tests__/StackClient.test.web.tsx b/packages/expo-router/src/layouts/__tests__/StackClient.test.web.tsx index d3f7876d5f50c8..4f0e12b18ed1ff 100644 --- a/packages/expo-router/src/layouts/__tests__/StackClient.test.web.tsx +++ b/packages/expo-router/src/layouts/__tests__/StackClient.test.web.tsx @@ -3,7 +3,8 @@ import { act, render, screen } from '@testing-library/react'; import { View } from 'react-native'; import { ExpoRoot } from '../../ExpoRoot'; -import { store } from '../../global-state/router-store'; +import { getRouteInfoFromState } from '../../global-state/getRouteInfoFromState'; +import { navigationRef } from '../../global-state/navigationRef'; import { router } from '../../imperative-api'; import type { NativeStackHeaderProps } from '../../react-navigation/native-stack'; import { getMockContext } from '../../testing-library/mock-config'; @@ -43,11 +44,11 @@ describe('StackClient on web', () => { act(() => router.push('/second')); expect(screen.getByTestId('second')).toBeTruthy(); - expect(store.getRouteInfo().pathname).toBe('/second'); + expect(getRouteInfoFromState(navigationRef.getRootState()).pathname).toBe('/second'); expect(backHref).toBe('/'); act(() => router.back()); expect(screen.getByTestId('index')).toBeTruthy(); - expect(store.getRouteInfo().pathname).toBe('/'); + expect(getRouteInfoFromState(navigationRef.getRootState()).pathname).toBe('/'); }); }); diff --git a/packages/expo-router/src/layouts/experimental-stack/ExperimentalStackView.tsx b/packages/expo-router/src/layouts/experimental-stack/ExperimentalStackView.tsx index 57801386fef5c5..6b3647b7847acf 100644 --- a/packages/expo-router/src/layouts/experimental-stack/ExperimentalStackView.tsx +++ b/packages/expo-router/src/layouts/experimental-stack/ExperimentalStackView.tsx @@ -3,11 +3,14 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import { Stack as ScreensStackV5 } from 'react-native-screens/experimental'; +import { + isRouteRemovalPrevented, + useRoutesWithRemovalPrevented, +} from '../../global-state/removalPrevention'; import { type ParamListBase, StackActions, type StackNavigationState, - usePreventRemoveContext, } from '../../react-navigation/native'; import { useDismissedRouteError } from '../../react-navigation/native-stack/utils/useDismissedRouteError'; import type { @@ -32,7 +35,7 @@ type Props = { export function ExperimentalStackView({ state, navigation, descriptors }: Props) { const { setNextDismissedKey } = useDismissedRouteError(state); - const { preventedRoutes } = usePreventRemoveContext(); + const routesWithRemovalPrevented = useRoutesWithRemovalPrevented(); return ( @@ -41,7 +44,7 @@ export function ExperimentalStackView({ state, navigation, descriptors }: Props) const descriptor = descriptors[route.key]!; const isPreloaded = index > state.index; const options = (descriptor.options ?? {}) as ExperimentalStackNavigationOptions; - const preventFromContext = preventedRoutes[route.key]?.preventRemove ?? false; + const preventFromContext = isRouteRemovalPrevented(route, routesWithRemovalPrevented); return ( getStateForHref(href, { segments: routeSegments }, linking), [href, routeSegments, linking] @@ -37,7 +39,7 @@ export function HrefPreview({ href }: { href: Href }) { let isProtected = false; if (hrefState?.routes[index]?.name === INTERNAL_SLOT_NAME) { let routerState: typeof hrefState | undefined = hrefState; - let rnState = store.state; + let rnState: ReactNavigationState | undefined = rootNavigationState; while (routerState && rnState) { const routerRoute: ResultState['routes'][number] = routerState.routes[0]!; // When the route we want to show is not present in react-navigation state diff --git a/packages/expo-router/src/link/preview/__tests__/utils.test.ios.tsx b/packages/expo-router/src/link/preview/__tests__/utils.test.ios.tsx index f659f9d0338e28..cf9c7cb6551a0e 100644 --- a/packages/expo-router/src/link/preview/__tests__/utils.test.ios.tsx +++ b/packages/expo-router/src/link/preview/__tests__/utils.test.ios.tsx @@ -1,9 +1,12 @@ -import { store } from '../../../global-state/router-store'; +import { getLinkingConfig } from '../../../getLinkingConfig'; +import { getRoutes } from '../../../getRoutes'; +import { getRouteInfoFromState } from '../../../global-state/getRouteInfoFromState'; +import { navigationRef } from '../../../global-state/navigationRef'; import { Stack } from '../../../layouts/Stack'; import { NativeTabs } from '../../../native-tabs/index'; import { INTERNAL_EXPO_ROUTER_IS_PREVIEW_NAVIGATION_PARAM_NAME } from '../../../navigationParams'; import type { NavigationState } from '../../../react-navigation/native'; -import { renderRouter } from '../../../testing-library'; +import { getMockContext, renderRouter } from '../../../testing-library'; import { deepEqual, getPreloadedRouteFromRootStateByHref, @@ -29,6 +32,40 @@ afterAll(() => { console.info = originalConsoleInfo; }); +const routes = { + _layout: () => ( + + + + + + ), + index: () => null, + 'faces/_layout': () => , + 'faces/index': () => null, + 'faces/[face]': () => null, + 'explore/_layout': () => , + 'explore/index': () => null, + 'explore/news/_layout': () => , + 'explore/news/index': () => null, + 'explore/news/[title]': () => null, +}; +const context = getMockContext(routes); +const routeNode = getRoutes(context, { + ignoreEntryPoints: true, + platform: 'ios', + preserveRedirectAndRewrites: true, + skipGenerated: true, +})!; +const linking = getLinkingConfig(routeNode, context, { + metaOnly: false, + redirects: [], + skipGenerated: false, + sitemap: true, + notFound: true, +}); +const getRouteInfo = () => getRouteInfoFromState(navigationRef.getRootState()); + describe('deepEqual', () => { it('returns true for same object reference', () => { const obj = { a: 1 }; @@ -94,24 +131,7 @@ describe('deepEqual', () => { describe(getTabPathFromRootStateByHref, () => { beforeEach(() => { - renderRouter({ - _layout: () => ( - - - - - - ), - index: () => null, - 'faces/_layout': () => , - 'faces/index': () => null, - 'faces/[face]': () => null, - 'explore/_layout': () => , - 'explore/index': () => null, - 'explore/news/_layout': () => , - 'explore/news/index': () => null, - 'explore/news/[title]': () => null, - }); + renderRouter(routes); }); it('returns single tab path with one tab navigator in href, but without change', () => { @@ -183,8 +203,8 @@ describe(getTabPathFromRootStateByHref, () => { const tabPath = getTabPathFromRootStateByHref( href, state as NavigationState, - store.getRouteInfo(), - store.linking + getRouteInfo(), + linking ); expect(tabPath).toEqual([ { @@ -263,8 +283,8 @@ describe(getTabPathFromRootStateByHref, () => { const tabPath = getTabPathFromRootStateByHref( href, state as NavigationState, - store.getRouteInfo(), - store.linking + getRouteInfo(), + linking ); expect(tabPath).toEqual([ { @@ -279,24 +299,7 @@ describe(getPreloadedRouteFromRootStateByHref, () => { let getStateForHref: jest.SpyInstance | undefined; beforeEach(() => { - renderRouter({ - _layout: () => ( - - - - - - ), - index: () => null, - 'faces/_layout': () => , - 'faces/index': () => null, - 'faces/[face]': () => null, - 'explore/_layout': () => , - 'explore/index': () => null, - 'explore/news/_layout': () => , - 'explore/news/index': () => null, - 'explore/news/[title]': () => null, - }); + renderRouter(routes); }); afterEach(() => { @@ -374,8 +377,8 @@ describe(getPreloadedRouteFromRootStateByHref, () => { href, // The inline fixture is a complete navigation state despite widened string literals. state as NavigationState, - store.getRouteInfo(), - store.linking + getRouteInfo(), + linking ); expect(preloadedRoute).toEqual({ key: '[face]-9rms2gdsibY9dVYUGCpZG', @@ -456,8 +459,8 @@ describe(getPreloadedRouteFromRootStateByHref, () => { href, // The inline fixture is a complete navigation state despite widened string literals. state as NavigationState, - store.getRouteInfo(), - store.linking + getRouteInfo(), + linking ); expect(preloadedRoute).toEqual({ key: '[face]-MZ5nYkDCFxwNv1BcD5exf', @@ -469,7 +472,7 @@ describe(getPreloadedRouteFromRootStateByHref, () => { }); it('matches the preloaded route by nested state shape', () => { - getStateForHref = jest.spyOn(store.linking!, 'getStateFromPath').mockReturnValue({ + getStateForHref = jest.spyOn(linking, 'getStateFromPath').mockReturnValue({ routes: [ { name: 'details', @@ -558,13 +561,13 @@ describe(getPreloadedRouteFromRootStateByHref, () => { type: 'stack' as const, }; - expect( - getPreloadedRouteFromRootStateByHref('/details', state, store.getRouteInfo(), store.linking) - ).toBe(matchingRoute); + expect(getPreloadedRouteFromRootStateByHref('/details', state, getRouteInfo(), linking)).toBe( + matchingRoute + ); }); it('does not match a preloaded route from a different branch', () => { - getStateForHref = jest.spyOn(store.linking!, 'getStateFromPath').mockReturnValue({ + getStateForHref = jest.spyOn(linking, 'getStateFromPath').mockReturnValue({ routes: [ { name: 'target', @@ -602,12 +605,7 @@ describe(getPreloadedRouteFromRootStateByHref, () => { }; expect( - getPreloadedRouteFromRootStateByHref( - '/target/child', - state, - store.getRouteInfo(), - store.linking - ) + getPreloadedRouteFromRootStateByHref('/target/child', state, getRouteInfo(), linking) ).toBeUndefined(); }); }); diff --git a/packages/expo-router/src/link/preview/useNextScreenId.ts b/packages/expo-router/src/link/preview/useNextScreenId.ts index 04e1ee98bf0324..f41ae27bd79d3c 100644 --- a/packages/expo-router/src/link/preview/useNextScreenId.ts +++ b/packages/expo-router/src/link/preview/useNextScreenId.ts @@ -1,22 +1,24 @@ import { use, useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'; -import type { ReactNavigationState } from '../../global-state/router-store'; -import { StoreContext } from '../../global-state/storeContext'; +import { RouterConfigContext } from '../../global-state/routerConfigContext'; +import type { ReactNavigationState } from '../../global-state/types'; import { useRouteInfo } from '../../global-state/useRouteInfo'; import { useRouter } from '../../hooks'; +import { NavigationContainerRefContext } from '../../react-navigation/native'; import type { Href } from '../../types'; import { useLinkPreviewContext } from './LinkPreviewContext'; import type { TabPath } from './native'; import { getPreloadedRouteFromRootStateByHref, getTabPathFromRootStateByHref } from './utils'; +// TODO(@ubax): Check if this can be migrated away from state listener export function useNextScreenId(): [ { nextScreenId: string | undefined; tabPath: TabPath[] }, (href: Href) => void, ] { const router = useRouter(); const routeInfo = useRouteInfo(); - const store = use(StoreContext); - const navigationRef = store?.navigationRef; + const routerConfig = use(RouterConfigContext); + const navigation = use(NavigationContainerRefContext); const { setOpenPreviewKey } = useLinkPreviewContext(); const [internalNextScreenId, internalSetNextScreenId] = useState(); const currentHref = useRef(undefined); @@ -30,14 +32,14 @@ export function useNextScreenId(): [ currentHref.current, state, routeInfo, - store?.linking + routerConfig?.linking ); const routeKey = preloadedRoute?.key; const tabPathFromRootState = getTabPathFromRootStateByHref( currentHref.current, state, routeInfo, - store?.linking + routerConfig?.linking ); // Without this timeout react-native does not have enough time to mount the new screen // and thus it will not be found on the native side @@ -57,8 +59,8 @@ export function useNextScreenId(): [ useEffect(() => { // When screen is prefetched, then the root state is updated with the preloaded route. - return navigationRef?.addListener('state', onNavigationStateChange); - }, [navigationRef]); + return navigation?.addListener('state', onNavigationStateChange); + }, [navigation]); const prefetch = useCallback( (href: Href): void => { diff --git a/packages/expo-router/src/link/preview/utils.ts b/packages/expo-router/src/link/preview/utils.ts index 4d74165e1a440b..b8367c0524fe0f 100644 --- a/packages/expo-router/src/link/preview/utils.ts +++ b/packages/expo-router/src/link/preview/utils.ts @@ -1,7 +1,7 @@ import type { ExpoLinkingOptions } from '../../getLinkingConfig'; import type { UrlObject } from '../../global-state/getRouteInfoFromState'; -import type { ReactNavigationState } from '../../global-state/router-store'; import { findDivergentState } from '../../global-state/routing'; +import type { ReactNavigationState } from '../../global-state/types'; import { removeInternalExpoRouterParams } from '../../navigationParams'; import type { ParamListBase, diff --git a/packages/expo-router/src/link/useLoadedNavigation.ts b/packages/expo-router/src/link/useLoadedNavigation.ts index a535c8b115ced2..7604cee36f1b03 100644 --- a/packages/expo-router/src/link/useLoadedNavigation.ts +++ b/packages/expo-router/src/link/useLoadedNavigation.ts @@ -1,8 +1,11 @@ -import { useCallback, useState, useEffect, useRef } from 'react'; +import { use, useCallback, useState, useEffect, useRef } from 'react'; -import { store } from '../global-state/store'; -import type { NavigationProp, NavigationState } from '../react-navigation/native'; -import { useNavigation } from '../react-navigation/native'; +import { + NavigationContainerRefContext, + type NavigationProp, + type NavigationState, + useNavigation, +} from '../react-navigation/native'; type GenericNavigation = NavigationProp & { getState(): NavigationState | undefined; @@ -11,6 +14,7 @@ type GenericNavigation = NavigationProp & { /** Returns a callback which is invoked when the navigation state has loaded. */ export function useLoadedNavigation() { const navigation = useNavigation(); + const rootNavigation = use(NavigationContainerRefContext); const isMounted = useRef(true); const pending = useRef<((navigation: GenericNavigation) => void)[]>([]); @@ -32,19 +36,19 @@ export function useLoadedNavigation() { }, [navigation]); useEffect(() => { - if (store.navigationRef.current) { + if (rootNavigation) { flush(); } - }, [flush]); + }, [flush, rootNavigation]); const push = useCallback( (fn: (navigation: GenericNavigation) => void) => { pending.current.push(fn); - if (store.navigationRef.current) { + if (rootNavigation) { flush(); } }, - [flush] + [flush, rootNavigation] ); return push; diff --git a/packages/expo-router/src/link/zoom/usePreventZoomTransitionDismissal.ios.tsx b/packages/expo-router/src/link/zoom/usePreventZoomTransitionDismissal.ios.tsx index 0b106ff8561185..ebf8f59a189037 100644 --- a/packages/expo-router/src/link/zoom/usePreventZoomTransitionDismissal.ios.tsx +++ b/packages/expo-router/src/link/zoom/usePreventZoomTransitionDismissal.ios.tsx @@ -4,6 +4,7 @@ import { use } from 'react'; import { DescriptorsContext } from '../../fork/native-stack/descriptors-context'; import { INTERNAL_EXPO_ROUTER_GESTURE_ENABLED_OPTION_NAME } from '../../navigationParams'; +import { NavigatorStateContext } from '../../react-navigation/core/useNavigationState'; import { useRoute } from '../../react-navigation/native'; import { useNavigation } from '../../useNavigation'; import { isRoutePreloadedInStack } from '../../utils/stack'; @@ -19,9 +20,10 @@ export function usePreventZoomTransitionDismissal( const context = use(ZoomTransitionTargetContext); const route = useRoute(); const navigation = useNavigation(); + const navigatorState = use(NavigatorStateContext); const isPreview = useIsPreview(); const isFocused = navigation.isFocused(); - const isPreloaded = isPreview ? false : isRoutePreloadedInStack(navigation.getState(), route); + const isPreloaded = isPreview ? false : isRoutePreloadedInStack(navigatorState, route); const descriptorsMap = use(DescriptorsContext); const currentDescriptor = descriptorsMap[route.key]; diff --git a/packages/expo-router/src/navigationEvents/navigation.ts b/packages/expo-router/src/navigationEvents/navigation.ts deleted file mode 100644 index 118499987453c7..00000000000000 --- a/packages/expo-router/src/navigationEvents/navigation.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { emit } from '.'; -import { storeRef } from '../global-state/store'; - -// TODO(@ubax): replace this singleton reader when store ownership has commit/teardown semantics. -// https://linear.app/expo/issue/ENG-26124 - -let unsubscribe: (() => void) | undefined; - -export function handleNavigationOnReady() { - if (unsubscribe) unsubscribe(); - unsubscribe = storeRef.current.navigationRef.addListener('__unsafe_action__', (e) => { - if (!e.data.noop && storeRef.current.state) { - const action = e.data.action; - emit('actionDispatched', { - actionType: action.type, - payload: action.payload, - state: storeRef.current.state, - }); - } - }); -} diff --git a/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx b/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx index 3f95430fa189f5..87a570b9edd0bd 100644 --- a/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx +++ b/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx @@ -10,10 +10,15 @@ import { areUrlObjectsEqual, getRouteInfoFromState, } from '../../global-state/getRouteInfoFromState'; +import { + GlobalRoutesWithRemovalPreventedContext, + RemovalPreventionProvider, +} from '../../global-state/removalPrevention'; import { RouteInfoContext } from '../../global-state/routeInfoContext'; +import { RouterConfigContext } from '../../global-state/routerConfigContext'; import { RouterRegistryContext, RouterRegistryProvider } from '../../global-state/routerRegistry'; -import { StoreContext } from '../../global-state/storeContext'; import { useNavigationTreeReducer } from '../../global-state/useNavigationTreeReducer'; +import { useNavigationTreeReportEvents } from '../../global-state/useNavigationTreeReportEvents'; import useLatestCallback from '../../utils/useLatestCallback'; import { CommonActions, @@ -27,6 +32,7 @@ import { EnsureSingleNavigator } from './EnsureSingleNavigator'; import { NavigationBuilderContext } from './NavigationBuilderContext'; import { NavigationContainerRefContext } from './NavigationContainerRefContext'; import { NavigationStateContext } from './NavigationStateContext'; +import { RootNavigationStateContext } from './RootNavigationStateContext'; import { checkDuplicateRouteNames } from './checkDuplicateRouteNames'; import { checkSerializable } from './checkSerializable'; import { NOT_INITIALIZED_ERROR } from './createNavigationContainerRef'; @@ -39,14 +45,12 @@ import type { import { useChildListeners } from './useChildListeners'; import { useClientLayoutEffect } from './useClientLayoutEffect'; import { useEventEmitter } from './useEventEmitter'; -import { useKeyedChildListeners } from './useKeyedChildListeners'; import { useOptionsGetters } from './useOptionsGetters'; type InternalNavigationContainerProps = Omit & { initialState: InitialState; ref?: React.Ref>; UNSTABLE_routeNode?: RouteNode; - UNSTABLE_onStateChangeInsertion?: (state: NavigationState) => void; }; const serializableWarnings: string[] = []; @@ -66,17 +70,17 @@ const duplicateNameWarnings: string[] = []; */ export function BaseNavigationContainer(props: InternalNavigationContainerProps) { const registry = use(RouterRegistryContext); + const routesWithRemovalPrevented = use(GlobalRoutesWithRemovalPreventedContext); // TODO(@ubax): investigate if this is really needed + let content = ; + if (routesWithRemovalPrevented === undefined) { + content = {content}; + } if (registry === undefined) { - return ( - - - - ); + content = {content}; } - - return ; + return content; } function BaseNavigationContainerInner({ @@ -85,13 +89,12 @@ function BaseNavigationContainerInner({ onStateChange, onReady, UNSTABLE_routeNode, - UNSTABLE_onStateChangeInsertion, theme, children, }: InternalNavigationContainerProps) { const parent = use(NavigationStateContext); const inheritedRouteInfo = use(RouteInfoContext); - const store = use(StoreContext); + const routerConfig = use(RouterConfigContext); if (!parent.isDefault) { throw new Error( @@ -100,36 +103,26 @@ function BaseNavigationContainerInner({ } const registry = use(RouterRegistryContext)!; + const routesWithRemovalPrevented = use(GlobalRoutesWithRemovalPreventedContext)!; const emitter = useEventEmitter(); - // TODO(@ubax): investigate if this is really needed - const stackRef = React.useRef(undefined); - // TODO(@ubax): invoke this callback from global reducer dispatches. - // https://linear.app/expo/issue/ENG-26123 - const onDispatchAction = useLatestCallback((action: NavigationAction, noop: boolean) => { - emitter.emit({ - type: '__unsafe_action__', - data: { action, noop, stack: stackRef.current }, - }); - }); // TODO(@ubax): consider moving this state to ExpoRoot. - const { state, getState, getStateForKey, resetNavigator, handleAction, processIntent } = + const { state, report, consumeReportEvents, resetNavigator, handleAction, processIntent } = useNavigationTreeReducer({ initialState, routeNode: UNSTABLE_routeNode, registry, - linking: store?.linking, - redirects: store?.redirects, - onStateChangeInsertion: UNSTABLE_onStateChangeInsertion, + routesWithRemovalPrevented, + linking: routerConfig?.linking, + redirects: routerConfig?.redirects, }); + useNavigationTreeReportEvents(report, consumeReportEvents); const hasNotifiedInitialStateRef = React.useRef(false); const lastNotifiedStateRef = React.useRef(undefined); const { listeners, addListener } = useChildListeners(); - const { addKeyedListener } = useKeyedChildListeners(); - const dispatch = useLatestCallback((action: NavigationAction) => { if (listeners.focus[0] == null) { console.error(NOT_INITIALIZED_ERROR); @@ -139,6 +132,7 @@ function BaseNavigationContainerInner({ }); const dispatchSync = useLatestCallback((action: NavigationAction) => { + // TODO(@ubax): Throw if this is called from a `removePrevented` callback. handleAction(action); }); @@ -156,9 +150,7 @@ function BaseNavigationContainerInner({ } }); - const getRootState = useLatestCallback(() => { - return getState(); - }); + const getRootState = useLatestCallback(() => state); const getCurrentRoute = useLatestCallback(() => { const state = getRootState(); @@ -172,9 +164,8 @@ function BaseNavigationContainerInner({ return route as Route | undefined; }); - const isReady = useLatestCallback( - () => listeners.focus[0] != null && registry.has(getState().key) - ); + // TODO(@ubax): check if this is still needed anywhere + const isReady = useLatestCallback(() => listeners.focus[0] != null && registry.has(state.key)); const { addOptionsGetter, getCurrentOptions } = useOptionsGetters({}); @@ -192,7 +183,7 @@ function BaseNavigationContainerInner({ isFocused: () => true, canGoBack, getParent: () => undefined, - getState, + getState: getRootState, getRootState, getCurrentRoute, getCurrentOptions, @@ -209,7 +200,6 @@ function BaseNavigationContainerInner({ getCurrentOptions, getCurrentRoute, getRootState, - getState, isReady, ] ); @@ -242,23 +232,11 @@ function BaseNavigationContainerInner({ const builderContext = React.useMemo( () => ({ addListener, - addKeyedListener, handleAction, - getStateForKey, resetNavigator, - onDispatchAction, onOptionsChange, - stackRef, }), - [ - addListener, - addKeyedListener, - getStateForKey, - handleAction, - onDispatchAction, - onOptionsChange, - resetNavigator, - ] + [addListener, handleAction, onOptionsChange, resetNavigator] ); const context = React.useMemo( @@ -388,10 +366,12 @@ function BaseNavigationContainerInner({ - - {children} - - + + + {children} + + + diff --git a/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx b/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx index 9a002bba4367aa..bc82bbd500a820 100644 --- a/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx +++ b/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx @@ -1,26 +1,15 @@ 'use client'; import * as React from 'react'; -import type { NavigationAction, NavigationState, ParamListBase } from '../routers'; +import type { NavigationAction, ParamListBase } from '../routers'; import type { NavigationHelpers } from './types'; export type ListenerMap = { focus: FocusedNavigationListener; }; -export type KeyedListenerMap = { - preventRemove: ChildPreventRemoveListener; - beforeRemove: ChildBeforeRemoveListener; -}; - export type AddListener = (type: T, listener: ListenerMap[T]) => void; -export type AddKeyedListener = ( - type: T, - key: string, - listener: KeyedListenerMap[T] -) => void; - export type FocusedNavigationCallback = (navigation: NavigationHelpers) => T; export type FocusedNavigationListener = (callback: FocusedNavigationCallback) => { @@ -28,26 +17,16 @@ export type FocusedNavigationListener = (callback: FocusedNavigationCallback< result: T; }; -export type ChildPreventRemoveListener = (action: NavigationAction) => boolean; - -export type ChildBeforeRemoveListener = (action: NavigationAction) => void; - /** * Context which holds the required helpers needed to build nested navigators. */ export const NavigationBuilderContext = React.createContext<{ handleAction: (action: NavigationAction, originKey?: string) => void; - getStateForKey: (key: string) => NavigationState | undefined; resetNavigator: (stateKey: string, routerType: string | undefined) => void; addListener?: AddListener; - addKeyedListener?: AddKeyedListener; - onDispatchAction: (action: NavigationAction, noop: boolean) => void; onOptionsChange: (options: object, routeKey?: string) => void; - stackRef?: React.MutableRefObject; }>({ handleAction: () => undefined, - getStateForKey: () => undefined, resetNavigator: () => undefined, - onDispatchAction: () => undefined, onOptionsChange: () => undefined, }); diff --git a/packages/expo-router/src/react-navigation/core/NavigationProvider.tsx b/packages/expo-router/src/react-navigation/core/NavigationProvider.tsx index 1c85ed85276cdd..8ad0e1607348f3 100644 --- a/packages/expo-router/src/react-navigation/core/NavigationProvider.tsx +++ b/packages/expo-router/src/react-navigation/core/NavigationProvider.tsx @@ -1,11 +1,10 @@ 'use client'; import * as React from 'react'; -import { use } from 'react'; import type { ParamListBase, Route } from '../routers'; import { NavigationContext } from './NavigationContext'; import type { NavigationProp } from './types'; -import { FocusedRouteKeyContext, IsFocusedContext } from './useIsFocused'; +import { IsFocusedContext, useIsRouteFocused } from './useIsFocused'; /** * Context which holds the route prop for a screen. @@ -26,14 +25,7 @@ export const NamedRouteContextListContext = React.createContext< >(undefined); export function NavigationProvider({ route, navigation, children }: Props) { - const parentIsFocused = use(IsFocusedContext); - const focusedRouteKey = use(FocusedRouteKeyContext); - - // Mark route as focused only if: - // - It doesn't have a parent navigator - // - Parent navigator is focused - const isFocused = - parentIsFocused == null || parentIsFocused ? focusedRouteKey === route.key : false; + const isFocused = useIsRouteFocused(route.key); return ( diff --git a/packages/expo-router/src/react-navigation/core/PreventRemoveContext.tsx b/packages/expo-router/src/react-navigation/core/PreventRemoveContext.tsx deleted file mode 100644 index d73527c30d2782..00000000000000 --- a/packages/expo-router/src/react-navigation/core/PreventRemoveContext.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client'; -import * as React from 'react'; - -/** - * A type of an object that has a route key as an object key - * and a value whether to prevent that route. - */ -export type PreventedRoutes = Record; - -export const PreventRemoveContext = React.createContext< - | { - preventedRoutes: PreventedRoutes; - setPreventRemove: (id: string, routeKey: string, preventRemove: boolean) => void; - } - | undefined ->(undefined); diff --git a/packages/expo-router/src/react-navigation/core/RootNavigationStateContext.tsx b/packages/expo-router/src/react-navigation/core/RootNavigationStateContext.tsx new file mode 100644 index 00000000000000..e42d0538513a26 --- /dev/null +++ b/packages/expo-router/src/react-navigation/core/RootNavigationStateContext.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { createContext } from 'react'; + +import type { NavigationState } from '../routers'; + +export const RootNavigationStateContext = createContext(undefined); diff --git a/packages/expo-router/src/react-navigation/core/SceneView.tsx b/packages/expo-router/src/react-navigation/core/SceneView.tsx index d278ac34fd78a4..ca09006c23687a 100644 --- a/packages/expo-router/src/react-navigation/core/SceneView.tsx +++ b/packages/expo-router/src/react-navigation/core/SceneView.tsx @@ -2,7 +2,14 @@ import * as React from 'react'; import { use } from 'react'; -import type { NavigationState, ParamListBase, PartialState, Route } from '../routers'; +import { PreventRemovalProvider } from '../../global-state/removalPrevention'; +import type { + NavigationAction, + NavigationState, + ParamListBase, + PartialState, + Route, +} from '../routers'; import { EnsureSingleNavigator } from './EnsureSingleNavigator'; import { type FocusedRouteState, @@ -20,6 +27,11 @@ type Props = { routeState: NavigationState | PartialState | undefined; options: object; clearOptions: () => void; + emitRemovalEvent: ( + routeKey: string, + type: 'removePrevented' | 'removed', + action: NavigationAction + ) => void; }; /** @@ -33,11 +45,11 @@ export function SceneView) { const { addOptionsGetter } = useOptionsGetters({ key: route.key, options, - navigation, }); // Clear options set by this screen when it is unmounted @@ -90,24 +102,25 @@ export function SceneView - - - - {ScreenComponent !== undefined ? ( - - ) : screen.children !== undefined ? ( - screen.children({ navigation, route }) - ) : null} - - - - + + + + + + {ScreenComponent !== undefined ? ( + + ) : screen.children !== undefined ? ( + screen.children({ navigation, route }) + ) : null} + + + + + ); } diff --git a/packages/expo-router/src/react-navigation/core/__tests__/BaseNavigationContainer.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/BaseNavigationContainer.test.ios.tsx index f55d0c4c8e1d2f..47adfe718e9d36 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/BaseNavigationContainer.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/BaseNavigationContainer.test.ios.tsx @@ -732,6 +732,66 @@ test('emits option events when options change with tab router', () => { expect(ref.current?.getCurrentOptions()).toEqual({ h: 9 }); }); +test('does not emit options from an unfocused nested navigator', () => { + const NoFocusMockRouter = (options: DefaultRouterOptions) => ({ + ...MockRouter(options), + shouldActionChangeFocus: () => false, + }); + const TestNavigator = React.forwardRef(function TestNavigator(props: any, ref: any): any { + const { state, navigation, descriptors, NavigationContent } = useNavigationBuilder( + NoFocusMockRouter, + props + ); + + React.useImperativeHandle(ref, () => navigation, [navigation]); + + return ( + + {state.routes.map((route) => descriptors[route.key]!.render())} + + ); + }); + const child = React.createRef(); + const ref = createNavigationContainerRef(); + const listener = jest.fn(); + + render( + + + + {() => null} + + + {() => ( + + + {() => null} + + + {() => null} + + + )} + + + + ); + ref.current?.addListener('options', listener); + + act(() => child.current.navigate('fourth')); + + expect(ref.current?.getCurrentRoute()?.name).toBe('first'); + expect(listener).not.toHaveBeenCalled(); + expect(ref.current?.getCurrentOptions()).toEqual({ x: 1 }); +}); + test('emits option events when options change with stack router', () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/actionBubbling.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/actionBubbling.test.ios.tsx index 40d7e782f6f114..046eb9bd39657f 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/actionBubbling.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/actionBubbling.test.ios.tsx @@ -603,8 +603,7 @@ test.skip('logs error if no navigator handled the action', () => { spy.mockRestore(); }); -// TODO(@ubax): Restore removePrevented handling after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a screen with 'removePrevented' event", () => { +test("prevents removing a screen with 'removePrevented' event", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -713,6 +712,10 @@ test.skip("prevents removing a screen with 'removePrevented' event", () => { setPreventRemove(false); }); + expect(onStateChange).toHaveBeenCalledTimes(2); + + act(() => ref.current?.dispatchSync(StackActions.popTo('foo'))); + expect(onStateChange).toHaveBeenCalledTimes(3); expect(onStateChange).toHaveBeenCalledWith({ type: 'stack', @@ -725,8 +728,7 @@ test.skip("prevents removing a screen with 'removePrevented' event", () => { }); }); -// TODO(@ubax): Restore child removePrevented propagation after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a child screen with 'removePrevented' event", () => { +test("prevents removing a child screen with 'removePrevented' event", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -816,8 +818,7 @@ test.skip("prevents removing a child screen with 'removePrevented' event", () => expect(ref.current?.getRootState()).toEqual(preventedState); }); -// TODO(@ubax): Restore grandchild removePrevented propagation after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a grand child screen with 'removePrevented' event", () => { +test("prevents removing a grand child screen with 'removePrevented' event", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -911,8 +912,7 @@ test.skip("prevents removing a grand child screen with 'removePrevented' event", expect(ref.current?.getRootState()).toEqual(preventedState); }); -// TODO(@ubax): Restore multiple removePrevented handlers after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing by multiple screens with 'removePrevented' event", () => { +test("prevents removing by multiple screens with 'removePrevented' event", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -1011,6 +1011,8 @@ test.skip("prevents removing by multiple screens with 'removePrevented' event", expect(onStateChange).toHaveBeenCalledTimes(1); expect(onBeforeRemove.lex).toHaveBeenCalledTimes(1); + expect(onBeforeRemove.baz).toHaveBeenCalledTimes(1); + expect(onBeforeRemove.bar).toHaveBeenCalledTimes(1); expect(ref.current?.getRootState()).toEqual(preventedState); @@ -1019,7 +1021,8 @@ test.skip("prevents removing by multiple screens with 'removePrevented' event", }); expect(onStateChange).toHaveBeenCalledTimes(1); - expect(onBeforeRemove.baz).toHaveBeenCalledTimes(1); + expect(onBeforeRemove.baz).toHaveBeenCalledTimes(2); + expect(onBeforeRemove.bar).toHaveBeenCalledTimes(2); expect(ref.current?.getRootState()).toEqual(preventedState); @@ -1028,13 +1031,12 @@ test.skip("prevents removing by multiple screens with 'removePrevented' event", }); expect(onStateChange).toHaveBeenCalledTimes(1); - expect(onBeforeRemove.bar).toHaveBeenCalledTimes(1); + expect(onBeforeRemove.bar).toHaveBeenCalledTimes(3); expect(ref.current?.getRootState()).toEqual(preventedState); }); -// TODO(@ubax): Restore targeted reset prevention after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a child screen with 'removePrevented' event with targeted reset", () => { +test("prevents removing a child screen with 'removePrevented' event with targeted reset", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.ios.tsx index 413bb45bb32ac8..ece7c60b6d0dc2 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.ios.tsx @@ -18,8 +18,7 @@ beforeEach(() => { require('nanoid/non-secure').__key = 0; }); -// TODO(@ubax): Restore removePrevented handling after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 -test.skip('blocks removal with the hook and emits removePrevented', () => { +test('blocks removal with the hook and emits removePrevented', () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); return ( @@ -29,22 +28,24 @@ test.skip('blocks removal with the hook and emits removePrevented', () => { ); }; const removePrevented = jest.fn(); - const beforeRemove = jest.fn(); + const removed = jest.fn(); + const explicitlyUnsubscribedRemoved = jest.fn(); let setPreventRemove: React.Dispatch>; + let unsubscribeRemoved: () => void; const TestScreen = ({ navigation }: any) => { const [preventRemove, setPreventRemoveState] = React.useState(true); setPreventRemove = setPreventRemoveState; usePreventRemove(preventRemove, removePrevented); React.useEffect(() => navigation.addListener('removePrevented', removePrevented), [navigation]); - React.useEffect( - () => - navigation.addListener('beforeRemove', (event: any) => { - beforeRemove(event); - event.preventDefault(); - }), - [navigation] - ); + React.useEffect(() => { + const unsubscribe = navigation.addListener('removed', removed); + return () => queueMicrotask(unsubscribe); + }, [navigation]); + React.useEffect(() => { + unsubscribeRemoved = navigation.addListener('removed', explicitlyUnsubscribedRemoved); + return () => queueMicrotask(unsubscribeRemoved); + }, [navigation]); return null; }; @@ -66,19 +67,18 @@ test.skip('blocks removal with the hook and emits removePrevented', () => { expect(removePrevented).toHaveBeenCalledTimes(2); expect(removePrevented.mock.calls[0][0].data.action).toBe(action); expect(removePrevented.mock.calls[1][0].data.action).toBe(action); - expect(beforeRemove).not.toHaveBeenCalled(); + expect(removed).not.toHaveBeenCalled(); + act(() => unsubscribeRemoved()); act(() => setPreventRemove(false)); - expect(() => act(() => ref.current?.dispatchSync(CommonActions.goBack()))).toThrow( - '`beforeRemove` is a notification-only event and cannot prevent screen removal. Use `usePreventRemove` with the `removePrevented` event instead.' - ); + act(() => ref.current?.dispatchSync(CommonActions.goBack())); - expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['foo', 'bar']); - expect(beforeRemove).toHaveBeenCalledTimes(1); - expect(beforeRemove.mock.calls[0][0].defaultPrevented).toBe(false); + expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['foo']); + expect(removed).toHaveBeenCalledTimes(1); + expect(explicitlyUnsubscribedRemoved).not.toHaveBeenCalled(); }); -// TODO(@ubax): Restore removePrevented redispatch after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 +// TODO(@ubax): prevent synchronous redispatch from a `removePrevented` callback. test.skip('blocks synchronous redispatch from removePrevented without re-emitting', () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -90,40 +90,30 @@ test.skip('blocks synchronous redispatch from removePrevented without re-emittin }; const ref = createNavigationContainerRef(); const removePrevented = jest.fn(({ data }) => ref.current?.dispatchSync(data.action)); - const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const TestScreen = () => { usePreventRemove(true, removePrevented); return null; }; - try { - render( - - - {() => null} - - - , - { wrapper: RouterRegistryProvider } - ); + render( + + + {() => null} + + + , + { wrapper: RouterRegistryProvider } + ); - act(() => ref.current?.navigate('bar')); - act(() => ref.current?.dispatchSync(CommonActions.goBack())); + act(() => ref.current?.navigate('bar')); + act(() => ref.current?.dispatchSync(CommonActions.goBack())); - expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['foo', 'bar']); - expect(removePrevented).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith( - "The action 'GO_BACK' was dispatched from inside a `usePreventRemove` callback and was prevented again. The `removePrevented` event was not re-emitted to avoid an infinite loop. There is no way to dispatch directly from the callback; set `preventRemove` to `false` first, then retry (for example, call `router.back()` from the handler or dispatch the captured action from an effect)." - ); - } finally { - warn.mockRestore(); - } + expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['foo', 'bar']); + expect(removePrevented).toHaveBeenCalledTimes(1); }); -// TODO(@ubax): Restore nested beforeRemove events after reducer dispatch supports them. https://linear.app/expo/issue/ENG-26123 -test.skip('emits beforeRemove in a nested navigator when its parent route is removed', () => { +test('emits removed in a nested navigator when its parent route is removed', () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); return ( @@ -132,10 +122,13 @@ test.skip('emits beforeRemove in a nested navigator when its parent route is rem ); }; - const beforeRemove = jest.fn(); + const removed = jest.fn(); const NestedScreen = ({ navigation }: any) => { - React.useEffect(() => navigation.addListener('beforeRemove', beforeRemove), [navigation]); + React.useEffect(() => { + const unsubscribe = navigation.addListener('removed', removed); + return () => queueMicrotask(unsubscribe); + }, [navigation]); return null; }; @@ -166,8 +159,10 @@ test.skip('emits beforeRemove in a nested navigator when its parent route is rem ); act(() => ref.current?.navigate('bar')); - act(() => ref.current?.goBack()); + const action = CommonActions.goBack(); + act(() => ref.current?.dispatch(action)); expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['foo']); - expect(beforeRemove).toHaveBeenCalledTimes(1); + expect(removed).toHaveBeenCalledTimes(1); + expect(removed.mock.calls[0][0].data.action).toBe(action); }); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.web.tsx b/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.web.tsx index 4b4065cc2d21c6..beb1e219afc527 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.web.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.web.tsx @@ -4,12 +4,11 @@ import * as React from 'react'; import { View } from 'react-native'; import { ExpoRoot } from '../../../ExpoRoot'; -import { store } from '../../../global-state/router-store'; +import { getRouteInfoFromState } from '../../../global-state/getRouteInfoFromState'; +import { navigationRef } from '../../../global-state/navigationRef'; import { router } from '../../../imperative-api'; import Stack from '../../../layouts/StackClient'; import { getMockContext } from '../../../testing-library/mock-config'; -import { CommonActions } from '../../routers'; -import { useNavigation } from '../useNavigation'; import { usePreventRemove } from '../usePreventRemove'; global.ResizeObserver = class { @@ -18,15 +17,15 @@ global.ResizeObserver = class { disconnect() {} } as typeof ResizeObserver; -// TODO(@ubax): Restore remove prevention after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 -test.skip('continues a blocked router back after disabling prevention', () => { +test('allows router back after disabling prevention', () => { let discard: () => void; const onPreventRemove = jest.fn(); const Form = () => { const [dirty, setDirty] = React.useState(true); - usePreventRemove(dirty, onPreventRemove); + const disablePrevention = usePreventRemove(dirty, onPreventRemove); discard = () => { setDirty(false); + disablePrevention(); router.back(); }; return ; @@ -44,24 +43,24 @@ test.skip('continues a blocked router back after disabling prevention', () => { act(() => router.back()); expect(screen.getByTestId('form')).toBeTruthy(); - expect(store.getRouteInfo().pathname).toBe('/form'); + expect(getRouteInfoFromState(navigationRef.getRootState()).pathname).toBe('/form'); expect(onPreventRemove).toHaveBeenCalledTimes(1); act(() => discard()); - expect(store.getRouteInfo().pathname).toBe('/'); + expect(getRouteInfoFromState(navigationRef.getRootState()).pathname).toBe('/'); expect(onPreventRemove).toHaveBeenCalledTimes(1); }); -// TODO(@ubax): Restore nested remove prevention after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 -test.skip('continues a blocked parent back after disabling nested prevention', () => { +test('allows parent back after disabling nested prevention', () => { let discard: () => void; const onPreventRemove = jest.fn(); const Form = () => { const [dirty, setDirty] = React.useState(true); - usePreventRemove(dirty, onPreventRemove); + const disablePrevention = usePreventRemove(dirty, onPreventRemove); discard = () => { setDirty(false); + disablePrevention(); router.back(); }; return ; @@ -78,43 +77,10 @@ test.skip('continues a blocked parent back after disabling nested prevention', ( act(() => router.push('/nested')); act(() => router.back()); - expect(store.getRouteInfo().pathname).toBe('/nested'); + expect(getRouteInfoFromState(navigationRef.getRootState()).pathname).toBe('/nested'); expect(onPreventRemove).toHaveBeenCalledTimes(1); act(() => discard()); - expect(store.getRouteInfo().pathname).toBe('/'); + expect(getRouteInfoFromState(navigationRef.getRootState()).pathname).toBe('/'); expect(onPreventRemove).toHaveBeenCalledTimes(1); }); - -// TODO(@ubax): Restore beforeRemove handling after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 -test.skip('throws a descriptive error when beforeRemove calls preventDefault', () => { - let goBack: () => void; - const Form = () => { - const navigation = useNavigation(); - goBack = () => navigation.dispatchSync(CommonActions.goBack()); - React.useEffect( - () => - navigation.addListener('beforeRemove', (event) => { - // @ts-expect-error: legacy code treated `beforeRemove` as preventable - event.preventDefault(); - }), - [navigation] - ); - return ; - }; - - process.env.EXPO_ROUTER_IMPORT_MODE = 'sync'; - const context = getMockContext({ - _layout: () => , - index: () => , - form: Form, - }); - render(); - - act(() => router.push('/form')); - - expect(() => act(() => goBack())).toThrow( - '`beforeRemove` is a notification-only event and cannot prevent screen removal. Use `usePreventRemove` with the `removePrevented` event instead.' - ); - expect(store.getRouteInfo().pathname).toBe('/form'); -}); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/useEventEmitter.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/useEventEmitter.test.ios.tsx index 2b6572a43b15b1..85949beca8c5f5 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/useEventEmitter.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/useEventEmitter.test.ios.tsx @@ -1,8 +1,9 @@ -import { act, render } from '@testing-library/react-native'; +import { act, render, renderHook } from '@testing-library/react-native'; import * as React from 'react'; import type { NavigationState, Router } from '../../routers'; import { Screen } from '../Screen'; +import { useEventEmitter } from '../useEventEmitter'; import { useNavigationBuilder } from '../useNavigationBuilder'; import { BaseNavigationContainer } from './__fixtures__/BaseNavigationContainer'; import { MockRouter, MockRouterKey } from './__fixtures__/MockRouter'; @@ -11,6 +12,19 @@ beforeEach(() => { MockRouterKey.current = 0; }); +test('stops emitting removed events immediately after unsubscribe', () => { + const callback = jest.fn(); + const { result } = renderHook(() => + useEventEmitter<{ removed: { data: { action: { type: string } } } }>() + ); + const unsubscribe = result.current.create('route').addListener('removed', callback); + + unsubscribe(); + result.current.emit({ type: 'removed', target: 'route', data: { action: { type: 'REMOVE' } } }); + + expect(callback).not.toHaveBeenCalled(); +}); + test('fires focus and blur events in root navigator', () => { const TestNavigator = React.forwardRef(function TestNavigator(props: any, ref: any): any { const { state, navigation, descriptors, NavigationContent } = useNavigationBuilder( @@ -274,7 +288,7 @@ test('fires focus and blur events in nested navigator', () => { act(() => parent.current.navigate('first')); expect(firstFocusCallback).toHaveBeenCalledTimes(2); - expect(thirdBlurCallback).toHaveBeenCalledTimes(2); + expect(thirdBlurCallback).toHaveBeenCalledTimes(1); act(() => { child.current.navigate('fourth'); @@ -282,7 +296,7 @@ test('fires focus and blur events in nested navigator', () => { }); expect(fourthFocusCallback).toHaveBeenCalledTimes(3); - expect(thirdBlurCallback).toHaveBeenCalledTimes(2); + expect(thirdBlurCallback).toHaveBeenCalledTimes(1); expect(firstBlurCallback).toHaveBeenCalledTimes(2); act(() => child.current.navigate('third')); @@ -298,7 +312,7 @@ test('fires focus and blur events in nested navigator', () => { expect(secondBlurCallback).toHaveBeenCalledTimes(1); expect(thirdFocusCallback).toHaveBeenCalledTimes(2); - expect(thirdBlurCallback).toHaveBeenCalledTimes(2); + expect(thirdBlurCallback).toHaveBeenCalledTimes(1); expect(fourthFocusCallback).toHaveBeenCalledTimes(3); expect(fourthBlurCallback).toHaveBeenCalledTimes(3); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx index 8bd065e6a0fc8c..b47511943a8430 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx @@ -1,10 +1,15 @@ -import { act, render } from '@testing-library/react-native'; +import { act, render, renderHook } from '@testing-library/react-native'; import * as React from 'react'; import type { ParamListBase } from '../../routers'; import { Screen } from '../Screen'; import { createNavigationContainerRef } from '../createNavigationContainerRef'; -import { IsFocusedContext, useIsFocused } from '../useIsFocused'; +import { + FocusedRouteKeyContext, + IsFocusedContext, + useIsFocused, + useIsRouteFocused, +} from '../useIsFocused'; import { useNavigationBuilder } from '../useNavigationBuilder'; import { useRoute } from '../useRoute'; import { BaseNavigationContainer } from './__fixtures__/BaseNavigationContainer'; @@ -41,6 +46,30 @@ test('throws without a focus context', () => { ); }); +test.each([ + { routeKey: undefined, parentIsFocused: undefined, focusedRouteKey: 'route', expected: true }, + { routeKey: undefined, parentIsFocused: false, focusedRouteKey: 'route', expected: false }, + { routeKey: undefined, parentIsFocused: true, focusedRouteKey: 'route', expected: true }, + { routeKey: 'route', parentIsFocused: undefined, focusedRouteKey: 'route', expected: true }, + { routeKey: 'route', parentIsFocused: true, focusedRouteKey: 'other', expected: false }, + { routeKey: 'route', parentIsFocused: false, focusedRouteKey: 'route', expected: false }, +])( + 'returns $expected for route $routeKey with parent focus $parentIsFocused and focused route $focusedRouteKey', + ({ routeKey, parentIsFocused, focusedRouteKey, expected }) => { + const wrapper = ({ children }: React.PropsWithChildren) => ( + + + {children} + + + ); + + const { result } = renderHook(() => useIsRouteFocused(routeKey), { wrapper }); + + expect(result.current).toBe(expected); + } +); + test('renders correct focus state', () => { const TestNavigator = (props: any): any => { const { state, descriptors, NavigationContent } = useNavigationBuilder(MockRouter, props); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/useNavigationCache.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/useNavigationCache.test.ios.tsx index 6a8ea80e8a1b99..ef244c427c0f02 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/useNavigationCache.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/useNavigationCache.test.ios.tsx @@ -26,7 +26,7 @@ afterEach(() => { }); test('preserves reference for navigation objects', () => { - expect.assertions(2); + expect.assertions(4); const state: NavigationState = { type: 'tab', @@ -41,7 +41,6 @@ test('preserves reference for navigation objects', () => { ], }; - const getState = () => state; const navigation = {} as any; const setOptions = (() => {}) as any; const router = MockRouter({}); @@ -53,14 +52,16 @@ test('preserves reference for navigation objects', () => { const getNavigation = useNavigationCache({ routes: state.routes, routeNames: state.routeNames, - getState, navigation, setOptions, router, emitter, }); - const navigations = state.routes.map((route) => getNavigation(route)); + const navigations = state.routes.flatMap((route) => [ + getNavigation(route, false), + getNavigation(route, true), + ]); if (previous.current !== undefined) { navigations.forEach((navigation, index) => { expect(navigation).toBe(previous.current[index]); @@ -82,15 +83,6 @@ test('preserves reference for navigation objects', () => { test('preserves placeholder navigation after the route is created', () => { let routeNames = ['Foo', 'Bar']; let routes = [{ key: 'Foo-key', name: 'Foo' }]; - const getState = (): NavigationState => ({ - type: 'tab', - stale: false as const, - routeKeySeq: 0, - index: 0, - key: 'State', - routeNames, - routes, - }); const navigation = { getId: () => 'State', getParent: jest.fn(), @@ -104,7 +96,6 @@ test('preserves placeholder navigation after the route is created', () => { getNavigation = useNavigationCache({ routes, routeNames, - getState, navigation, setOptions, router, @@ -114,12 +105,12 @@ test('preserves placeholder navigation after the route is created', () => { }; const root = render(); - const placeholderNavigation = getNavigation!({ key: 'Bar', name: 'Bar' }); + const placeholderNavigation = getNavigation!({ key: 'Bar', name: 'Bar' }, false); routes = [...routes, { key: 'Bar-key', name: 'Bar' }]; root.update(); - expect(getNavigation!({ key: 'Bar', name: 'Bar' })).toBe(placeholderNavigation); + expect(getNavigation!({ key: 'Bar', name: 'Bar' }, false)).toBe(placeholderNavigation); routeNames = ['Foo']; routes = routes.filter((route) => route.name !== 'Bar'); @@ -283,7 +274,7 @@ test('returns correct value for isFocused after changing screens', () => { expect(navigation.isFocused()).toBe(false); }); -test('ignores dispatches from a preloaded stack screen until it is promoted', () => { +test('uses a no-op navigation object for a preloaded stack screen', () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -346,20 +337,22 @@ test('ignores dispatches from a preloaded stack screen until it is promoted', () act(() => ref.current?.navigate('second')); - expect(navigation).toBe(preloadedNavigation); + expect(navigation).not.toBe(preloadedNavigation); + const activeNavigation = navigation; enqueue.mockClear(); - act(() => preloadedNavigation.dispatch(CommonActions.goBack())); + act(() => activeNavigation.dispatch(CommonActions.goBack())); expect(enqueue).toHaveBeenCalledTimes(1); expect(enqueue).toHaveBeenCalledWith({ - type: 'NAVIGATOR_ACTION', - payload: expect.objectContaining({ + type: 'ACTION', + payload: { action: expect.objectContaining({ source: expect.any(String), type: 'GO_BACK', }), - }), + originKey: expect.any(String), + }, }); expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['first']); }); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/usePreventRemove.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/usePreventRemove.test.ios.tsx index 410c8fbd903f8d..b5cbea976fa6f8 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/usePreventRemove.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/usePreventRemove.test.ios.tsx @@ -1,15 +1,11 @@ -import { act, render, renderHook } from '@testing-library/react-native'; +import { act, render } from '@testing-library/react-native'; import * as React from 'react'; -import { use, useEffect } from 'react'; import { CommonActions, type ParamListBase, StackActions, StackRouter } from '../../routers'; -import { type PreventedRoutes, PreventRemoveContext } from '../PreventRemoveContext'; import { Screen } from '../Screen'; import { createNavigationContainerRef } from '../createNavigationContainerRef'; import { useNavigationBuilder } from '../useNavigationBuilder'; -import { getPreventableRoutes } from '../useOnPreventRemove'; import { usePreventRemove } from '../usePreventRemove'; -import { usePreventRemoveContext } from '../usePreventRemoveContext'; import { BaseNavigationContainer } from './__fixtures__/BaseNavigationContainer'; import { MockRouterKey } from './__fixtures__/MockRouter'; @@ -19,226 +15,18 @@ jest.mock('nanoid/non-secure', () => { return m; }); +let consoleWarnSpy: jest.SpyInstance; + beforeEach(() => { MockRouterKey.current = 0; require('nanoid/non-secure').__key = 0; + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); }); -test('throws when the prevent remove context is missing', () => { - expect(() => renderHook(() => usePreventRemoveContext())).toThrow( - "Couldn't find the prevent remove context. Is your component inside NavigationContent?" - ); -}); - -test('throws when registering a route outside the navigation state', () => { - const TestNavigator = (props: any) => { - const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); - return ( - - {state.routes.map((route) => descriptors[route.key]!.render())} - - ); - }; - let setPreventRemove: NonNullable< - React.ContextType - >['setPreventRemove']; - const TestScreen = () => { - setPreventRemove = usePreventRemoveContext().setPreventRemove; - return null; - }; - - render( - - - - - - ); - - expect(() => act(() => setPreventRemove('test', 'missing', true))).toThrow( - "Couldn't find a route with the key missing. Is your component inside NavigationContent?" - ); -}); - -// TODO(@ubax): Restore preventRemove behavior for preloaded screens. https://linear.app/expo/issue/ENG-26123 -test.skip('only enables preventRemove after a preloaded screen is promoted', () => { - const TestNavigator = (props: any) => { - const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); - - return ( - - {state.routes.map((route) => descriptors[route.key]!.render())} - - ); - }; - const onPreventRemove = jest.fn(); - let preventedRoutes: PreventedRoutes | undefined; - const ProtectedScreen = () => { - usePreventRemove(true, onPreventRemove); - preventedRoutes = use(PreventRemoveContext)?.preventedRoutes; - return null; - }; - const ref = createNavigationContainerRef(); - - render( - - - {() => null} - {() => null} - - - - ); - - act(() => { - ref.current?.navigate('second'); - ref.current?.dispatch(CommonActions.preload('protected')); - }); - const preloadedRoute = ref.current?.getRootState().routes.at(-1)!; - - expect(preventedRoutes?.[preloadedRoute.key]).toBeUndefined(); - act(() => ref.current?.goBack()); - - expect(onPreventRemove).not.toHaveBeenCalled(); - expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual([ - 'first', - 'protected', - ]); - expect(ref.current?.getRootState().index).toBe(0); - - act(() => ref.current?.navigate('protected')); - const promotedState = ref.current?.getRootState(); - - expect(preventedRoutes?.[preloadedRoute.key]).toEqual({ preventRemove: true }); - act(() => ref.current?.goBack()); - - expect(onPreventRemove).toHaveBeenCalledTimes(1); - expect(ref.current?.getRootState()).toEqual(promotedState); -}); - -test('does not propagate preventRemove from a preloaded nested stack', () => { - const TestNavigator = (props: any) => { - const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); - - return ( - - {state.routes.map((route) => descriptors[route.key]!.render())} - - ); - }; - const onPreventRemove = jest.fn(); - let parentPreventedRoutes: PreventedRoutes | undefined; - const ProtectedScreen = () => { - usePreventRemove(true, onPreventRemove); - return null; - }; - const ParentPreventedRoutesObserver = () => { - parentPreventedRoutes = use(PreventRemoveContext)?.preventedRoutes; - return null; - }; - const NestedStack = (props: any) => { - const { state, descriptors, navigation, NavigationContent } = useNavigationBuilder( - StackRouter, - props - ); - - useEffect(() => navigation.dispatch(CommonActions.preload('protected')), [navigation]); - - return ( - - {state.routes.map((route) => descriptors[route.key]!.render())} - - ); - }; - const ref = createNavigationContainerRef(); - - render( - - - {() => null} - - {() => ( - <> - - - {() => null} - - - - )} - - - - ); - - act(() => ref.current?.navigate('nested')); - const nestedRoute = ref.current?.getRootState().routes.at(-1)!; - - expect(parentPreventedRoutes?.[nestedRoute.key]).toBeUndefined(); - act(() => ref.current?.goBack()); - - expect(onPreventRemove).not.toHaveBeenCalled(); - expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['home']); -}); - -test('only active stack routes are preventable', () => { - const routes = [ - { key: 'a', name: 'a' }, - { key: 'b', name: 'b' }, - { key: 'p1', name: 'p1' }, - { key: 'p2', name: 'p2' }, - ]; - - expect( - getPreventableRoutes({ - stale: false, - routeKeySeq: 0, - type: 'stack', - key: 'stack', - index: 1, - routeNames: routes.map((route) => route.name), - routes, - }) - ).toEqual(routes.slice(0, 2)); - - expect( - getPreventableRoutes({ - stale: false, - routeKeySeq: 0, - type: 'tab', - key: 'tabs', - index: 1, - routeNames: routes.map((route) => route.name), - routes, - }) - ).toEqual(routes); - - expect( - getPreventableRoutes( - { - index: 0, - routes, - }, - 'stack' - ) - ).toEqual(routes.slice(0, 1)); -}); +afterEach(() => consoleWarnSpy.mockRestore()); -// TODO(@ubax): Restore usePreventRemove after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a screen with 'usePreventRemove' hook", () => { +test("prevents removing a screen with 'usePreventRemove' hook", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -347,8 +135,7 @@ test.skip("prevents removing a screen with 'usePreventRemove' hook", () => { }); }); -// TODO(@ubax): Restore blocked effect dispatch after reducer dispatch supports prevention. https://linear.app/expo/issue/ENG-26123 -test.skip('dispatches a blocked action from an effect after disabling prevention', () => { +test('allows an action dispatched while disabling prevention', () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); return ( @@ -359,19 +146,14 @@ test.skip('dispatches a blocked action from an effect after disabling prevention }; let discard: () => void; const onPreventRemove = jest.fn(); - const TestScreen = ({ navigation }: any) => { + const TestScreen = () => { const [preventRemove, setPreventRemove] = React.useState(true); - const pendingAction = React.useRef(null); - usePreventRemove(preventRemove, ({ data }) => { - pendingAction.current = data.action; - onPreventRemove(); - }); - React.useEffect(() => { - if (!preventRemove && pendingAction.current) { - navigation.dispatch(pendingAction.current); - } - }, [navigation, preventRemove]); - discard = () => setPreventRemove(false); + const disablePrevention = usePreventRemove(preventRemove, onPreventRemove); + discard = () => { + setPreventRemove(false); + disablePrevention(); + ref.current?.goBack(); + }; return null; }; const ref = createNavigationContainerRef(); @@ -391,12 +173,139 @@ test.skip('dispatches a blocked action from an effect after disabling prevention expect(onPreventRemove).toHaveBeenCalledTimes(1); act(() => discard()); + expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['foo']); expect(onPreventRemove).toHaveBeenCalledTimes(1); }); -// TODO(@ubax): Restore repeated usePreventRemove registration after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a screen when 'usePreventRemove' hook is called multiple times", () => { +test('warns when disablePrevention is called and preventRemove stays true', () => { + const TestNavigator = (props: any) => { + const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); + return ( + + {state.routes.map((route) => descriptors[route.key]!.render())} + + ); + }; + let disablePrevention!: () => void; + const TestScreen = () => { + disablePrevention = usePreventRemove(true); + return null; + }; + + render( + + + + + + ); + + act(disablePrevention); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); +}); + +test('does not warn when preventRemove is set to false with disablePrevention', () => { + const TestNavigator = (props: any) => { + const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); + return ( + + {state.routes.map((route) => descriptors[route.key]!.render())} + + ); + }; + let discard!: () => void; + const TestScreen = () => { + const [preventRemove, setPreventRemove] = React.useState(true); + const disablePrevention = usePreventRemove(preventRemove); + discard = () => { + setPreventRemove(false); + disablePrevention(); + }; + return null; + }; + + render( + + + + + + ); + + act(discard); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); +}); + +test('does not propagate prevention from a preloaded nested stack route', () => { + const TestNavigator = (props: any) => { + const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); + return ( + + {state.routes.map((route) => descriptors[route.key]!.render())} + + ); + }; + const onPreventRemove = jest.fn(); + const ProtectedScreen = () => { + usePreventRemove(true, onPreventRemove); + return null; + }; + let preloadProtected!: () => void; + const NestedStack = (props: any) => { + const { state, descriptors, navigation, NavigationContent } = useNavigationBuilder( + StackRouter, + props + ); + preloadProtected = () => navigation.dispatch(CommonActions.preload('protected')); + return ( + + {state.routes.map((route) => descriptors[route.key]!.render())} + + ); + }; + const ref = createNavigationContainerRef(); + + render( + + + {() => null} + + {() => ( + + {() => null} + + + )} + + + + ); + + act(preloadProtected); + expect(ref.current?.getRootState().routes[1]?.state?.routes.map((route) => route.name)).toEqual([ + 'index', + 'protected', + ]); + act(() => ref.current?.navigate('nested')); + act(() => ref.current?.goBack()); + + expect(onPreventRemove).not.toHaveBeenCalled(); + expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['home']); +}); + +test("prevents removing a screen when 'usePreventRemove' hook is called multiple times", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -608,8 +517,7 @@ test("should have no effect when 'usePreventRemove' hook is set to false", () => expect(onPreventRemove).toHaveBeenCalledTimes(0); }); -// TODO(@ubax): Restore child usePreventRemove propagation after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a child screen with 'usePreventRemove' hook", () => { +test("prevents removing a child screen with 'usePreventRemove' hook", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -710,8 +618,7 @@ test.skip("prevents removing a child screen with 'usePreventRemove' hook", () => }); }); -// TODO(@ubax): Restore grandchild usePreventRemove propagation after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a grand child screen with 'usePreventRemove' hook", () => { +test("prevents removing a grand child screen with 'usePreventRemove' hook", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -812,8 +719,7 @@ test.skip("prevents removing a grand child screen with 'usePreventRemove' hook", }); }); -// TODO(@ubax): Restore multiple usePreventRemove handlers after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing by multiple screens with 'usePreventRemove' hook", () => { +test("prevents removing by multiple screens with 'usePreventRemove' hook", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -906,6 +812,8 @@ test.skip("prevents removing by multiple screens with 'usePreventRemove' hook", expect(onStateChange).toHaveBeenCalledTimes(1); expect(onPreventRemove.lex).toHaveBeenCalledTimes(1); + expect(onPreventRemove.baz).toHaveBeenCalledTimes(1); + expect(onPreventRemove.bar).toHaveBeenCalledTimes(1); expect(ref.current?.getRootState()).toEqual(preventedState); @@ -916,7 +824,8 @@ test.skip("prevents removing by multiple screens with 'usePreventRemove' hook", act(() => ref.current?.dispatch(StackActions.popTo('foo'))); expect(onStateChange).toHaveBeenCalledTimes(1); - expect(onPreventRemove.baz).toHaveBeenCalledTimes(1); + expect(onPreventRemove.baz).toHaveBeenCalledTimes(2); + expect(onPreventRemove.bar).toHaveBeenCalledTimes(2); expect(ref.current?.getRootState()).toEqual(preventedState); @@ -927,7 +836,7 @@ test.skip("prevents removing by multiple screens with 'usePreventRemove' hook", act(() => ref.current?.dispatch(StackActions.popTo('foo'))); expect(onStateChange).toHaveBeenCalledTimes(1); - expect(onPreventRemove.bar).toHaveBeenCalledTimes(1); + expect(onPreventRemove.bar).toHaveBeenCalledTimes(3); expect(ref.current?.getRootState()).toEqual(preventedState); @@ -944,8 +853,7 @@ test.skip("prevents removing by multiple screens with 'usePreventRemove' hook", }); }); -// TODO(@ubax): Restore targeted reset prevention after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a child screen with 'usePreventRemove' hook with targeted reset", () => { +test("prevents removing a child screen with 'usePreventRemove' hook with targeted reset", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); diff --git a/packages/expo-router/src/react-navigation/core/index.tsx b/packages/expo-router/src/react-navigation/core/index.tsx index 2fbac45106b1c5..56e4f5978e6377 100644 --- a/packages/expo-router/src/react-navigation/core/index.tsx +++ b/packages/expo-router/src/react-navigation/core/index.tsx @@ -47,7 +47,6 @@ export { NavigationProvider } from './NavigationProvider'; * @deprecated Will be removed in a future SDK. */ export { NavigationRouteContext } from './NavigationProvider'; -export { PreventRemoveContext } from './PreventRemoveContext'; /** * @deprecated Will be removed in a future SDK. */ @@ -81,7 +80,6 @@ export { useNavigationBuilder } from './useNavigationBuilder'; export { useNavigationContainerRef } from './useNavigationContainerRef'; export { useNavigationState } from './useNavigationState'; export { usePreventRemove } from './usePreventRemove'; -export { usePreventRemoveContext } from './usePreventRemoveContext'; /** * @deprecated Import `useRoute` from `expo-router` instead. Will be removed in a future SDK. */ diff --git a/packages/expo-router/src/react-navigation/core/types.tsx b/packages/expo-router/src/react-navigation/core/types.tsx index 941026e50213aa..a2801393e03862 100644 --- a/packages/expo-router/src/react-navigation/core/types.tsx +++ b/packages/expo-router/src/react-navigation/core/types.tsx @@ -120,8 +120,24 @@ export type EventMapCore = { focus: { data: undefined }; blur: { data: undefined }; state: { data: { state: State } }; - beforeRemove: { data: { action: NavigationAction } }; removePrevented: { data: { action: NavigationAction } }; + /** + * Emitted after the route is removed and its component unmounts. The listener cannot rely on + * component state or update the unmounted component. Since effect cleanup runs before this event, + * it must defer unsubscription until the next microtask. + * + * @example + * ```tsx + * React.useEffect(() => { + * const unsubscribe = navigation.addListener('removed', (event) => { + * logRemovedRoute(event.data.action); + * }); + * + * return () => queueMicrotask(unsubscribe); + * }, [navigation]); + * ``` + */ + removed: { data: { action: NavigationAction } }; }; export type EventArg< @@ -739,27 +755,6 @@ export type NavigationContainerEventMap = { * Event that fires when current options changes. */ options: { data: { options: object } }; - /** - * Event that fires when an action is dispatched. - * Only intended for debugging purposes, don't use it for app logic. - * This event will be emitted before state changes have been applied. - */ - __unsafe_action__: { - data: { - /** - * The action object that was dispatched. - */ - action: NavigationAction; - /** - * Whether the action was a no-op, i.e. resulted in any state changes. - */ - noop: boolean; - /** - * Stack trace of the action, this will only be available during development. - */ - stack: string | undefined; - }; - }; }; export type ParamListRoute = { diff --git a/packages/expo-router/src/react-navigation/core/useDescriptors.tsx b/packages/expo-router/src/react-navigation/core/useDescriptors.tsx index 0197e6beb83258..e3d42b7bfeec34 100644 --- a/packages/expo-router/src/react-navigation/core/useDescriptors.tsx +++ b/packages/expo-router/src/react-navigation/core/useDescriptors.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { use } from 'react'; +import { isRoutePreloadedInStack } from '../../utils/stack'; import type { NavigationAction, NavigationState, @@ -9,11 +10,7 @@ import type { PartialState, Router, } from '../routers'; -import { - type AddKeyedListener, - type AddListener, - NavigationBuilderContext, -} from './NavigationBuilderContext'; +import { type AddListener, NavigationBuilderContext } from './NavigationBuilderContext'; import { NavigationProvider } from './NavigationProvider'; import { SceneView } from './SceneView'; import { ThemeContext } from './theming/ThemeContext'; @@ -21,6 +18,7 @@ import type { Descriptor, DescriptorRouteProp, EventMapBase, + EventMapCore, NavigationHelpers, NavigationProp, RouteConfig, @@ -71,11 +69,10 @@ type Options< navigation: NavigationHelpers; screenOptions: ScreenOptionsOrCallback | undefined; screenLayout: ScreenLayout | undefined; - getState: () => State; + state: State; addListener: AddListener; - addKeyedListener: AddKeyedListener; router: Router; - emitter: NavigationEventEmitter; + emitter: NavigationEventEmitter>; }; /** @@ -99,59 +96,42 @@ export function useDescriptors< navigation, screenOptions, screenLayout, - getState, + state, addListener, - addKeyedListener, router, emitter, }: Options) { const theme = use(ThemeContext); const [options, setOptions] = React.useState>({}); - const { - handleAction, - getStateForKey, - resetNavigator, - onDispatchAction, - onOptionsChange, - stackRef, - } = use(NavigationBuilderContext); + const { handleAction, resetNavigator, onOptionsChange } = use(NavigationBuilderContext); const context = React.useMemo( () => ({ navigation, handleAction, - getStateForKey, resetNavigator, addListener, - addKeyedListener, - onDispatchAction, onOptionsChange, - stackRef, }), - [ - navigation, - handleAction, - getStateForKey, - resetNavigator, - addListener, - addKeyedListener, - onDispatchAction, - onOptionsChange, - stackRef, - ] + [navigation, handleAction, resetNavigator, addListener, onOptionsChange] ); const getNavigation = useNavigationCache({ routes, routeNames, - getState, navigation, setOptions, router, - emitter, + // The same runtime emitter handles custom events; this generic only exposes core events here. + emitter: emitter as unknown as NavigationEventEmitter, }); const cachedRoutes = useRouteCache(routes); + const emitRemovalEvent = React.useCallback( + (routeKey: string, type: 'removePrevented' | 'removed', action: NavigationAction) => + emitter.emit({ type, target: routeKey, data: { action } }), + [emitter] + ); const getOptions = ( route: DescriptorRouteProp, @@ -235,6 +215,7 @@ export function useDescriptors< routeState={routeState} options={customOptions} clearOptions={clearOptions} + emitRemovalEvent={emitRemovalEvent} /> ); @@ -270,7 +251,7 @@ export function useDescriptors< >; const descriptors = cachedRoutes.reduce((acc, route, i) => { - const navigation = getNavigation(route); + const navigation = getNavigation(route, isRoutePreloadedInStack(state, route)); if (screens[route.name] === undefined) { acc[route.key] = { @@ -318,13 +299,13 @@ export function useDescriptors< if (!config) { return { route, - navigation: getNavigation({ key: route.name, name: route.name }), + navigation: getNavigation({ key: route.name, name: route.name }, false), options: {} as ScreenOptions, render: () => null, } as DescriptorMap[string]; } - const navigation = getNavigation({ key: route.name, name: route.name }); + const navigation = getNavigation({ key: route.name, name: route.name }, false); return { route, navigation, diff --git a/packages/expo-router/src/react-navigation/core/useEventEmitter.tsx b/packages/expo-router/src/react-navigation/core/useEventEmitter.tsx index 63d22fac54ec4c..9008cac4fe63bd 100644 --- a/packages/expo-router/src/react-navigation/core/useEventEmitter.tsx +++ b/packages/expo-router/src/react-navigation/core/useEventEmitter.tsx @@ -7,7 +7,7 @@ export type NavigationEventEmitter> = EventEmitter create: (target: string) => EventConsumer; }; -type Listeners = ((e: any) => void)[]; +type Listeners = Set<(e: any) => void>; /** * Hook to manage the event system used by the navigator to notify screens of various events. @@ -31,17 +31,13 @@ export function useEventEmitter>( return; } - const index = callbacks.indexOf(callback); - - if (index > -1) { - callbacks.splice(index, 1); - } + callbacks.delete(callback); }; const addListener = (type: string, callback: (data: any) => void) => { listeners.current[type] = listeners.current[type] || {}; - listeners.current[type][target] = listeners.current[type][target] || []; - listeners.current[type][target].push(callback); + listeners.current[type][target] = listeners.current[type][target] || new Set(); + listeners.current[type][target].add(callback); let removed = false; return () => { @@ -78,10 +74,8 @@ export function useEventEmitter>( // Copy the current list of callbacks in case they are mutated during execution const callbacks = target !== undefined - ? items[target]?.slice() - : ([] as Listeners) - .concat(...Object.keys(items).map((t) => items[t]!)) - .filter((cb, i, self) => self.lastIndexOf(cb) === i); + ? [...(items[target] ?? [])] + : [...new Set(Object.keys(items).flatMap((target) => [...items[target]!]))]; const event: EventArg = { get type() { @@ -125,7 +119,6 @@ export function useEventEmitter>( }, }); } else if (preventDefault) { - // Legacy `beforeRemove` listeners get a throwing shim without making the event preventable. Object.defineProperties(event, { defaultPrevented: { enumerable: true, diff --git a/packages/expo-router/src/react-navigation/core/useIsFocused.tsx b/packages/expo-router/src/react-navigation/core/useIsFocused.tsx index cb2984c1774721..f22bb04ee0e3d9 100644 --- a/packages/expo-router/src/react-navigation/core/useIsFocused.tsx +++ b/packages/expo-router/src/react-navigation/core/useIsFocused.tsx @@ -6,6 +6,17 @@ export const FocusedRouteKeyContext = React.createContext(un export const IsFocusedContext = React.createContext(undefined); +export function useIsRouteFocused(routeKey: string | undefined): boolean { + const parentIsFocused = use(IsFocusedContext); + const focusedRouteKey = use(FocusedRouteKeyContext); + + if (routeKey === undefined) { + return parentIsFocused ?? true; + } + + return parentIsFocused == null || parentIsFocused ? focusedRouteKey === routeKey : false; +} + /** * Hook to get the current focus state of the screen. Returns a `true` if screen is focused, otherwise `false`. * This can be used if a component needs to render something based on the focus state. diff --git a/packages/expo-router/src/react-navigation/core/useKeyedChildListeners.tsx b/packages/expo-router/src/react-navigation/core/useKeyedChildListeners.tsx deleted file mode 100644 index f3ad0ec1511a14..00000000000000 --- a/packages/expo-router/src/react-navigation/core/useKeyedChildListeners.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client'; -import * as React from 'react'; - -import type { KeyedListenerMap } from './NavigationBuilderContext'; - -/** - * Hook which lets child navigators add keyed listeners. - */ -export function useKeyedChildListeners() { - const { current: keyedListeners } = React.useRef<{ - [K in keyof KeyedListenerMap]: Record; - }>( - Object.assign(Object.create(null), { - preventRemove: {}, - beforeRemove: {}, - }) - ); - - const addKeyedListener = React.useCallback( - (type: T, key: string, listener: KeyedListenerMap[T]) => { - // @ts-expect-error: according to ref stated above you can use `key` to index type - keyedListeners[type][key] = listener; - - return () => { - // @ts-expect-error: according to ref stated above you can use `key` to index type - keyedListeners[type][key] = undefined; - }; - }, - [keyedListeners] - ); - - return { - keyedListeners, - addKeyedListener, - }; -} diff --git a/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx b/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx index a443db791db535..bf48d60f990149 100644 --- a/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx +++ b/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx @@ -9,7 +9,7 @@ import { useComponent } from '../../fork/useComponent'; import { type RouterRegistryEntry, useRegisterRouter } from '../../global-state/routerRegistry'; import { useEnqueueRoutingIntent } from '../../global-state/routingQueueContext'; import { resetNavigatorState } from '../../global-state/stateUtils'; -import useLatestCallback from '../../utils/useLatestCallback'; +import { findStateByKey } from '../../global-state/useNavigationTreeReducer'; import { type DefaultRouterOptions, type NavigationAction, @@ -26,7 +26,7 @@ import { NavigationHelpersContext } from './NavigationHelpersContext'; import { NavigationMetaContext } from './NavigationMetaContext'; import { NavigationStateContext } from './NavigationStateContext'; import { NavigatorTypeContext } from './NavigatorTypeContext'; -import { PreventRemoveContext } from './PreventRemoveContext'; +import { RootNavigationStateContext } from './RootNavigationStateContext'; import { Screen } from './Screen'; import { isArrayEqual } from './isArrayEqual'; import { @@ -45,17 +45,9 @@ import { useEventEmitter } from './useEventEmitter'; import { useFocusEvents } from './useFocusEvents'; import { useFocusedListenersChildrenAdapter } from './useFocusedListenersChildrenAdapter'; import { FocusedRouteKeyContext } from './useIsFocused'; -import { useKeyedChildListeners } from './useKeyedChildListeners'; import { useLazyValue } from './useLazyValue'; import { useNavigationHelpers } from './useNavigationHelpers'; import { NavigatorStateContext } from './useNavigationState'; -import { - emitBeforeRemove, - getPreventableRoutes, - shouldPreventRemove, - useOnPreventRemove, -} from './useOnPreventRemove'; -import { usePreventRemoveState } from './usePreventRemoveState'; import { useRegisterNavigator } from './useRegisterNavigator'; // This is to make TypeScript compiler happy @@ -332,8 +324,9 @@ export function useNavigationBuilder< const routeNamesKey = routeNames.join('\0'); const { state: currentState } = use(NavigationStateContext); + const rootState = use(RootNavigationStateContext); - const { getStateForKey, resetNavigator, handleAction } = use(NavigationBuilderContext); + const { resetNavigator, handleAction } = use(NavigationBuilderContext); if ( currentState === undefined || currentState.stale !== false || @@ -345,10 +338,13 @@ export function useNavigationBuilder< ); } - const isForeignType = currentState.type !== undefined && currentState.type !== router.type; + const treeState = rootState + ? (findStateByKey(rootState, currentState.key) ?? currentState) + : currentState; + const isForeignType = treeState.type !== undefined && treeState.type !== router.type; // The reset keeps the complete fields required by every navigator state. const committedState = ( - isForeignType ? resetNavigatorState(currentState, router.type) : currentState + isForeignType ? resetNavigatorState(treeState, router.type) : treeState ) as State; const state = React.useMemo( () => router.getStateForDeclaredRoutes(committedState, routeNames), @@ -375,22 +371,6 @@ export function useNavigationBuilder< }), [routeNamesKey, router] ); - const getState = useLatestCallback((): State => { - const currentState = getStateForKey(stateKeyRef.current); - if (currentState === undefined) { - return committedState; - } - if (currentState.stale !== false) { - throw new Error( - 'The mounted navigator no longer has complete state in the global navigation tree.' - ); - } - if (currentState.type !== undefined && currentState.type !== router.type) { - // The reset keeps the complete fields required by every navigator state. - return resetNavigatorState(currentState, router.type) as State; - } - return currentState as State; - }); const emitter = useEventEmitter>((e) => { const routeNames = []; @@ -460,21 +440,6 @@ export function useNavigationBuilder< const { listeners: childListeners, addListener } = useChildListeners(); - const { keyedListeners, addKeyedListener } = useKeyedChildListeners(); - - const { isRoutePrevented, preventRemoveContextValue } = usePreventRemoveState({ - getState, - state, - }); - - useOnPreventRemove({ - getState, - isRoutePrevented, - emitter, - preventRemoveListeners: keyedListeners.preventRemove, - beforeRemoveListeners: keyedListeners.beforeRemove, - }); - const onAction = React.useCallback( (action: NavigationAction) => handleAction(action, stateKeyRef.current), [handleAction] @@ -486,28 +451,9 @@ export function useNavigationBuilder< shouldActionChangeFocus: router.shouldActionChangeFocus, getStateForRouteFocus: (registryState, routeKey) => router.getStateForRouteFocus(registryState as State, routeKey), - // TODO(@ubax): invoke removal-prevention callbacks from the global reducer. - // https://linear.app/expo/issue/ENG-26123 - shouldPreventRemove: (prev, next, action) => - shouldPreventRemove( - emitter, - keyedListeners.preventRemove, - isRoutePrevented, - getPreventableRoutes(prev), - getPreventableRoutes(next, prev.type), - action - ), - emitBeforeRemove: (prev, next, action) => - emitBeforeRemove( - emitter, - keyedListeners.beforeRemove, - getPreventableRoutes(prev), - getPreventableRoutes(next, prev.type), - action - ), routeNode: routeNode ?? undefined, }), - [emitter, isRoutePrevented, keyedListeners, reduce, routeNode, routeNamesKey, router] + [reduce, routeNode, routeNamesKey, router] ); useRegisterRouter(committedState.key, registryEntry); @@ -525,7 +471,7 @@ export function useNavigationBuilder< if (isForeignType) { return; } - const committed = getState(); + const committed = committedState; if (isArrayEqual(committed.routeNames, routeNames)) { pendingRouteNamesRef.current = undefined; @@ -548,7 +494,7 @@ export function useNavigationBuilder< const navigation = useNavigationHelpers({ id: options.id, handleAction: onAction, - getState, + state: committedState, emitter, router, }); @@ -565,11 +511,9 @@ export function useNavigationBuilder< navigation, screenOptions, screenLayout, - getState, + state: committedState, addListener, - addKeyedListener, router, - // @ts-expect-error: this should have both core and custom events, but too much work right now emitter, }); useCurrentRender({ @@ -594,11 +538,9 @@ export function useNavigationBuilder< - - - {element} - - + + {element} + diff --git a/packages/expo-router/src/react-navigation/core/useNavigationCache.tsx b/packages/expo-router/src/react-navigation/core/useNavigationCache.tsx index d60e45a0cba1fd..2d875f556570ca 100644 --- a/packages/expo-router/src/react-navigation/core/useNavigationCache.tsx +++ b/packages/expo-router/src/react-navigation/core/useNavigationCache.tsx @@ -1,8 +1,6 @@ 'use client'; import * as React from 'react'; -import { use } from 'react'; -import { isRoutePreloadedInStack } from '../../utils/stack'; import { CommonActions, type NavigationAction, @@ -10,7 +8,6 @@ import { type ParamListBase, type Router, } from '../routers'; -import { NavigationBuilderContext } from './NavigationBuilderContext'; import type { NavigationHelpers, NavigationProp } from './types'; import type { NavigationEventEmitter } from './useEventEmitter'; @@ -21,7 +18,6 @@ type Options< > = { routes: State['routes']; routeNames: State['routeNames']; - getState: () => State; navigation: NavigationHelpers & Partial>; setOptions: ( @@ -47,6 +43,9 @@ type NavigationCache< * Hook to cache navigation objects for each screen in the navigator. * It's important to cache them to make sure navigation objects don't change between renders. * This lets us apply optimizations like `React.memo` to minimize re-rendering screens. + * Exception: a route's navigation object changes identity once when the route is promoted from + * preloaded to active. + * TODO(@ubax): consider resolving `isPreloaded` at call time to keep one object per route. */ export function useNavigationCache< State extends NavigationState, @@ -56,34 +55,30 @@ export function useNavigationCache< >({ routes, routeNames, - getState, navigation, setOptions, router, emitter, }: Options) { - const { stackRef } = use(NavigationBuilderContext); - // Cache object which holds navigation objects for each screen // We use `React.useMemo` instead of `React.useRef` coz we want to invalidate it when deps change // In reality, these deps will rarely change, if ever const cache = React.useMemo( () => ({ current: {} as NavigationCache }), // eslint-disable-next-line react-hooks/exhaustive-deps - [getState, navigation, setOptions, emitter] + [navigation, setOptions, emitter] ); // Keep name-keyed placeholders stable after their real route keys are created. - const validKeys = new Set([...routes.map((route) => route.key), ...routeNames]); + const routeKeys = [...routes.map((route) => route.key), ...routeNames]; + const validKeys = new Set(routeKeys.flatMap((key) => [key, `p\0${key}`])); cache.current = Object.fromEntries( Object.entries(cache.current).filter(([key]) => validKeys.has(key)) ); - const createNavigation = (route: { key: string; name: string }) => { + const createNavigation = (route: { key: string; name: string }, isPreloaded: boolean) => { const dispatchSync = (action: NavigationAction) => { - const state = getState(); - - if (isRoutePreloadedInStack(state, route)) { + if (isPreloaded) { if (process.env.NODE_ENV !== 'production') { console.warn( `Ignored a navigation action dispatched from the preloaded screen '${route.name}'. The screen is rendered for preloading and is not focused, so its actions would unexpectedly modify the visible stack. Wait until the screen is focused before dispatching.` @@ -97,8 +92,7 @@ export function useNavigationCache< }; const dispatch = (action: NavigationAction) => { - const state = getState(); - if (isRoutePreloadedInStack(state, route)) { + if (isPreloaded) { if (process.env.NODE_ENV !== 'production') { console.warn( `Ignored a navigation action dispatched from the preloaded screen '${route.name}'. The screen is rendered for preloading and is not focused, so its actions would unexpectedly modify the visible stack. Wait until the screen is focused before dispatching.` @@ -110,24 +104,6 @@ export function useNavigationCache< navigation.dispatch({ source: route.key, ...action }); }; - const withStack = (callback: () => void) => { - let isStackSet = false; - - try { - if (process.env.NODE_ENV !== 'production' && stackRef && !stackRef.current) { - // Capture the stack trace for devtools - stackRef.current = new Error().stack; - isStackSet = true; - } - - callback(); - } finally { - if (isStackSet && stackRef) { - stackRef.current = undefined; - } - } - }; - const actions = { ...router.actionCreators, ...CommonActions, @@ -135,10 +111,8 @@ export function useNavigationCache< const helpers = Object.keys(actions).reduce void>>((acc, name) => { acc[name] = (...args: any) => - withStack(() => - // @ts-expect-error: name is a valid key, but TypeScript is dumb - dispatch(actions[name](...args)) - ); + // @ts-expect-error: name is a valid key, but TypeScript is dumb + dispatch(actions[name](...args)); return acc; }, {}); @@ -151,8 +125,8 @@ export function useNavigationCache< ...helpers, // FIXME: too much work to fix the types for now ...(emitter.create(route.key) as any), - dispatch: (action: NavigationAction) => withStack(() => dispatch(action)), - dispatchSync: (action: NavigationAction) => withStack(() => dispatchSync(action)), + dispatch, + dispatchSync, getParent: (id?: string) => { if (id !== undefined && id === rest.getId()) { // If the passed id is the same as the current navigation id, @@ -171,7 +145,7 @@ export function useNavigationCache< isFocused: () => { const state = rest.getState(); - if (state.routes[state.index]!.key !== route.key) { + if (state.routes[state.index]?.key !== route.key) { return false; } @@ -184,14 +158,15 @@ export function useNavigationCache< return navigationItem; }; - return (route: { key: string; name: string }) => { - const cachedNavigation = cache.current[route.key]; + return (route: { key: string; name: string }, isPreloaded: boolean) => { + const key = `${isPreloaded ? 'p\0' : ''}${route.key}`; + const cachedNavigation = cache.current[key]; if (cachedNavigation) { return cachedNavigation; } - const navigation = createNavigation(route); - cache.current[route.key] = navigation; + const navigation = createNavigation(route, isPreloaded); + cache.current[key] = navigation; return navigation; }; } diff --git a/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx b/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx index d7a93a6eac2ffd..2a0eae40b6d878 100644 --- a/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx +++ b/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { use } from 'react'; import { useEnqueueRoutingIntent } from '../../global-state/routingQueueContext'; +import useLatestCallback from '../../utils/useLatestCallback'; import { CommonActions, type NavigationAction, @@ -21,7 +22,7 @@ PrivateValueStore; type Options = { id: string | undefined; handleAction: (action: NavigationAction) => void; - getState: () => State; + state: State; emitter: NavigationEventEmitter; router: Router; }; @@ -35,9 +36,11 @@ export function useNavigationHelpers< ActionHelpers extends Record void>, Action extends NavigationAction, EventMap extends Record, ->({ id: navigatorId, handleAction, getState, emitter, router }: Options) { +>({ id: navigatorId, handleAction, state, emitter, router }: Options) { const parentNavigationHelpers = use(NavigationContext); const enqueue = useEnqueueRoutingIntent(); + // Unlike handler-only Effect Events, the public accessor can be called during render. + const getState = useLatestCallback(() => state); return React.useMemo(() => { const dispatchSync = (action: Action) => { @@ -46,12 +49,8 @@ export function useNavigationHelpers< const dispatch = (action: Action) => { enqueue({ - type: 'NAVIGATOR_ACTION', - payload: { - action, - // The queued action was already constrained to this navigator's action type. - dispatchSync: (queuedAction) => dispatchSync(queuedAction as Action), - }, + type: 'ACTION', + payload: { action, originKey: getState().key }, }); }; @@ -105,5 +104,5 @@ export function useNavigationHelpers< } as NavigationHelpers & ActionHelpers; return navigationHelpers; - }, [enqueue, router, parentNavigationHelpers, emitter.emit, getState, handleAction, navigatorId]); + }, [enqueue, router, parentNavigationHelpers, emitter.emit, handleAction, navigatorId]); } diff --git a/packages/expo-router/src/react-navigation/core/useOnPreventRemove.tsx b/packages/expo-router/src/react-navigation/core/useOnPreventRemove.tsx deleted file mode 100644 index 20281a12092081..00000000000000 --- a/packages/expo-router/src/react-navigation/core/useOnPreventRemove.tsx +++ /dev/null @@ -1,161 +0,0 @@ -'use client'; -import * as React from 'react'; -import { use } from 'react'; - -import type { NavigationAction, NavigationState } from '../routers'; -import { - type ChildBeforeRemoveListener, - type ChildPreventRemoveListener, - NavigationBuilderContext, -} from './NavigationBuilderContext'; -import { NavigationRouteContext } from './NavigationProvider'; -import type { EventMapCore } from './types'; -import type { NavigationEventEmitter } from './useEventEmitter'; -import type { IsRoutePrevented } from './usePreventRemoveState'; - -type Options = { - getState: () => NavigationState; - isRoutePrevented: IsRoutePrevented; - emitter: NavigationEventEmitter>; - preventRemoveListeners: Record; - beforeRemoveListeners: Record; -}; - -const VISITED_ROUTE_KEYS = Symbol('VISITED_ROUTE_KEYS'); -const emittingRemovePreventedKeys = new Set(); - -export const getPreventableRoutes = ( - state: NavigationState | { type?: string; index?: number; routes: { key?: string }[] }, - type = state.type -) => - // In order to preload routes in stack, an action needs to be dispatched, so the type will be always - // set when there are preloaded routes - type === 'stack' - ? state.routes.slice(0, (state.index ?? state.routes.length - 1) + 1) - : state.routes; - -const getRemovedRoutes = (currentRoutes: { key?: string }[], nextRoutes: { key?: string }[]) => { - const nextRouteKeys = nextRoutes.map((route) => route.key); - - return currentRoutes - .filter( - (route): route is { key: string } => - route.key !== undefined && !nextRouteKeys.includes(route.key) - ) - .reverse(); -}; - -export const shouldPreventRemove = ( - emitter: NavigationEventEmitter>, - preventRemoveListeners: Record, - isRoutePrevented: IsRoutePrevented, - currentRoutes: { key?: string }[], - nextRoutes: { key?: string }[], - action: NavigationAction -) => { - for (const route of getRemovedRoutes(currentRoutes, nextRoutes)) { - if (preventRemoveListeners[route.key]?.(action)) { - return true; - } - - if (isRoutePrevented(route.key)) { - // TODO: Queued redispatch runs after this callback and bypasses this re-entrancy guard. - // Check whether the guard is still needed now that only `dispatchSync` can re-enter it. - if (emittingRemovePreventedKeys.has(route.key)) { - if (__DEV__) { - console.warn( - `The action '${action.type}' was dispatched from inside a \`usePreventRemove\` callback and was prevented again. The \`removePrevented\` event was not re-emitted to avoid an infinite loop. There is no way to dispatch directly from the callback; set \`preventRemove\` to \`false\` first, then retry (for example, call \`router.back()\` from the handler or dispatch the captured action from an effect).` - ); - } - return true; - } - - emittingRemovePreventedKeys.add(route.key); - try { - emitter.emit({ - type: 'removePrevented', - target: route.key, - data: { action }, - }); - } finally { - emittingRemovePreventedKeys.delete(route.key); - } - return true; - } - } - - return false; -}; - -export const emitBeforeRemove = ( - emitter: NavigationEventEmitter>, - beforeRemoveListeners: Record, - currentRoutes: { key?: string }[], - nextRoutes: { key?: string }[], - action: NavigationAction -) => { - const visitedRouteKeys: Set = - // @ts-expect-error: add this property to mark that we've already emitted this action - action[VISITED_ROUTE_KEYS] ?? new Set(); - const beforeRemoveAction = { ...action, [VISITED_ROUTE_KEYS]: visitedRouteKeys }; - - for (const route of getRemovedRoutes(currentRoutes, nextRoutes)) { - if (visitedRouteKeys.has(route.key)) { - continue; - } - - beforeRemoveListeners[route.key]?.(beforeRemoveAction); - visitedRouteKeys.add(route.key); - emitter.emit({ - type: 'beforeRemove', - target: route.key, - data: { action: beforeRemoveAction }, - preventDefault() { - throw new Error( - '`beforeRemove` is a notification-only event and cannot prevent screen removal. Use `usePreventRemove` with the `removePrevented` event instead.' - ); - }, - }); - } -}; - -export function useOnPreventRemove({ - getState, - isRoutePrevented, - emitter, - preventRemoveListeners, - beforeRemoveListeners, -}: Options) { - const { addKeyedListener } = use(NavigationBuilderContext); - const routeKey = use(NavigationRouteContext)?.key; - - React.useEffect(() => { - if (!routeKey) { - return; - } - - return addKeyedListener?.('preventRemove', routeKey, (action) => { - const state = getState(); - return shouldPreventRemove( - emitter, - preventRemoveListeners, - isRoutePrevented, - getPreventableRoutes(state), - [], - action - ); - }); - }, [addKeyedListener, emitter, getState, isRoutePrevented, preventRemoveListeners, routeKey]); - - React.useEffect(() => { - if (!routeKey) { - return; - } - - // Forward beforeRemove into nested navigators when an ancestor removes their route. - return addKeyedListener?.('beforeRemove', routeKey, (action) => { - const state = getState(); - emitBeforeRemove(emitter, beforeRemoveListeners, getPreventableRoutes(state), [], action); - }); - }, [addKeyedListener, beforeRemoveListeners, emitter, getState, routeKey]); -} diff --git a/packages/expo-router/src/react-navigation/core/useOptionsGetters.tsx b/packages/expo-router/src/react-navigation/core/useOptionsGetters.tsx index 87c8f3abca2d14..d85f51cab636aa 100644 --- a/packages/expo-router/src/react-navigation/core/useOptionsGetters.tsx +++ b/packages/expo-router/src/react-navigation/core/useOptionsGetters.tsx @@ -2,18 +2,17 @@ import * as React from 'react'; import { use } from 'react'; -import type { ParamListBase } from '../routers'; +import useLatestCallback from '../../utils/useLatestCallback'; import { NavigationBuilderContext } from './NavigationBuilderContext'; import { NavigationStateContext } from './NavigationStateContext'; -import type { NavigationProp } from './types'; +import { useIsRouteFocused } from './useIsFocused'; type Options = { key?: string; - navigation?: NavigationProp; options?: object | undefined; }; -export function useOptionsGetters({ key, options, navigation }: Options) { +export function useOptionsGetters({ key, options }: Options) { const optionsRef = React.useRef(options); const optionsGettersFromChildRef = React.useRef object | undefined | null>>( {} @@ -21,22 +20,20 @@ export function useOptionsGetters({ key, options, navigation }: Options) { const { onOptionsChange } = use(NavigationBuilderContext); const { addOptionsGetter: parentAddOptionsGetter } = use(NavigationStateContext); + const isFocused = useIsRouteFocused(key); const optionsChangeListener = React.useCallback(() => { - const isFocused = navigation?.isFocused() ?? true; const hasChildren = Object.keys(optionsGettersFromChildRef.current).length; if (isFocused && !hasChildren) { onOptionsChange(optionsRef.current ?? {}, key); } - }, [key, navigation, onOptionsChange]); + }, [isFocused, key, onOptionsChange]); React.useEffect(() => { optionsRef.current = options; optionsChangeListener(); - - return navigation?.addListener('focus', optionsChangeListener); - }, [navigation, options, optionsChangeListener]); + }, [options, optionsChangeListener]); const getOptionsFromListener = React.useCallback(() => { for (const key in optionsGettersFromChildRef.current) { @@ -53,9 +50,7 @@ export function useOptionsGetters({ key, options, navigation }: Options) { return null; }, []); - const getCurrentOptions = React.useCallback(() => { - const isFocused = navigation?.isFocused() ?? true; - + const getCurrentOptions = useLatestCallback(() => { if (!isFocused) { return null; } @@ -67,7 +62,7 @@ export function useOptionsGetters({ key, options, navigation }: Options) { } return optionsRef.current; - }, [navigation, getOptionsFromListener]); + }); React.useEffect(() => { return parentAddOptionsGetter?.(key!, getCurrentOptions); diff --git a/packages/expo-router/src/react-navigation/core/usePreventRemove.tsx b/packages/expo-router/src/react-navigation/core/usePreventRemove.tsx index 6e2e8e2a9e059d..f3814917f3f555 100644 --- a/packages/expo-router/src/react-navigation/core/usePreventRemove.tsx +++ b/packages/expo-router/src/react-navigation/core/usePreventRemove.tsx @@ -1,35 +1,61 @@ 'use client'; -import { nanoid } from 'nanoid/non-secure'; import * as React from 'react'; +import { ScreenRemovalPreventionSetterContext } from '../../global-state/removalPrevention'; import useLatestCallback from '../../utils/useLatestCallback'; import type { NavigationAction } from '../routers'; import type { EventListenerCallback, EventMapCore } from './types'; +import { useClientLayoutEffect } from './useClientLayoutEffect'; import { useNavigation } from './useNavigation'; -import { usePreventRemoveContext } from './usePreventRemoveContext'; -import { useRoute } from './useRoute'; + +const NOOP = () => {}; + +function useWarnOnStalePreventRemoveDev(preventRemove: boolean) { + const [shouldCheck, setShouldCheck] = React.useState(false); + + React.useEffect(() => { + if (!shouldCheck) { + return; + } + + setShouldCheck(false); + if (preventRemove) { + console.warn( + '`disablePrevention` from `usePreventRemove` was called, but `preventRemove` is still ' + + '`true`. The screen is no longer protected, but the hook will not re-enable prevention ' + + 'until `preventRemove` changes. Set `preventRemove` to `false` in the same handler to ' + + 'keep the prop and the prevention state in sync.' + ); + } + }, [shouldCheck, preventRemove]); + + return React.useCallback(() => setShouldCheck(true), []); +} + +// Dev-only: warns when `disablePrevention` was called but `preventRemove` is still `true`. +const useWarnOnStalePreventRemove: (preventRemove: boolean) => () => void = + process.env.NODE_ENV === 'production' ? () => NOOP : useWarnOnStalePreventRemoveDev; /** * Prevents the screen from being removed while `preventRemove` is `true` and calls `callback` * with the blocked navigation action. * - * To continue, first set `preventRemove` to `false`, then call `router.back()` from the same - * press handler. To retry the blocked action, store it in the callback and dispatch it from an - * effect after `preventRemove` becomes `false`. Dispatching synchronously inside the callback - * re-triggers prevention. + * To continue from the same handler, call the returned `disablePrevention` function before + * navigating. * * @example * ```tsx * const [hasUnsavedChanges, setHasUnsavedChanges] = useState(true); * const [showConfirm, setShowConfirm] = useState(false); * - * usePreventRemove(hasUnsavedChanges, () => setShowConfirm(true)); + * const disablePrevention = usePreventRemove(hasUnsavedChanges, () => setShowConfirm(true)); * * {showConfirm && ( *