diff --git a/.expo-agents/verify/.eas/workflows/verify.yml b/.expo-agents/verify/.eas/workflows/verify.yml index 83b7f29c77ffb7..689986d19a2ea4 100644 --- a/.expo-agents/verify/.eas/workflows/verify.yml +++ b/.expo-agents/verify/.eas/workflows/verify.yml @@ -62,6 +62,10 @@ jobs: VERIFY_RETRY: ${{ inputs.retry && 'true' || 'false' }} VERIFY_ACTOR: ${{ inputs.actor }} VERIFY_DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} + # workflow.id is the RUN id and workflow.url the run page (universe + # WorkflowRunUtils: getWorkflowRunUrl) — so comments link the EAS run. + VERIFY_RUN_ID: ${{ workflow.id }} + VERIFY_RUN_URL: ${{ workflow.url }} steps: - uses: eas/checkout # the uploaded profile dir; the engine clones the # target, and target code executes only in sandboxes diff --git a/apps/expo-go/ios/Podfile.lock b/apps/expo-go/ios/Podfile.lock index 1e5face53b384e..ef166720b9e9d2 100644 --- a/apps/expo-go/ios/Podfile.lock +++ b/apps/expo-go/ios/Podfile.lock @@ -45,6 +45,10 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - ExpoAgeRange (57.0.2): + - ExpoModulesCore + - ExpoAppleAuthentication (57.0.1): + - ExpoModulesCore - ExpoAsset (57.0.8): - ExpoModulesCore - ExpoAudio (57.0.3): @@ -114,6 +118,8 @@ PODS: - ExpoModulesCore - ExpoLinking (57.0.4): - ExpoModulesCore + - ExpoLivePhoto (57.0.1): + - ExpoModulesCore - ExpoLocalAuthentication (57.0.2): - ExpoModulesCore - ExpoLocalization (57.0.1): @@ -127,6 +133,8 @@ PODS: - ExpoMediaLibrary (57.0.3): - ExpoModulesCore - React-Core + - ExpoMeshGradient (57.0.1): + - ExpoModulesCore - ExpoModulesCore (57.0.8): - boost - DoubleConversion @@ -3989,6 +3997,8 @@ DEPENDENCIES: - EXJSONUtils (from `../../../packages/expo-json-utils/ios`) - EXManifests (from `../../../packages/expo-manifests/ios`) - Expo (from `../../../packages/expo`) + - ExpoAgeRange (from `../../../packages/expo-age-range/ios`) + - ExpoAppleAuthentication (from `../../../packages/expo-apple-authentication/ios`) - ExpoAsset (from `../../../packages/expo-asset/ios`) - ExpoAudio (from `../../../packages/expo-audio/ios`) - ExpoBackgroundFetch (from `../../../packages/expo-background-fetch/ios`) @@ -4018,12 +4028,14 @@ DEPENDENCIES: - ExpoKeepAwake (from `../../../packages/expo-keep-awake/ios`) - ExpoLinearGradient (from `../../../packages/expo-linear-gradient/ios`) - ExpoLinking (from `../../../packages/expo-linking/ios`) + - ExpoLivePhoto (from `../../../packages/expo-live-photo/ios`) - ExpoLocalAuthentication (from `../../../packages/expo-local-authentication/ios`) - ExpoLocalization (from `../../../packages/expo-localization/ios`) - ExpoLocation (from `../../../packages/expo-location/ios`) - "ExpoLogBox (from `../../../packages/@expo/log-box`)" - ExpoMailComposer (from `../../../packages/expo-mail-composer/ios`) - ExpoMediaLibrary (from `../../../packages/expo-media-library/ios`) + - ExpoMeshGradient (from `../../../packages/expo-mesh-gradient/ios`) - ExpoModulesCore (from `../../../packages/expo-modules-core`) - ExpoModulesJSI (from `../../../packages/expo-modules-jsi/apple`) - ExpoModulesWorklets (from `../../../packages/expo-modules-core`) @@ -4221,6 +4233,10 @@ EXTERNAL SOURCES: :path: "../../../packages/expo-manifests/ios" Expo: :path: "../../../packages/expo" + ExpoAgeRange: + :path: "../../../packages/expo-age-range/ios" + ExpoAppleAuthentication: + :path: "../../../packages/expo-apple-authentication/ios" ExpoAsset: :path: "../../../packages/expo-asset/ios" ExpoAudio: @@ -4279,6 +4295,8 @@ EXTERNAL SOURCES: :path: "../../../packages/expo-linear-gradient/ios" ExpoLinking: :path: "../../../packages/expo-linking/ios" + ExpoLivePhoto: + :path: "../../../packages/expo-live-photo/ios" ExpoLocalAuthentication: :path: "../../../packages/expo-local-authentication/ios" ExpoLocalization: @@ -4291,6 +4309,8 @@ EXTERNAL SOURCES: :path: "../../../packages/expo-mail-composer/ios" ExpoMediaLibrary: :path: "../../../packages/expo-media-library/ios" + ExpoMeshGradient: + :path: "../../../packages/expo-mesh-gradient/ios" ExpoModulesCore: :path: "../../../packages/expo-modules-core" ExpoModulesJSI: @@ -4553,10 +4573,12 @@ SPEC CHECKSUMS: DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb EASClient: 81f44ce9f07ec3d01606507213d21510a5f97388 EXApplication: bbd517d50878ca1d121fb3843392beb210181e28 - EXConstants: 5efe6122eecac2d970aa50a2a502ecc6ce4b7564 + EXConstants: 5bac5be939128b162bcddc7686b9234e40be106c EXJSONUtils: 7a1683cfa2cbe6d11fca97097d51a496755b0287 EXManifests: f9888c0bf5aaa6ddb7e3d4b224d6d54ac4ca84a4 Expo: 3f63bd27f754689bab053910280cbeae79256b4e + ExpoAgeRange: f30bef82f1a99c097e28f50ed41ca04d5e1cab1c + ExpoAppleAuthentication: 97d113f0f2680067e6e76d3c9249e7e1fc026b42 ExpoAsset: a76534cf7b762978861dd31708308496c676166f ExpoAudio: 34f5940d49071f1c5f9d8c5f78e67045b0fbcb5a ExpoBackgroundFetch: b681657b37b294b56a02fceec4af17aebd187cee @@ -4586,12 +4608,14 @@ SPEC CHECKSUMS: ExpoKeepAwake: c26f14275017370cc8a4b7b43a0e23361f2053a5 ExpoLinearGradient: 903b3f5fe566c666ff78b2599add51d094483613 ExpoLinking: 5a796f9284535c0349537a844dd40680229b7fcb + ExpoLivePhoto: 383719af5c54875f877b6ab97fc098974f0e1c86 ExpoLocalAuthentication: 96989637e567a10cdeabe61e5c6026d30c0dc53f ExpoLocalization: 659243b03b3a3e9589793cee2f728a899e181329 ExpoLocation: ad64144374a840dd365274b309dbbbb253a1e44e ExpoLogBox: 6bb73c341aca22e699bd4da6cc61afc844e1a648 ExpoMailComposer: 101933dc6bdb4d7a46495d1dd87420365325adb9 ExpoMediaLibrary: e0829ae4fe90dc8bcd5ec13250ba6503aebbbc94 + ExpoMeshGradient: ade33604a92081a2deda41c6107e7d080493012d ExpoModulesCore: cf741394007fb2754ae09817e3f32bd117202d28 ExpoModulesJSI: b20fefa4cdd9097e7e6b715466d1900b8c5d4711 ExpoModulesWorklets: 6b240031daa3de4df791588fc78236637ff602f6 @@ -4607,7 +4631,7 @@ SPEC CHECKSUMS: ExpoSharing: dcf5cb459c99f344aabf126b171eb61d2e49276f ExpoSMS: ac35f6c85b72ee5b18a7c6bb06550fbd4a683775 ExpoSpeech: 0e90904e2af5d6f166d4d2ffbdab535686597938 - ExpoSQLite: 7ea4f5b0ebd0b6f15927c2349db7624a5abf6beb + ExpoSQLite: 73ec3c3dd0c5e8c6592c341edf00efa6271c51a8 ExpoStoreReview: 2a0f6b8e04112aea2f6e3e1cb84109d78f4d041f ExpoSymbols: 7c7c7bd3c52f0b6dcbba6e8af4088b0dc1ea7d29 ExpoSystemUI: ff3142b323ae7b4d11dc0b1dc4acfa30d92be7dc @@ -4631,11 +4655,11 @@ SPEC CHECKSUMS: FirebaseRemoteConfigInterop: 85bdce8babed7814816496bb6f082bc05b0a45e1 FirebaseSessions: f5c6bfeb66a7202deaf33352017bb6365e395820 fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac - glog: e56ede4028c4b7418e6b1195a36b1656bb35e225 + glog: 0456694f3aaf09460b660ea327cfc11defbeea4c GoogleAppMeasurement: 8a82b93a6400c8e6551c0bcd66a9177f2e067aed GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - hermes-engine: 349ded4feea539299cee22cb680b0d50402116e5 + hermes-engine: 1aac0336ab06465103e89322838baf2911318c89 libavif: 5f8e715bea24debec477006f21ef9e95432e254d libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 @@ -4645,7 +4669,7 @@ SPEC CHECKSUMS: nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - RCT-Folly: 36c4f904fb6cd0219dcb76b94e9502d2a72fab0b + RCT-Folly: 121436bcc4611f6bde5c09bf35f0a7a82cef1969 RCTDeprecation: debcd5d989dc39edd50d61566b56e96da1c167b0 RCTRequired: f06ef156c6b0244ac76aa51ef2e830a1a13004c8 RCTSwiftUI: 04eac825e818fb067564bed44b20308916085563 diff --git a/apps/expo-go/package.json b/apps/expo-go/package.json index 16cacd939a4411..5a8cdcdaf719cf 100644 --- a/apps/expo-go/package.json +++ b/apps/expo-go/package.json @@ -24,6 +24,8 @@ "@shopify/react-native-skia": "2.6.2", "@stripe/stripe-react-native": "0.64.0", "expo": "workspace:*", + "expo-age-range": "workspace:*", + "expo-apple-authentication": "workspace:*", "expo-application": "workspace:*", "expo-asset": "workspace:*", "expo-audio": "workspace:*", @@ -53,11 +55,13 @@ "expo-intent-launcher": "workspace:*", "expo-linear-gradient": "workspace:*", "expo-linking": "workspace:*", + "expo-live-photo": "workspace:*", "expo-local-authentication": "workspace:*", "expo-localization": "workspace:*", "expo-location": "workspace:*", "expo-mail-composer": "workspace:*", "expo-media-library": "workspace:*", + "expo-mesh-gradient": "workspace:*", "expo-navigation-bar": "workspace:*", "expo-network": "workspace:*", "expo-notifications": "workspace:*", diff --git a/docs/pages/router/advanced/router-settings.mdx b/docs/pages/router/advanced/router-settings.mdx index d713aa2a3c0aed..76fe8e713b0f20 100644 --- a/docs/pages/router/advanced/router-settings.mdx +++ b/docs/pages/router/advanced/router-settings.mdx @@ -44,13 +44,13 @@ export const unstable_settings = { }; ``` -The `initialRouteName` is only used when deep-linking to a route. During app navigation, the route you are navigating to will be the initial route. You can disable this behavior using the `initial` prop on the `` component or by passing the option to the imperative APIs. +The `initialRouteName` is only used when deep-linking to a route. During app navigation, the route you are navigating to will be the initial route. To also load the `initialRouteName` route below the target route, use the `withAnchor` prop on the `` component. The imperative APIs accept the same option. ```js -// If this navigates to a new _layout, don't override the initial route -; +// If this navigates to a new _layout, load the initial route below the target route +; -router.push('/route', { overrideInitialScreen: false }); +router.push('/route', { withAnchor: true }); ``` diff --git a/packages/@expo/cli/e2e/playwright/dev/server-loader.test.ts b/packages/@expo/cli/e2e/playwright/dev/server-loader.test.ts index a19d8c4744798b..4bbbaae6203a44 100644 --- a/packages/@expo/cli/e2e/playwright/dev/server-loader.test.ts +++ b/packages/@expo/cli/e2e/playwright/dev/server-loader.test.ts @@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test'; import { clearEnv, restoreEnv } from '../../__tests__/export/export-side-effects'; import { getRouterE2ERoot } from '../../__tests__/utils'; import { createExpoStart } from '../../utils/expo'; -import { pageCollectErrors, trackLoaderNetworkStatuses } from '../page'; +import { pageCollectErrors, trackLoaderNetworkStatuses, waitForLoaderData } from '../page'; test.beforeAll(() => clearEnv()); test.afterAll(() => restoreEnv()); @@ -51,7 +51,7 @@ for (const outputMode of outputModes) { expect(loaderRequests).toHaveLength(0); await page.click('a[href="/posts/static-post-1"]'); - await page.waitForSelector('[data-testid="loader-result"]'); + await waitForLoaderData(page, { params: { postId: 'static-post-1' } }); expect(loaderRequests).toContainEqual( expect.stringContaining('/_expo/loaders/posts/static-post-1') ); @@ -85,17 +85,19 @@ for (const outputMode of outputModes) { await page.goto(expoStart.url.href); await page.click('a[href="/posts/static-post-1"]'); - await page.waitForSelector('[data-testid="loader-result"]'); + await waitForLoaderData(page, { params: { postId: 'static-post-1' } }); await page.click('a[href="/"]'); + await waitForLoaderData(page, { data: 'root-index' }); await page.click('a[href="/posts/static-post-2"]'); - await page.waitForSelector('[data-testid="loader-result"]'); + await waitForLoaderData(page, { params: { postId: 'static-post-2' } }); await page.click('a[href="/"]'); + await waitForLoaderData(page, { data: 'root-index' }); await page.click('a[href="/posts/static-post-1"]'); - await page.waitForSelector('[data-testid="loader-result"]'); + await waitForLoaderData(page, { params: { postId: 'static-post-1' } }); expect(loaderRequests).toEqual([ expect.stringContaining('/_expo/loaders/posts/static-post-1'), @@ -183,7 +185,7 @@ for (const outputMode of outputModes) { // Navigate to index route (has loader) await page.click('a[href="/"]'); - await page.waitForSelector('[data-testid="loader-result"]'); + await waitForLoaderData(page, { data: 'root-index' }); const loaderDataContent = await page.locator('[data-testid="loader-result"]').textContent(); expect(JSON.parse(loaderDataContent!)).toEqual({ data: 'root-index' }); @@ -208,6 +210,7 @@ for (const outputMode of outputModes) { // Navigate to posts route (has loader) await page.click('a[href="/posts/static-post-1"]'); + await waitForLoaderData(page, { params: { postId: 'static-post-1' } }); const postsLoaderDataContent = await page .locator('[data-testid="loader-result"]') .textContent(); diff --git a/packages/@expo/cli/e2e/playwright/page.ts b/packages/@expo/cli/e2e/playwright/page.ts index 0cfe673ee1e8d6..20ef84519854c7 100644 --- a/packages/@expo/cli/e2e/playwright/page.ts +++ b/packages/@expo/cli/e2e/playwright/page.ts @@ -1,4 +1,10 @@ -import type { ConsoleMessage, Page, Request } from '@playwright/test'; +import { expect, type ConsoleMessage, type Page, type Request } from '@playwright/test'; + +export async function waitForLoaderData(page: Page, data: unknown) { + await expect(page.locator('[data-testid="loader-result"]')).toHaveText( + JSON.stringify(data, null, 2) + ); +} /** Collect all console and thrown errors of the page */ export function pageCollectErrors(page: Page) { diff --git a/packages/@expo/cli/e2e/playwright/prod/server-loader.test.ts b/packages/@expo/cli/e2e/playwright/prod/server-loader.test.ts index 996b6976126d29..84d3004685beb1 100644 --- a/packages/@expo/cli/e2e/playwright/prod/server-loader.test.ts +++ b/packages/@expo/cli/e2e/playwright/prod/server-loader.test.ts @@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test'; import { clearEnv, restoreEnv } from '../../__tests__/export/export-side-effects'; import { getRouterE2ERoot } from '../../__tests__/utils'; import { createExpoServe, executeExpoAsync } from '../../utils/expo'; -import { pageCollectErrors, trackLoaderNetworkStatuses } from '../page'; +import { pageCollectErrors, trackLoaderNetworkStatuses, waitForLoaderData } from '../page'; test.beforeAll(() => clearEnv()); test.afterAll(() => restoreEnv()); @@ -56,7 +56,7 @@ test.describe('server loaders in production', () => { expect(loaderRequests).toHaveLength(0); await page.click('a[href="/posts/static-post-1"]'); - await page.waitForSelector('[data-testid="loader-result"]'); + await waitForLoaderData(page, { params: { postId: 'static-post-1' } }); expect(loaderRequests).toContainEqual(expect.stringContaining('/_expo/loaders/posts')); const loaderDataContent = await page.locator('[data-testid="loader-result"]').textContent(); @@ -74,17 +74,19 @@ test.describe('server loaders in production', () => { await page.goto(expoServe.url.href); await page.click('a[href="/posts/static-post-1"]'); - await page.waitForSelector('[data-testid="loader-result"]'); + await waitForLoaderData(page, { params: { postId: 'static-post-1' } }); await page.click('a[href="/"]'); + await waitForLoaderData(page, { data: 'root-index' }); await page.click('a[href="/posts/static-post-2"]'); - await page.waitForSelector('[data-testid="loader-result"]'); + await waitForLoaderData(page, { params: { postId: 'static-post-2' } }); await page.click('a[href="/"]'); + await waitForLoaderData(page, { data: 'root-index' }); await page.click('a[href="/posts/static-post-1"]'); - await page.waitForSelector('[data-testid="loader-result"]'); + await waitForLoaderData(page, { params: { postId: 'static-post-1' } }); expect(loaderRequests).toEqual([ expect.stringContaining('/_expo/loaders/posts/static-post-1'), @@ -168,11 +170,11 @@ test.describe('server loaders in production', () => { url.pathname = '/no-loader'; // Start on no loader route - await page.goto(url.toString()); + await page.goto(url.toString(), { waitUntil: 'networkidle' }); // Navigate to index route (has loader) await page.click('a[href="/"]'); - await page.waitForSelector('[data-testid="loader-result"]'); + await waitForLoaderData(page, { data: 'root-index' }); const loaderDataContent = await page.locator('[data-testid="loader-result"]').textContent(); expect(JSON.parse(loaderDataContent!)).toEqual({ data: 'root-index' }); @@ -197,6 +199,7 @@ test.describe('server loaders in production', () => { // Navigate to posts route (has loader) await page.click('a[href="/posts/static-post-1"]'); + await waitForLoaderData(page, { params: { postId: 'static-post-1' } }); const postsLoaderDataContent = await page .locator('[data-testid="loader-result"]') .textContent(); diff --git a/packages/@expo/cli/e2e/playwright/prod/static-loader.test.ts b/packages/@expo/cli/e2e/playwright/prod/static-loader.test.ts index a244a457fdc621..0eb6b7069e0e8d 100644 --- a/packages/@expo/cli/e2e/playwright/prod/static-loader.test.ts +++ b/packages/@expo/cli/e2e/playwright/prod/static-loader.test.ts @@ -3,7 +3,7 @@ import { test, expect } from '@playwright/test'; import { clearEnv, restoreEnv } from '../../__tests__/export/export-side-effects'; import { getRouterE2ERoot } from '../../__tests__/utils'; import { createExpoServe, executeExpoAsync } from '../../utils/expo'; -import { pageCollectErrors } from '../page'; +import { pageCollectErrors, waitForLoaderData } from '../page'; test.beforeAll(() => clearEnv()); test.afterAll(() => restoreEnv()); @@ -51,7 +51,7 @@ test.describe('static loaders in production', () => { expect(loaderRequests).toHaveLength(0); await page.click('a[href="/posts/static-post-1"]'); - await page.waitForSelector('[data-testid="loader-result"]'); + await waitForLoaderData(page, { params: { postId: 'static-post-1' } }); expect(loaderRequests).toContainEqual( expect.stringContaining('/_expo/loaders/posts/static-post-1') ); @@ -71,17 +71,19 @@ test.describe('static loaders in production', () => { await page.goto(expoServe.url.href); await page.click('a[href="/posts/static-post-1"]'); - await page.waitForSelector('[data-testid="loader-result"]'); + await waitForLoaderData(page, { params: { postId: 'static-post-1' } }); await page.click('a[href="/"]'); + await waitForLoaderData(page, { data: 'root-index' }); await page.click('a[href="/posts/static-post-2"]'); - await page.waitForSelector('[data-testid="loader-result"]'); + await waitForLoaderData(page, { params: { postId: 'static-post-2' } }); await page.click('a[href="/"]'); + await waitForLoaderData(page, { data: 'root-index' }); await page.click('a[href="/posts/static-post-1"]'); - await page.waitForSelector('[data-testid="loader-result"]'); + await waitForLoaderData(page, { params: { postId: 'static-post-1' } }); expect(loaderRequests).toEqual([ expect.stringContaining('/_expo/loaders/posts/static-post-1'), @@ -128,11 +130,11 @@ test.describe('static loaders in production', () => { url.pathname = '/no-loader'; // Start on no loader route - await page.goto(url.toString()); + await page.goto(url.toString(), { waitUntil: 'networkidle' }); // Navigate to index route (has loader) await page.click('a[href="/"]'); - await page.waitForSelector('[data-testid="loader-result"]'); + await waitForLoaderData(page, { data: 'root-index' }); const loaderDataContent = await page.locator('[data-testid="loader-result"]').textContent(); expect(JSON.parse(loaderDataContent!)).toEqual({ data: 'root-index' }); @@ -157,6 +159,7 @@ test.describe('static loaders in production', () => { // Navigate to posts route (has loader) await page.click('a[href="/posts/static-post-1"]'); + await waitForLoaderData(page, { params: { postId: 'static-post-1' } }); const postsLoaderDataContent = await page .locator('[data-testid="loader-result"]') .textContent(); diff --git a/packages/@expo/env/CHANGELOG.md b/packages/@expo/env/CHANGELOG.md index bddcf52ba37569..343182cc4595a2 100644 --- a/packages/@expo/env/CHANGELOG.md +++ b/packages/@expo/env/CHANGELOG.md @@ -10,6 +10,7 @@ ### 🐛 Bug fixes +- Block dotenv files from setting `__EXPO_CONFIG_MODE` through `EXPO_UNSAFE_DOTENV_KEYS`. ([#49417](https://github.com/expo/expo/pull/49417) by [@ramonclaudio](https://github.com/ramonclaudio)) - Set `NODE_ENV` when `loadProjectEnv` receives a development or production mode. ([#48554](https://github.com/expo/expo/pull/48554) by [@ramonclaudio](https://github.com/ramonclaudio)) ### 💡 Others diff --git a/packages/@expo/env/src/__tests__/index.test.ts b/packages/@expo/env/src/__tests__/index.test.ts index 9a79379589a2b2..92d944ad4f6e3f 100644 --- a/packages/@expo/env/src/__tests__/index.test.ts +++ b/packages/@expo/env/src/__tests__/index.test.ts @@ -527,6 +527,27 @@ describe(getOriginalEnv, () => { expect(getOriginalEnv(inheritedEnv)).toEqual({ PRE_EXISTING: 'original' }); }); + it('keeps inherited dotenv values listed in EXPO_UNSAFE_DOTENV_KEYS', () => { + const prev = process.env.EXPO_UNSAFE_DOTENV_KEYS; + process.env.EXPO_UNSAFE_DOTENV_KEYS = 'DOTENV_VALUE'; + try { + jest.isolateModules(() => { + const mod = require('../'); + const inheritedEnv = { + DOTENV_VALUE: 'from-parent-dotenv', + [LOADED_ENV_NAME]: JSON.stringify(['DOTENV_VALUE']), + }; + + expect(mod.getOriginalEnv(inheritedEnv)).toEqual({ + DOTENV_VALUE: 'from-parent-dotenv', + }); + }); + } finally { + if (prev === undefined) delete process.env.EXPO_UNSAFE_DOTENV_KEYS; + else process.env.EXPO_UNSAFE_DOTENV_KEYS = prev; + } + }); + it('removes inherited dotenv values after a forced local load', () => { const inheritedEnv = { FOO: 'from-parent-dotenv', @@ -819,6 +840,22 @@ describe('isLocalEnvKey policy', () => { expect(() => parseProjectEnv('/', { systemEnv: {} })).toThrow(/__EXPO_CONFIG_MODE/); }); + it('keeps __EXPO_CONFIG_MODE blocked when the unsafe list includes it', () => { + const prev = process.env.EXPO_UNSAFE_DOTENV_KEYS; + process.env.EXPO_UNSAFE_DOTENV_KEYS = '__EXPO_CONFIG_MODE'; + try { + jest.isolateModules(() => { + const mod = require('../'); + vol.fromJSON({ '.env': '__EXPO_CONFIG_MODE=production' }, '/'); + + expect(() => mod.parseProjectEnv('/', { systemEnv: {} })).toThrow(/__EXPO_CONFIG_MODE/); + }); + } finally { + if (prev === undefined) delete process.env.EXPO_UNSAFE_DOTENV_KEYS; + else process.env.EXPO_UNSAFE_DOTENV_KEYS = prev; + } + }); + it('combines both violation classes into a single thrown error', () => { process.env.NODE_ENV = 'development'; delete process.env.ANDROID_HOME; diff --git a/packages/@expo/env/src/constants.ts b/packages/@expo/env/src/constants.ts index 0fd7caac31cb94..9340698716315a 100644 --- a/packages/@expo/env/src/constants.ts +++ b/packages/@expo/env/src/constants.ts @@ -6,7 +6,7 @@ const platform = os.platform(); const safeKeys = new Set(process.env.EXPO_UNSAFE_DOTENV_KEYS?.split(',').filter((x) => !!x)); export function isUnsafeAllowedEnvKey(name: string): boolean { - return safeKeys.has(name); + return name !== '__EXPO_CONFIG_MODE' && safeKeys.has(name); } export function isIgnoredEnvKey(name: string) { @@ -14,7 +14,7 @@ export function isIgnoredEnvKey(name: string) { return true; } else if (platform === 'linux' && name.startsWith('LD_')) { return true; - } else if (safeKeys.has(name)) { + } else if (isUnsafeAllowedEnvKey(name)) { return false; } diff --git a/packages/@expo/env/src/index.ts b/packages/@expo/env/src/index.ts index ebcec202ce5274..b7794bd204e1fb 100644 --- a/packages/@expo/env/src/index.ts +++ b/packages/@expo/env/src/index.ts @@ -245,7 +245,7 @@ function formatViolationFiles(byFile: Record): string { function formatBlockedViolation(byFile: Record): string { return [ 'Refused to load dangerous environment variables from .env files.', - 'Opt in via EXPO_UNSAFE_DOTENV_KEYS in your shell environment if you truly need them.', + 'Use EXPO_UNSAFE_DOTENV_KEYS in your shell environment to allow a non-internal key.', '', formatViolationFiles(byFile), ].join('\n'); diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index abdb00174e49c6..34ab8ed6559053 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -62,6 +62,7 @@ ### 🐛 Bug fixes +- Fix `useLoaderData()` throwing "Update hook called on initial render" when React replays a suspended route after its loader settles during a transition. ([#49351](https://github.com/expo/expo/pull/49351) by [@Ubax](https://github.com/Ubax)) - Make layouts with explicitly declared screens honor `unstable_settings.initialRouteName` instead of declaration order, which can change deep-link back stacks. ([#48708](https://github.com/expo/expo/pull/48708) by [@Ubax](https://github.com/Ubax)) - Prevent unfocused nested native tab navigators from redirecting global router state. ([#48257](https://github.com/expo/expo/pull/48257) by [@Ubax](https://github.com/Ubax)) - Fixed `Tabs` and `TopTabs` (`expo-router/js-tabs`, `expo-router/js-top-tabs`) not being usable from RSC ([#48330](https://github.com/expo/expo/pull/48330) by [@Ubax](https://github.com/Ubax)) @@ -78,6 +79,7 @@ ### 💡 Others +- 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)) - Move Expo Router store values into React context. ([#49218](https://github.com/expo/expo/pull/49218) by [@Ubax](https://github.com/Ubax)) diff --git a/packages/expo-router/src/ExpoRoot.tsx b/packages/expo-router/src/ExpoRoot.tsx index 502de156b412dd..2be4c4fc2086d3 100644 --- a/packages/expo-router/src/ExpoRoot.tsx +++ b/packages/expo-router/src/ExpoRoot.tsx @@ -10,6 +10,7 @@ import { NavigationContainer as UpstreamNavigationContainer } from './fork/Navig import type { ExpoLinkingOptions } from './getLinkingConfig'; import { useStore } from './global-state/router-store'; 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 { shouldAppendNotFound, shouldAppendSitemap } from './global-state/utils'; @@ -81,7 +82,11 @@ export function ExpoRoot({ wrapper: ParentWrapper = Fragment, ...props }: ExpoRo [ParentWrapper] ); - return ; + return ( + + + + ); } const initialUrl = diff --git a/packages/expo-router/src/Prefetch.tsx b/packages/expo-router/src/Prefetch.tsx index b646a53730d484..911199e3dac65d 100644 --- a/packages/expo-router/src/Prefetch.tsx +++ b/packages/expo-router/src/Prefetch.tsx @@ -14,6 +14,7 @@ export type PreloadProps = { export function Prefetch(props: PreloadProps) { const navigation = useOptionalNavigation(); + // TODO(@ubax): This layout effect runs before the router bridge binds on the first commit. It works only because useOptionalNavigation returns null on the first pass. Move to useRouterActions(). useLayoutEffect(() => { if (navigation?.isFocused()) { router.prefetch(props.href); diff --git a/packages/expo-router/src/__tests__/navigation.test.ios.tsx b/packages/expo-router/src/__tests__/navigation.test.ios.tsx index e38132aec9ee3a..bfc0e290eeeffa 100644 --- a/packages/expo-router/src/__tests__/navigation.test.ios.tsx +++ b/packages/expo-router/src/__tests__/navigation.test.ios.tsx @@ -17,6 +17,21 @@ import { Tabs } from '../layouts/Tabs'; import { Link, Redirect } from '../link'; import { renderRouter, screen } from '../testing-library'; +it('throws when navigating before the first render finishes', () => { + expect(() => + renderRouter({ + index: function MyIndexRoute() { + router.push('/profile/test-name'); + return Press me; + }, + '/profile/[name]': function MyRoute() { + const { name } = useGlobalSearchParams(); + return {name}; + }, + }) + ).toThrow('The imperative router is unavailable before the first render has finished.'); +}); + it('should respect `unstable_settings', () => { const render = (options: any = {}) => renderRouter( @@ -121,23 +136,6 @@ describe('hooks only', () => { }); describe('imperative only', () => { - // The navigation action is offloaded until the navigation tree is ready. - it('can navigate before navigation is ready', async () => { - renderRouter({ - index: function MyIndexRoute() { - router.push('/profile/test-name'); - return Press me; - }, - '/profile/[name]': function MyRoute() { - const { name } = useGlobalSearchParams(); - return {name}; - }, - }); - - expect(screen.queryByTestId('index')).toBeNull(); - expect(screen.getByTestId('profile-name')).toBeOnTheScreen(); - }); - it('can handle navigation between routes', async () => { renderRouter({ index: function MyIndexRoute() { diff --git a/packages/expo-router/src/domComponents/useDomComponentNavigation.ts b/packages/expo-router/src/domComponents/useDomComponentNavigation.ts index eb73a5e3204732..14cc1ec80c4c59 100644 --- a/packages/expo-router/src/domComponents/useDomComponentNavigation.ts +++ b/packages/expo-router/src/domComponents/useDomComponentNavigation.ts @@ -1,7 +1,7 @@ import { addGlobalDomEventListener } from 'expo/dom/global'; import React from 'react'; -import { dismiss, dismissAll, goBack, linkTo, setParams } from '../global-state/routing'; +import { useRouterActions } from '../global-state/useRouterActions'; import { ROUTER_LINK_TYPE, ROUTER_DISMISS_ALL_TYPE, @@ -11,6 +11,8 @@ import { } from './events'; export function useDomComponentNavigation() { + const router = useRouterActions(); + React.useEffect(() => { if (process.env.EXPO_OS === 'web') { return () => {}; @@ -18,21 +20,21 @@ export function useDomComponentNavigation() { return addGlobalDomEventListener(({ type, data }) => { switch (type) { case ROUTER_LINK_TYPE: - linkTo(data.href, data.options); + router.linkTo(data.href, data.options); break; case ROUTER_DISMISS_ALL_TYPE: - dismissAll(); + router.dismissAll(); break; case ROUTER_DISMISS_TYPE: - dismiss(data.count); + router.dismiss(data.count); break; case ROUTER_BACK_TYPE: - goBack(); + router.back(); break; case ROUTER_SET_PARAMS_TYPE: - setParams(data.params); + router.setParams(data.params); break; } }); - }, []); + }, [router]); } diff --git a/packages/expo-router/src/fork/NavigationContainer.tsx b/packages/expo-router/src/fork/NavigationContainer.tsx index 36fa9d0e9c1c05..6a1c086fe8a8a4 100644 --- a/packages/expo-router/src/fork/NavigationContainer.tsx +++ b/packages/expo-router/src/fork/NavigationContainer.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { I18nManager } from 'react-native'; +import { RoutingQueueApiContext, RoutingQueueProvider } from '../global-state/routingQueueContext'; import { syncStoreNavigationState } from '../global-state/store'; import { StoreContext } from '../global-state/storeContext'; import type { @@ -196,7 +197,20 @@ function NavigationContainerInner( ); } -export const NavigationContainer = React.forwardRef(NavigationContainerInner) as < +const NavigationContainerContent = React.forwardRef(NavigationContainerInner); + +// TODO(@ubax): Remove this component once we require single container in the whole app +function NavigationContainerWithQueue( + props: Props, + ref?: React.Ref | null> +) { + const api = React.use(RoutingQueueApiContext); + const content = ; + + return api === undefined ? {content} : content; +} + +export const NavigationContainer = React.forwardRef(NavigationContainerWithQueue) as < RootParamList extends object = ReactNavigation.RootParamList, >( props: Props & { diff --git a/packages/expo-router/src/fork/__tests__/__fixtures__/store.tsx b/packages/expo-router/src/fork/__tests__/__fixtures__/store.tsx index d54b660e819801..02fd81e72e467d 100644 --- a/packages/expo-router/src/fork/__tests__/__fixtures__/store.tsx +++ b/packages/expo-router/src/fork/__tests__/__fixtures__/store.tsx @@ -2,8 +2,13 @@ import { render as renderWithoutStore, renderHook as renderHookWithoutStore, } from '@testing-library/react-native'; -import type { ReactElement, ReactNode } from 'react'; +import { use, type ReactElement, type ReactNode } from 'react'; +import { + PendingIntentsContext, + 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'; @@ -26,8 +31,24 @@ export const storeValue: StoreContextValue = { redirects: [], }; +let pendingIntents: RoutingIntent[] = []; + +function PendingIntentsProbe() { + pendingIntents = use(PendingIntentsContext); + return null; +} + +export function getPendingIntents() { + return pendingIntents; +} + export function StoreProvider({ children }: { children: ReactNode }) { - return {children}; + return ( + + {children} + + + ); } export function render(element: ReactElement): ReturnType { 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 2ad185e99e4bc6..67d17909c5abd4 100644 --- a/packages/expo-router/src/fork/__tests__/useLinking.test.ios.tsx +++ b/packages/expo-router/src/fork/__tests__/useLinking.test.ios.tsx @@ -3,12 +3,11 @@ import { act, type RenderAPI } from '@testing-library/react-native'; import { Text } from 'react-native'; import { node } from '../../global-state/__tests__/__fixtures__/routeNode'; -import { routingQueue } from '../../global-state/routingQueue'; import { store, storeRef as mockStoreRef } from '../../global-state/store'; import { createNavigationContainerRef, type ParamListBase } from '../../react-navigation/core'; import { NavigationContainer } from '../NavigationContainer'; import { useLinking } from '../useLinking'; -import { render, renderHook } from './__fixtures__/store'; +import { getPendingIntents, render, renderHook } from './__fixtures__/store'; let errorSpy: jest.SpiedFunction | undefined; @@ -24,7 +23,6 @@ function getParsedHomeState() { } beforeEach(() => { - routingQueue.queue = []; mockStoreRef.current.routeNode = node('root', [node('home', [node('[id]')])]); mockStoreRef.current.state = undefined; }); @@ -59,10 +57,10 @@ test('queues an incoming deep link using its extracted app path', () => { } render(); - listener?.('example://home?from=link'); + act(() => listener?.('example://home?from=link')); expect(getStateFromPath).toHaveBeenCalledWith('home?from=link', undefined, []); - expect(routingQueue.queue).toEqual([ + expect(getPendingIntents()).toEqual([ { type: 'NAVIGATE_TO_HREF', payload: { @@ -100,10 +98,10 @@ test('reports an incoming deep link using its extracted app path', () => { } render(); - listener?.('myapp://foo/bar'); + act(() => listener?.('myapp://foo/bar')); expect(onUnhandledLinking).toHaveBeenCalledWith('/foo/bar'); - expect(routingQueue.queue[0]).toMatchObject({ + expect(getPendingIntents()[0]).toMatchObject({ payload: { href: '/foo/bar' }, }); }); @@ -178,12 +176,12 @@ test('resubscribes on re-render and cleans up the previous subscription', () => const element = render(); element.rerender(); - listeners[1]?.('example://home'); + act(() => listeners[1]?.('example://home')); expect(subscribe).toHaveBeenCalledTimes(2); expect(unsubscribes[0]).toHaveBeenCalledTimes(1); expect(unsubscribes[1]).not.toHaveBeenCalled(); - expect(routingQueue.queue).toMatchObject([ + expect(getPendingIntents()).toMatchObject([ { type: 'NAVIGATE_TO_HREF', payload: { href: '/home' } }, ]); }); 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 a90fc1b523faab..e26cc8e38bfcad 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 { routingQueue } from '../../global-state/routingQueue'; import { storeRef as mockStoreRef } from '../../global-state/store'; import { getRootStackRouteNames } from '../../global-state/utils'; import { getStateFromPath } from '../../link/linking'; @@ -19,7 +18,7 @@ import { getMockConfig } from '../../testing-library/mock-config'; import { NavigationContainer } from '../NavigationContainer'; import { createMemoryHistory } from '../createMemoryHistory'; import { useLinking } from '../useLinking'; -import { render } from './__fixtures__/store'; +import { getPendingIntents, render } from './__fixtures__/store'; jest.mock('../createMemoryHistory'); let mockNavigationRef: ReturnType; @@ -47,7 +46,6 @@ function EmptyScreen() { beforeEach(() => { mockStoreRef.current.state = undefined; mockStoreRef.current.routeNode = null; - routingQueue.queue = []; jest.mocked(getRootStackRouteNames).mockReturnValue(['home']); jest.mocked(createMemoryHistory).mockReturnValue(history); Object.defineProperty(globalThis, 'location', { @@ -107,7 +105,7 @@ test('queues forward history navigation', () => { emitPopState('/forward', 4); expect(getStateFromPath).toHaveBeenCalledWith('/forward', undefined, []); - expect(routingQueue.queue).toEqual([ + expect(getPendingIntents()).toEqual([ { type: 'NAVIGATE_TO_HREF', payload: { href: '/forward', options: { event: 'NAVIGATE' } }, @@ -134,7 +132,7 @@ test('restores saved history state without parsing its path', () => { emitPopState('/saved', 3); expect(getStateFromPath).not.toHaveBeenCalled(); - expect(routingQueue.queue).toEqual([ + expect(getPendingIntents()).toEqual([ { type: 'ACTION', payload: { @@ -156,7 +154,7 @@ test('restores state parsed from a history path', () => { emitPopState('/parsed', 2); expect(getStateFromPath).toHaveBeenCalledWith('/parsed', undefined, []); - const parsedIntent = routingQueue.queue[0]; + const parsedIntent = getPendingIntents()[0]; expect(parsedIntent).toMatchObject({ type: 'ACTION', payload: { action: { type: 'RESET', target: expect.any(String) } }, @@ -184,7 +182,7 @@ test('restores initial state when a history path cannot be parsed', () => { emitPopState('/invalid', 1); expect(getStateFromPath).toHaveBeenCalledWith('/invalid', undefined, []); - expect(routingQueue.queue).toEqual([ + expect(getPendingIntents()).toEqual([ { type: 'ACTION', payload: { action: { type: 'RESET', payload: initialState, target: 'root' } }, @@ -239,7 +237,7 @@ test('keeps the current route group when parsing a popstate path', () => { act(() => historyListener?.()); expect(parsePath).toHaveBeenCalledWith('/shared', config, ['(b)', 'other']); - expect(routingQueue.queue).toEqual([ + expect(getPendingIntents()).toEqual([ { type: 'NAVIGATE_TO_HREF', payload: { href: '/shared', options: { event: 'NAVIGATE' } }, diff --git a/packages/expo-router/src/fork/useLinking.native.ts b/packages/expo-router/src/fork/useLinking.native.ts index fabbc5c687d374..9a001717f4f94f 100644 --- a/packages/expo-router/src/fork/useLinking.native.ts +++ b/packages/expo-router/src/fork/useLinking.native.ts @@ -6,7 +6,7 @@ import { createSeededRootState, } from '../global-state/createSeededNavigationState'; import { getRouteInfoFromState } from '../global-state/getRouteInfoFromState'; -import { routingQueue } from '../global-state/routingQueue'; +import { useEnqueueRoutingIntent } from '../global-state/routingQueueContext'; import { StoreContext } from '../global-state/storeContext'; import { type LinkingOptions, @@ -60,6 +60,7 @@ export function useLinking( onUnhandledLinking: (lastUnhandledLining: string | undefined) => void ) { const store = use(StoreContext); + const enqueue = useEnqueueRoutingIntent(); useEffect(() => { if (process.env.NODE_ENV === 'production') { @@ -180,7 +181,7 @@ export function useLinking( return; } - routingQueue.add({ + enqueue({ type: 'NAVIGATE_TO_HREF', payload: { href: path, @@ -192,7 +193,7 @@ export function useLinking( }; return subscribe(listener); - }, [getStateFromURL, onUnhandledLinking, prefixes, ref, subscribe]); + }, [enqueue, getStateFromURL, onUnhandledLinking, prefixes, ref, subscribe]); return { getInitialState, diff --git a/packages/expo-router/src/fork/useLinking.ts b/packages/expo-router/src/fork/useLinking.ts index bf7aa0be14d89c..53b4432d1adca7 100644 --- a/packages/expo-router/src/fork/useLinking.ts +++ b/packages/expo-router/src/fork/useLinking.ts @@ -6,7 +6,8 @@ import { createSeededRootState, } from '../global-state/createSeededNavigationState'; import { getRouteInfoFromState } from '../global-state/getRouteInfoFromState'; -import { routingQueue, type RoutingIntent } from '../global-state/routingQueue'; +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 { @@ -180,6 +181,7 @@ function useBrowserHistorySync({ onUnhandledLinking: (path: string | undefined) => void; }) { const store = use(StoreContext); + const enqueue = useEnqueueRoutingIntent(); const [history] = useState(createMemoryHistory); const configRef = useRef(config); const getStateFromPathRef = useRef(getStateFromPath); @@ -221,7 +223,7 @@ function useBrowserHistorySync({ pendingHistoryOperationsRef.current.push(metadata.history); } }; - routingQueue.add(intent); + enqueue(intent); }; const reset = (state: ResetState) => ({ type: 'RESET', @@ -289,7 +291,7 @@ function useBrowserHistorySync({ unsubscribe(); pendingHistoryOperationsRef.current = []; }; - }, [history, onUnhandledLinking, ref]); + }, [enqueue, history, onUnhandledLinking, ref]); useEffect(() => { const getPathForRoute = ( diff --git a/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx b/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx index 5e9a006ee687f8..1540f984b2bf3f 100644 --- a/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx +++ b/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx @@ -2,9 +2,8 @@ import * as React from 'react'; -import { routingQueue, type RoutingIntent } from './routingQueue'; - -const drainers: symbol[] = []; +import type { RoutingIntent } from './routingQueue'; +import { PendingIntentsContext, RoutingQueueApiContext } from './routingQueueContext'; type Props = { ready: boolean; @@ -12,44 +11,18 @@ type Props = { }; export function RoutingQueueDrainer({ ready, processIntent }: Props) { - const intents = React.useSyncExternalStore( - routingQueue.subscribe, - routingQueue.snapshot, - routingQueue.snapshot - ); - - React.useEffect(() => { - if (process.env.NODE_ENV === 'production') { - return undefined; - } - - if (drainers.length) { - console.error( - [ - 'Looks like you have multiple navigation containers draining the shared routing queue. Only one container will receive queued actions, while the others will drop them. Make sure that:', - "- You don't have multiple NavigationContainers in the app", - '- Only a single instance of the root component is rendered', - ].join('\n') - ); - } - - // TODO(@ubax): move routingQueue into a per-container context so sibling containers each drain their own queue and pending intents are cleared on unmount. - const drainer = Symbol(); - drainers.push(drainer); - - return () => { - const index = drainers.indexOf(drainer); - if (index > -1) { - drainers.splice(index, 1); - } - }; - }, []); + const intents = React.use(PendingIntentsContext); + const { dequeue } = React.use(RoutingQueueApiContext)!; + const lastProcessed = React.useRef(undefined); React.useEffect(() => { - if (!ready || intents.length === 0) { + if (!ready || intents.length === 0 || lastProcessed.current === intents) { return; } - for (const intent of routingQueue.drain(intents)) { + // Strict Mode re-runs the mount effect with the same array before `dequeue` updates state. + lastProcessed.current = intents; + dequeue(intents); + for (const intent of intents) { // Only catches errors thrown while dispatching. The navigation reducer runs // during the next render, so errors from it surface there, not here. try { @@ -67,7 +40,7 @@ export function RoutingQueueDrainer({ ready, processIntent }: Props) { ); } } - }, [intents, processIntent, ready]); + }, [dequeue, intents, processIntent, ready]); return null; } 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 a3273ad18a43a9..2159ef37590620 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 @@ -2,24 +2,8 @@ import { act, render } from '@testing-library/react-native'; import * as React from 'react'; import { RoutingQueueDrainer } from '../RoutingQueueDrainer'; -import { routingQueue, type RoutingIntent } from '../routingQueue'; - -const multipleDrainersError = [ - 'Looks like you have multiple navigation containers draining the shared routing queue. Only one container will receive queued actions, while the others will drop them. Make sure that:', - "- You don't have multiple NavigationContainers in the app", - '- Only a single instance of the root component is rendered', -].join('\n'); - -let error: jest.SpyInstance | undefined; - -beforeEach(() => { - routingQueue.queue = []; - routingQueue.subscribers.clear(); -}); - -afterEach(() => { - error?.mockRestore(); -}); +import type { RoutingIntent } from '../routingQueue'; +import { RoutingQueueProvider, useEnqueueRoutingIntent } from '../routingQueueContext'; function actionIntent(type: string): RoutingIntent { return { type: 'ACTION', payload: { action: { type } } }; @@ -32,55 +16,74 @@ function actionType(intent: RoutingIntent): string { return intent.payload.action.type; } +function renderDrainer(ready: boolean, processIntent: (intent: RoutingIntent) => void) { + let enqueue: ReturnType; + + function CaptureEnqueue() { + enqueue = useEnqueueRoutingIntent(); + return null; + } + + const result = render( + + + + + ); + + return { ...result, enqueue: (intent: RoutingIntent) => enqueue(intent) }; +} + it('isolates queue notifications from its parent', () => { const parentRender = jest.fn(); const processIntent = jest.fn(); + let enqueue: ReturnType; + + function Consumer() { + enqueue = useEnqueueRoutingIntent(); + return ; + } function Parent() { parentRender(); - return ; + return ( + + + + ); } render(); - act(() => routingQueue.add(actionIntent('TEST'))); + act(() => enqueue(actionIntent('TEST'))); expect(parentRender).toHaveBeenCalledTimes(1); expect(processIntent).toHaveBeenCalledWith(actionIntent('TEST')); }); -it('logs an error when multiple drainers are mounted', () => { - error = jest.spyOn(console, 'error').mockImplementation(() => {}); - - render(); - render(); - - expect(error).toHaveBeenCalledTimes(1); - expect(error).toHaveBeenCalledWith(multipleDrainersError); -}); - -it('cleans up the mounted drainer when it unmounts', () => { - error = jest.spyOn(console, 'error').mockImplementation(() => {}); - const first = render(); - - first.unmount(); - render(); - - expect(error).not.toHaveBeenCalled(); -}); - it('keeps intents queued until ready', () => { const processIntent = jest.fn(); - const result = render(); + let enqueue: ReturnType; - act(() => routingQueue.add(actionIntent('TEST'))); + function Tree({ ready }: { ready: boolean }) { + enqueue = useEnqueueRoutingIntent(); + return ; + } + const result = render( + + + + ); + act(() => enqueue(actionIntent('TEST'))); expect(processIntent).not.toHaveBeenCalled(); - expect(routingQueue.snapshot()).toEqual([actionIntent('TEST')]); - result.rerender(); + result.rerender( + + + + ); expect(processIntent).toHaveBeenCalledWith(actionIntent('TEST')); - expect(routingQueue.snapshot()).toEqual([]); }); it('processes a queued batch in FIFO order', () => { @@ -88,29 +91,45 @@ it('processes a queued batch in FIFO order', () => { 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')); - render(); + const result = renderDrainer(true, processIntent); act(() => { - routingQueue.add(actionIntent('FIRST')); - routingQueue.add({ + result.enqueue(actionIntent('FIRST')); + result.enqueue({ type: 'NAVIGATOR_ACTION', payload: { action: { type: 'SECOND' }, dispatchSync }, onDispatch, }); - routingQueue.add(actionIntent('THIRD')); + result.enqueue(actionIntent('THIRD')); }); expect(calls).toEqual(['FIRST', 'onDispatch', 'NAVIGATOR_ACTION', 'THIRD']); expect(dispatchSync).toHaveBeenCalledWith({ type: 'SECOND' }); }); -it('does not process a snapshot twice in Strict Mode', () => { +it('does not process a batch twice in Strict Mode', () => { const processIntent = jest.fn(); - routingQueue.add(actionIntent('TEST')); + let enqueue: ReturnType; + + function CaptureEnqueue() { + enqueue = useEnqueueRoutingIntent(); + return null; + } - render( + const result = render( - + + + + + ); + act(() => enqueue(actionIntent('TEST'))); + result.rerender( + + + + + ); @@ -119,30 +138,30 @@ it('does not process a snapshot twice in Strict Mode', () => { it('processes intents added while draining in a later batch', () => { const processed: string[] = []; + let enqueue: (intent: RoutingIntent) => void; const processIntent = jest.fn((intent: RoutingIntent) => { processed.push(actionType(intent)); if (actionType(intent) === 'FIRST') { - routingQueue.add(actionIntent('SECOND')); + enqueue(actionIntent('SECOND')); } }); - render(); + const result = renderDrainer(true, processIntent); + enqueue = result.enqueue; - act(() => routingQueue.add(actionIntent('FIRST'))); + act(() => enqueue(actionIntent('FIRST'))); expect(processed).toEqual(['FIRST', 'SECOND']); }); -it('unsubscribes when unmounted', () => { +it('drops pending intents when the provider unmounts', () => { const processIntent = jest.fn(); - const result = render(); - expect(routingQueue.subscribers.size).toBe(1); - result.unmount(); + const first = renderDrainer(false, processIntent); + act(() => first.enqueue(actionIntent('TEST'))); - act(() => routingQueue.add(actionIntent('TEST'))); + first.unmount(); + renderDrainer(true, processIntent); - expect(routingQueue.subscribers.size).toBe(0); expect(processIntent).not.toHaveBeenCalled(); - expect(routingQueue.snapshot()).toEqual([actionIntent('TEST')]); }); it('continues after processIntent throws synchronously', () => { @@ -152,11 +171,11 @@ it('continues after processIntent throws synchronously', () => { throw new Error('failed'); } }); - render(); + const result = renderDrainer(true, processIntent); act(() => { - routingQueue.add(actionIntent('FIRST')); - routingQueue.add(actionIntent('SECOND')); + result.enqueue(actionIntent('FIRST')); + result.enqueue(actionIntent('SECOND')); }); expect(processIntent).toHaveBeenCalledTimes(2); 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 277e19c9caa182..4cf91de23cb0ce 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 @@ -4,6 +4,7 @@ import { emitDomDismiss, emitDomDismissAll, emitDomGoBack } from '../../domCompo import { canDismiss, canGoBack, + createImperativeRouter, dismiss, dismissAll, dismissTo, @@ -14,9 +15,9 @@ import { push, reload, replace, + router, setParams, } from '../router'; -import { routingQueue } from '../routingQueue'; import { store } from '../store'; jest.mock('../store', () => ({ @@ -39,12 +40,6 @@ jest.mock('../store', () => ({ }, })); -jest.mock('../routingQueue', () => ({ - routingQueue: { - add: jest.fn(), - }, -})); - jest.mock('expo/dom', () => ({ IS_DOM: false, })); @@ -65,16 +60,21 @@ jest.mock('../../link/href', () => ({ resolveHref: jest.fn((href: any) => (typeof href === 'string' ? href : href.pathname || '/')), })); -const mockAdd = routingQueue.add as jest.Mock; +const mockAdd = jest.fn(); const mockEmitDomDismiss = emitDomDismiss as jest.Mock; const mockEmitDomDismissAll = emitDomDismissAll as jest.Mock; const mockEmitDomGoBack = emitDomGoBack as jest.Mock; - beforeEach(() => { jest.clearAllMocks(); (store as any).state = undefined; }); +it('throws before the module-level router is installed', () => { + expect(() => navigate('/first')).toThrow('first render'); + + Object.assign(router, createImperativeRouter(mockAdd)); +}); + describe('canDismiss', () => { it('returns false when state is undefined', () => { (store as any).state = undefined; @@ -282,13 +282,13 @@ describe('router action functions', () => { }); it('prefetch enqueues NAVIGATE_TO_HREF intent with PRELOAD event', () => { - prefetch('/path'); + prefetch('/path', { withAnchor: true }); expect(mockAdd).toHaveBeenCalledWith( expect.objectContaining({ type: 'NAVIGATE_TO_HREF', payload: expect.objectContaining({ - options: expect.objectContaining({ event: 'PRELOAD' }), + options: expect.objectContaining({ event: 'PRELOAD', withAnchor: true }), }), }) ); diff --git a/packages/expo-router/src/global-state/__tests__/routingQueue.test.ios.ts b/packages/expo-router/src/global-state/__tests__/routingQueue.test.ios.ts deleted file mode 100644 index e9111acbdd2cd7..00000000000000 --- a/packages/expo-router/src/global-state/__tests__/routingQueue.test.ios.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { routingQueue } from '../routingQueue'; - -beforeEach(() => { - routingQueue.queue = []; - routingQueue.subscribers.clear(); -}); - -describe('routingQueue', () => { - it('adds intents and notifies subscribers', () => { - const callback = jest.fn(); - routingQueue.subscribe(callback); - - routingQueue.add({ type: 'ACTION', payload: { action: { type: 'GO_BACK' } } }); - - expect(routingQueue.snapshot()).toEqual([ - { type: 'ACTION', payload: { action: { type: 'GO_BACK' } } }, - ]); - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('unsubscribes subscribers', () => { - const callback = jest.fn(); - const unsubscribe = routingQueue.subscribe(callback); - unsubscribe(); - - routingQueue.add({ type: 'ACTION', payload: { action: { type: 'GO_BACK' } } }); - - expect(callback).not.toHaveBeenCalled(); - }); - - it('notifies every subscriber', () => { - const first = jest.fn(); - const second = jest.fn(); - routingQueue.subscribe(first); - routingQueue.subscribe(second); - - routingQueue.add({ type: 'ACTION', payload: { action: { type: 'GO_BACK' } } }); - - expect(first).toHaveBeenCalledTimes(1); - expect(second).toHaveBeenCalledTimes(1); - }); - - it('drains the current queue into a separate array', () => { - routingQueue.add({ type: 'ACTION', payload: { action: { type: 'GO_BACK' } } }); - routingQueue.add({ type: 'ACTION', payload: { action: { type: 'POP_TO_TOP' } } }); - const queued = routingQueue.snapshot(); - - expect(routingQueue.drain(queued)).toEqual([ - { type: 'ACTION', payload: { action: { type: 'GO_BACK' } } }, - { type: 'ACTION', payload: { action: { type: 'POP_TO_TOP' } } }, - ]); - expect(routingQueue.snapshot()).not.toBe(queued); - expect(routingQueue.snapshot()).toEqual([]); - }); - - it('does not drain a stale snapshot', () => { - routingQueue.add({ type: 'ACTION', payload: { action: { type: 'GO_BACK' } } }); - const staleSnapshot = routingQueue.snapshot(); - routingQueue.add({ type: 'ACTION', payload: { action: { type: 'POP_TO_TOP' } } }); - const currentSnapshot = routingQueue.snapshot(); - - expect(routingQueue.drain(staleSnapshot)).toEqual([]); - expect(routingQueue.snapshot()).toBe(currentSnapshot); - }); - - it('notifies subscribers when draining', () => { - const callback = jest.fn(); - routingQueue.subscribe(callback); - routingQueue.add({ type: 'ACTION', payload: { action: { type: 'GO_BACK' } } }); - const snapshot = routingQueue.snapshot(); - callback.mockClear(); - - routingQueue.drain(snapshot); - - expect(callback).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/expo-router/src/global-state/__tests__/routingQueueContext.test.ios.tsx b/packages/expo-router/src/global-state/__tests__/routingQueueContext.test.ios.tsx new file mode 100644 index 00000000000000..d5d8ae7aa31c21 --- /dev/null +++ b/packages/expo-router/src/global-state/__tests__/routingQueueContext.test.ios.tsx @@ -0,0 +1,172 @@ +import { act, render } from '@testing-library/react-native'; +import { use, type ContextType } from 'react'; + +import { router } from '../router'; +import type { RoutingIntent } from '../routingQueue'; +import { + PendingIntentsContext, + RoutingQueueApiContext, + RoutingQueueProvider, + useEnqueueRoutingIntent, +} from '../routingQueueContext'; + +function actionIntent(type: string): RoutingIntent { + return { type: 'ACTION', payload: { action: { type } } }; +} + +it('installs the module-level router after the provider commits', () => { + let pending: RoutingIntent[] = []; + + function Consumer() { + pending = use(PendingIntentsContext); + return null; + } + + expect(() => router.push('/test')).toThrow('first render'); + + render( + + + + ); + act(() => router.push('/test')); + + expect(pending).toEqual([ + { + type: 'NAVIGATE_TO_HREF', + payload: { href: '/test', options: { event: 'PUSH' } }, + }, + ]); +}); + +it('restores the throwing router after the provider unmounts', () => { + const { unmount } = render(); + + expect(() => act(() => router.push('/test'))).not.toThrow(); + unmount(); + + expect(() => router.push('/test')).toThrow('first render'); +}); + +it('warns when a second root binds the imperative router', () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const firstRoot = render(); + const secondRoot = render(); + + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith(expect.stringContaining('multiple')); + + secondRoot.unmount(); + firstRoot.unmount(); + error.mockRestore(); +}); + +it('preserves enqueue order and drains on the following render', () => { + const snapshots: RoutingIntent[][] = []; + let enqueue: ReturnType; + + function Consumer() { + enqueue = useEnqueueRoutingIntent(); + snapshots.push(use(PendingIntentsContext)); + return null; + } + + render( + + + + ); + + act(() => { + enqueue(actionIntent('FIRST')); + enqueue(actionIntent('SECOND')); + }); + + expect(snapshots).toEqual([[], [actionIntent('FIRST'), actionIntent('SECOND')]]); +}); + +it('keeps intents added while a batch is being dequeued', () => { + let api: NonNullable>; + let pending: RoutingIntent[] = []; + + function Consumer() { + api = use(RoutingQueueApiContext)!; + pending = use(PendingIntentsContext); + return null; + } + + render( + + + + ); + act(() => api.enqueue(actionIntent('FIRST'))); + const processed = pending; + + act(() => { + api.enqueue(actionIntent('SECOND')); + api.dequeue(processed); + }); + + expect(pending).toEqual([actionIntent('SECOND')]); +}); + +it('keeps providers isolated', () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + const queues: RoutingIntent[][] = [[], []]; + const enqueues: ((intent: RoutingIntent) => void)[] = []; + + function Consumer({ index }: { index: number }) { + enqueues[index] = useEnqueueRoutingIntent(); + queues[index] = use(PendingIntentsContext); + return null; + } + + render( + <> + + + + + + + + ); + act(() => enqueues[0]!(actionIntent('FIRST'))); + + expect(queues).toEqual([[actionIntent('FIRST')], []]); + error.mockRestore(); +}); + +it('does not re-render producers when the queue changes', () => { + const producerRender = jest.fn(); + let enqueue: ReturnType; + + function Producer() { + producerRender(); + enqueue = useEnqueueRoutingIntent(); + return null; + } + + render( + + + + ); + act(() => enqueue(actionIntent('TEST'))); + + expect(producerRender).toHaveBeenCalledTimes(1); +}); + +it('throws when enqueue is called without a provider', () => { + let enqueue: ReturnType; + + function Consumer() { + enqueue = useEnqueueRoutingIntent(); + return null; + } + + expect(() => render()).not.toThrow(); + expect(() => enqueue(actionIntent('TEST'))).toThrow('ExpoRoot'); +}); diff --git a/packages/expo-router/src/global-state/router.ts b/packages/expo-router/src/global-state/router.ts index 84e40b4ec28225..41c91de0152a41 100644 --- a/packages/expo-router/src/global-state/router.ts +++ b/packages/expo-router/src/global-state/router.ts @@ -13,7 +13,7 @@ import { resolveHref } from '../link/href'; import type { Href, RoutePath, RouteInputParams } from '../types'; import { getHistoryLength } from '../utils/stack'; import { shouldLinkExternally } from '../utils/url'; -import { routingQueue } from './routingQueue'; +import type { RoutingIntent } from './routingQueue'; import { store } from './store'; import type { LinkToOptions, NavigationOptions } from './types'; @@ -25,8 +25,12 @@ function assertIsReady() { } } -export function navigate(url: Href, options?: NavigationOptions) { - return linkTo(resolveHref(url), { ...options, event: 'NAVIGATE' }); +function navigateImpl( + enqueue: (intent: RoutingIntent) => void, + url: Href, + options?: NavigationOptions +) { + return linkToImpl(enqueue, resolveHref(url), { ...options, event: 'NAVIGATE' }); } export function reload() { @@ -34,47 +38,63 @@ export function reload() { throw new Error('The reload method is not implemented in the client-side router yet.'); } -export function prefetch(href: Href, options?: NavigationOptions) { - return linkTo(resolveHref(href), { ...options, event: 'PRELOAD' }); +function prefetchImpl( + enqueue: (intent: RoutingIntent) => void, + href: Href, + options?: NavigationOptions +) { + return linkToImpl(enqueue, resolveHref(href), { ...options, event: 'PRELOAD' }); } -export function push(url: Href, options?: NavigationOptions) { - return linkTo(resolveHref(url), { ...options, event: 'PUSH' }); +function pushImpl( + enqueue: (intent: RoutingIntent) => void, + url: Href, + options?: NavigationOptions +) { + return linkToImpl(enqueue, resolveHref(url), { ...options, event: 'PUSH' }); } // `GO_BACK` follows focused back handling; `POP` explicitly removes stack routes. -export function dismiss(count: number = 1) { +function dismissImpl(enqueue: (intent: RoutingIntent) => void, count: number = 1) { if (emitDomDismiss(count)) { return; } - routingQueue.add({ + enqueue({ type: 'ACTION', payload: { action: { type: 'POP', payload: { count } } }, }); } -export function dismissTo(href: Href, options?: NavigationOptions) { - return linkTo(resolveHref(href), { ...options, event: 'POP_TO' }); +function dismissToImpl( + enqueue: (intent: RoutingIntent) => void, + href: Href, + options?: NavigationOptions +) { + return linkToImpl(enqueue, resolveHref(href), { ...options, event: 'POP_TO' }); } -export function replace(url: Href, options?: NavigationOptions) { - return linkTo(resolveHref(url), { ...options, event: 'REPLACE' }); +function replaceImpl( + enqueue: (intent: RoutingIntent) => void, + url: Href, + options?: NavigationOptions +) { + return linkToImpl(enqueue, resolveHref(url), { ...options, event: 'REPLACE' }); } -export function dismissAll() { +function dismissAllImpl(enqueue: (intent: RoutingIntent) => void) { if (emitDomDismissAll()) { return; } - routingQueue.add({ type: 'ACTION', payload: { action: { type: 'POP_TO_TOP' } } }); + enqueue({ type: 'ACTION', payload: { action: { type: 'POP_TO_TOP' } } }); } // `GO_BACK` follows focused back handling; `POP` (used by `dismiss`) explicitly removes stack routes. -export function goBack() { +function goBackImpl(enqueue: (intent: RoutingIntent) => void) { if (emitDomGoBack()) { return; } - routingQueue.add({ type: 'ACTION', payload: { action: { type: 'GO_BACK' } } }); + enqueue({ type: 'ACTION', payload: { action: { type: 'GO_BACK' } } }); } export function canGoBack(): boolean { @@ -127,7 +147,11 @@ export function setParams( return (store.navigationRef?.current?.setParams as any)(params); } -export function linkTo(originalHref: Href | string, options: LinkToOptions = {}) { +function linkToImpl( + enqueue: (intent: RoutingIntent) => void, + originalHref: Href | string, + options: LinkToOptions = {} +) { let href: string | undefined | null = typeof originalHref == 'string' ? originalHref : resolveHref(originalHref); @@ -145,7 +169,7 @@ export function linkTo(originalHref: Href | string, options: LinkToOptions = {}) } if (href === '..' || href === '../') { - return goBack(); + return goBackImpl(enqueue); } // TODO(@ubax): Extract this change to standalone PR @@ -157,7 +181,7 @@ export function linkTo(originalHref: Href | string, options: LinkToOptions = {}) }, }; - routingQueue.add(linkAction); + enqueue(linkAction); } /** @@ -237,23 +261,68 @@ export type ImperativeRouter = { /** * Prefetch a screen in the background before navigating to it */ - prefetch: (name: Href) => void; + prefetch: (href: Href, options?: NavigationOptions) => void; }; /** * @hidden */ -export const router: ImperativeRouter = { - navigate, - push, - dismiss, - dismissAll, - dismissTo, - canDismiss, - replace, - back: () => goBack(), - canGoBack, - reload, - prefetch, - setParams: setParams as ImperativeRouter['setParams'], +type InternalRouter = ImperativeRouter & { + goBack: () => void; + linkTo: (href: Href | string, options?: LinkToOptions) => void; }; + +export function createImperativeRouter(enqueue: (intent: RoutingIntent) => void): InternalRouter { + return { + navigate: (href, options) => navigateImpl(enqueue, href, options), + push: (href, options) => pushImpl(enqueue, href, options), + dismiss: (count) => dismissImpl(enqueue, count), + dismissAll: () => dismissAllImpl(enqueue), + dismissTo: (href, options) => dismissToImpl(enqueue, href, options), + canDismiss, + replace: (href, options) => replaceImpl(enqueue, href, options), + back: () => goBackImpl(enqueue), + goBack: () => goBackImpl(enqueue), + canGoBack, + reload, + prefetch: (href, options) => prefetchImpl(enqueue, href, options), + setParams: setParams as ImperativeRouter['setParams'], + linkTo: (href, options) => linkToImpl(enqueue, href, options), + }; +} + +const throwBeforeFirstRender = () => { + throw new Error('The imperative router is unavailable before the first render has finished.'); +}; + +export const unboundRouter: InternalRouter = { + navigate: throwBeforeFirstRender, + push: throwBeforeFirstRender, + dismiss: throwBeforeFirstRender, + dismissAll: throwBeforeFirstRender, + dismissTo: throwBeforeFirstRender, + canDismiss: throwBeforeFirstRender, + replace: throwBeforeFirstRender, + back: throwBeforeFirstRender, + goBack: throwBeforeFirstRender, + canGoBack: throwBeforeFirstRender, + reload: throwBeforeFirstRender, + prefetch: throwBeforeFirstRender, + setParams: throwBeforeFirstRender, + linkTo: throwBeforeFirstRender, +}; + +export const router: InternalRouter = { ...unboundRouter }; + +export const navigate = (...args: Parameters) => + router.navigate(...args); +export const push = (...args: Parameters) => router.push(...args); +export const dismiss = (...args: Parameters) => router.dismiss(...args); +export const dismissAll = () => router.dismissAll(); +export const dismissTo = (...args: Parameters) => + router.dismissTo(...args); +export const replace = (...args: Parameters) => router.replace(...args); +export const goBack = () => router.goBack(); +export const prefetch = (...args: Parameters) => + router.prefetch(...args); +export const linkTo = (...args: Parameters) => router.linkTo(...args); diff --git a/packages/expo-router/src/global-state/routing.ts b/packages/expo-router/src/global-state/routing.ts index bf52ced0f393d0..b96cbc2453ae33 100644 --- a/packages/expo-router/src/global-state/routing.ts +++ b/packages/expo-router/src/global-state/routing.ts @@ -1,7 +1,6 @@ // Re-export shim — preserves all existing import paths. // TODO: Refactor consumers to import directly from the new modules, then delete this file. -export { routingQueue } from './routingQueue'; export { navigate, push, diff --git a/packages/expo-router/src/global-state/routingQueue.ts b/packages/expo-router/src/global-state/routingQueue.ts index b7a35500ad307a..0b8aa2650aff4e 100644 --- a/packages/expo-router/src/global-state/routingQueue.ts +++ b/packages/expo-router/src/global-state/routingQueue.ts @@ -35,33 +35,3 @@ export type RoutingIntent = metadata?: RoutingIntentMetadata; onDispatch?: (metadata: RoutingIntentMetadata | undefined) => void; }; - -export const routingQueue = { - queue: [] as RoutingIntent[], - subscribers: new Set<() => void>(), - subscribe(callback: () => void) { - routingQueue.subscribers.add(callback); - return () => { - routingQueue.subscribers.delete(callback); - }; - }, - snapshot() { - return routingQueue.queue; - }, - add(intent: RoutingIntent) { - routingQueue.queue = [...routingQueue.queue, intent]; - for (const callback of routingQueue.subscribers) { - callback(); - } - }, - drain(snapshot: RoutingIntent[]) { - if (snapshot !== routingQueue.queue) { - return []; - } - routingQueue.queue = []; - for (const callback of routingQueue.subscribers) { - callback(); - } - return snapshot; - }, -}; diff --git a/packages/expo-router/src/global-state/routingQueueContext.tsx b/packages/expo-router/src/global-state/routingQueueContext.tsx new file mode 100644 index 00000000000000..ecbc1e22201b9f --- /dev/null +++ b/packages/expo-router/src/global-state/routingQueueContext.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { createContext, use, useMemo, useState, type PropsWithChildren } from 'react'; + +import { useClientLayoutEffect } from '../react-navigation/core/useClientLayoutEffect'; +import { createImperativeRouter, router, unboundRouter } from './router'; +import type { RoutingIntent } from './routingQueue'; + +const EMPTY: RoutingIntent[] = []; +let boundBridges = 0; +const throwMissingRoutingQueue = () => { + throw new Error( + 'Attempted to navigate from a component rendered outside the Expo Router root. Render the component inside `ExpoRoot`. If this happened inside `ExpoRoot`, please report a bug at https://github.com/expo/expo/issues.' + ); +}; + +export type RoutingQueueApi = { + enqueue: (intent: RoutingIntent) => void; + dequeue: (processed: RoutingIntent[]) => void; +}; + +export const RoutingQueueApiContext = createContext(undefined); +export const PendingIntentsContext = createContext(EMPTY); + +export function RoutingQueueProvider({ children }: PropsWithChildren) { + const [queue, setQueue] = useState(EMPTY); + const api = useMemo( + () => ({ + enqueue: (intent) => setQueue((previous) => [...previous, intent]), + // Keep intents added between the drained render and this state update. + dequeue: (processed) => + setQueue((previous) => (previous === processed ? EMPTY : previous.slice(processed.length))), + }), + [] + ); + + return ( + + + {children} + + + + ); +} + +export function useEnqueueRoutingIntent() { + const api = use(RoutingQueueApiContext); + if (api === undefined) { + return throwMissingRoutingQueue; + } + return api.enqueue; +} + +function ImperativeRoutingQueueBridge({ enqueue }: Pick) { + useClientLayoutEffect(() => { + if (__DEV__ && boundBridges > 0) { + console.error( + [ + 'Looks like you have multiple navigation containers consuming the shared imperative routing queue. Only one container will receive queued actions. Make sure that:', + "- You don't have multiple NavigationContainers in the app", + '- Only a single instance of the root component is rendered', + ].join('\n') + ); + } + + boundBridges++; + // The exported router identity must stay stable, so the bridge mutates it in place. + Object.assign(router, createImperativeRouter(enqueue)); + + return () => { + boundBridges--; + Object.assign(router, unboundRouter); + }; + }, [enqueue]); + + return null; +} diff --git a/packages/expo-router/src/global-state/useRouterActions.ts b/packages/expo-router/src/global-state/useRouterActions.ts new file mode 100644 index 00000000000000..9a81ff139605c5 --- /dev/null +++ b/packages/expo-router/src/global-state/useRouterActions.ts @@ -0,0 +1,11 @@ +'use client'; + +import { useMemo } from 'react'; + +import { createImperativeRouter } from './router'; +import { useEnqueueRoutingIntent } from './routingQueueContext'; + +export function useRouterActions(): ReturnType { + const enqueue = useEnqueueRoutingIntent(); + return useMemo(() => createImperativeRouter(enqueue), [enqueue]); +} diff --git a/packages/expo-router/src/hooks/__tests__/useLoaderData.test.ios.tsx b/packages/expo-router/src/hooks/__tests__/useLoaderData.test.ios.tsx index c825a3f0c23f82..6d2cb28e38cf7c 100644 --- a/packages/expo-router/src/hooks/__tests__/useLoaderData.test.ios.tsx +++ b/packages/expo-router/src/hooks/__tests__/useLoaderData.test.ios.tsx @@ -13,9 +13,9 @@ import { } from '../../loaders/LoaderContext'; import { ServerDataLoaderContext } from '../../loaders/ServerDataLoaderContext'; import { fetchLoader } from '../../loaders/utils'; -import { renderRouter, renderRouterAsync } from '../../testing-library'; +import { renderRouterAsync } from '../../testing-library'; import { useLoaderData } from '../useLoaderData'; -import { renderHook, renderHookAsync } from './renderHook'; +import { renderHookAsync } from './renderHook'; jest.mock('../../loaders/utils', () => ({ fetchLoader: jest.fn(), @@ -54,26 +54,26 @@ describe(useLoaderData, () => { initialUrl: '/users/123', expectedPath: '/users/123', }, - ])('resolves $route to $expectedPath', ({ route, initialUrl, expectedPath }) => { + ])('resolves $route to $expectedPath', async ({ route, initialUrl, expectedPath }) => { globalThis.__EXPO_ROUTER_LOADER_DATA__ = { [expectedPath]: { correct: true }, }; - const { result } = renderHook(() => useLoaderData(), [route], { + const { result } = await renderHookAsync(() => useLoaderData(), [route], { initialUrl, }); expect(result.current).toEqual({ correct: true }); }); - it('resolves nested route under `_layout` to full pathname', () => { + it('resolves nested route under `_layout` to full pathname', async () => { globalThis.__EXPO_ROUTER_LOADER_DATA__ = { '/nested/index': { correct: true }, }; let loaderResult: any; - renderRouter( + await renderRouterAsync( { 'nested/_layout': () => , 'nested/index': function NestedIndex() { @@ -87,12 +87,12 @@ describe(useLoaderData, () => { expect(loaderResult).toEqual({ correct: true }); }); - it('includes search params in the lookup key', () => { + it('includes search params in the lookup key', async () => { globalThis.__EXPO_ROUTER_LOADER_DATA__ = { '/request?foo=bar': { correct: true }, }; - const { result } = renderHook(() => useLoaderData(), ['request'], { + const { result } = await renderHookAsync(() => useLoaderData(), ['request'], { initialUrl: '/request?foo=bar', }); @@ -108,7 +108,7 @@ describe(useLoaderData, () => { const { ctx, LoaderWrapper } = createLoaderTestContext(); - const firstMount = renderHook(() => useLoaderData(), ['index'], { + const firstMount = await renderHookAsync(() => useLoaderData(), ['index'], { initialUrl: '/', wrapper: LoaderWrapper, }); @@ -116,7 +116,7 @@ describe(useLoaderData, () => { expect(globalThis.__EXPO_ROUTER_LOADER_DATA__).not.toHaveProperty('/index'); expect(fetchLoaderMock).not.toHaveBeenCalled(); - firstMount.unmount(); + await firstMount.unmountAsync(); await act(async () => {}); expect(ctx.store.get('/index')).toBeUndefined(); @@ -175,24 +175,29 @@ describe(useLoaderData, () => { expect(fetchLoader).not.toHaveBeenCalled(); }); - it('reuses a hydrated entry across a same-tick Strict Mode remount', async () => { + it('reuses a hydrated entry across a same-tick remount', async () => { const fetchLoaderMock = fetchLoader as jest.MockedFunction; fetchLoaderMock.mockImplementation(() => new Promise(() => {})); const { ctx, LoaderWrapper } = createLoaderTestContext(); ctx.store.seed('/index', { hydrated: true }); - const first = renderHook(() => useLoaderData(), ['index'], { - initialUrl: '/', - wrapper: LoaderWrapper, - }); - first.unmount(); - const remount = renderHook(() => useLoaderData(), ['index'], { - initialUrl: '/', - wrapper: LoaderWrapper, - }); - await act(async () => {}); + let data: unknown; + let remount!: () => void; + function Reader() { + data = useLoaderData(); + return Reader; + } + function Screen() { + const [key, setKey] = useState(0); + remount = () => setKey((current) => current + 1); + return ; + } - expect(remount.result.current).toEqual({ hydrated: true }); + await renderRouterAsync({ index: Screen }, { wrapper: LoaderWrapper }); + jest.useRealTimers(); + await act(async () => remount()); + + expect(data).toEqual({ hydrated: true }); expect(ctx.store.get('/index')).toEqual({ data: { hydrated: true }, }); @@ -228,8 +233,9 @@ describe(useLoaderData, () => { jest.useRealTimers(); expect(screen.getByTestId('first-reader')).toHaveTextContent('{"shared":true}'); - fireEvent.press(screen.getByText('Remove first')); - await act(async () => {}); + await act(async () => { + fireEvent.press(screen.getByText('Remove first')); + }); expect(screen.queryByTestId('first-reader')).toBeNull(); expect(screen.getByTestId('second-reader')).toHaveTextContent('{"shared":true}'); @@ -248,7 +254,7 @@ describe(useLoaderData, () => { globalThis.__EXPO_ROUTER_LOADER_DATA__ = { '/index': { version: 1 }, }; - const hook = renderHook(() => useLoaderData(), ['index'], { + const hook = await renderHookAsync(() => useLoaderData(), ['index'], { initialUrl: '/', wrapper: LoaderWrapper, }); @@ -283,7 +289,7 @@ describe(useLoaderData, () => { {children} ); - const hook = renderHook(() => useLoaderData(), ['index'], { + const hook = await renderHookAsync(() => useLoaderData(), ['index'], { initialUrl: '/', wrapper: LoaderWrapper, }); @@ -291,10 +297,10 @@ describe(useLoaderData, () => { expect(hook.result.current).toEqual({ source: 'server' }); serverData = null; - hook.rerender(undefined); + await hook.rerenderAsync(undefined); expect(hook.result.current).toEqual({ version: 1 }); - act(() => { + await act(async () => { const { client, store } = loaderContextValue; store.retain(client.revalidate()); }); @@ -305,18 +311,18 @@ describe(useLoaderData, () => { expect(hook.result.current).toEqual({ version: 2 }); }); - it('clears inactive entries while retaining live entries during HMR coordination', () => { + it('clears inactive entries while retaining live entries during HMR coordination', async () => { const fetchLoaderMock = fetchLoader as jest.MockedFunction; fetchLoaderMock.mockImplementation(() => new Promise(() => {})); const { ctx, LoaderWrapper } = createLoaderTestContext(); ctx.store.seed('/index', { live: true }); ctx.store.seed('/inactive', { stale: true }); - renderHook(() => useLoaderData(), ['index'], { + await renderHookAsync(() => useLoaderData(), ['index'], { initialUrl: '/', wrapper: LoaderWrapper, }); - act(() => { + await act(async () => { const { client, store } = ctx; store.retain(client.revalidate()); }); @@ -339,7 +345,7 @@ describe(useLoaderData, () => { let profileRenders = 0; let indexResult: unknown; let profileResult: unknown; - renderRouter( + await renderRouterAsync( { _layout: () => ( @@ -363,7 +369,7 @@ describe(useLoaderData, () => { jest.useRealTimers(); expect(indexResult).toEqual({ tab: 'home' }); - act(() => router.push('/profile')); + await act(async () => router.push('/profile')); expect(profileResult).toEqual({ tab: 'profile' }); expect(indexResult).toEqual({ tab: 'home' }); const indexBefore = indexRenders; @@ -393,7 +399,7 @@ describe(useLoaderData, () => { return null; } - renderRouter({ + await renderRouterAsync({ index: () => ( <> @@ -412,11 +418,11 @@ describe(useLoaderData, () => { expect(renders[1]).toBeGreaterThan(rendersBefore[1]); }); - it('catches a store update between render and effect subscription', () => { + it('catches a store update between render and effect subscription', async () => { const { ctx, LoaderWrapper } = createLoaderTestContext(); ctx.store.seed('/index', { version: 1 }); - const { result } = renderHook( + const { result } = await renderHookAsync( () => { const data = useLoaderData(); useLayoutEffect(() => { @@ -436,7 +442,7 @@ describe(useLoaderData, () => { ctx.store.seed('/index', { version: 1 }); const oldFetch = createDeferred<{ version: number }>(); let renders = 0; - const hook = renderHook( + const hook = await renderHookAsync( () => { renders++; return useLoaderData(); @@ -449,7 +455,7 @@ describe(useLoaderData, () => { ctx.client.clear(); const replacementUnsubscribe = ctx.client.subscribeLoader('/index'); ctx.store.set('/index', { data: { version: 2 } }); - hook.rerender(undefined); + await hook.rerenderAsync(undefined); const rendersBeforeOldSettle = renders; await act(async () => { @@ -473,7 +479,7 @@ describe(useLoaderData, () => { let renders = 0; let latestData: unknown; - renderRouter( + await renderRouterAsync( { 'users/[id]': function User() { const data = useLoaderData() as { id: number }; @@ -490,7 +496,7 @@ describe(useLoaderData, () => { ctx.client.execute('/users/1', () => oldFetch.promise); - act(() => router.replace('/users/2')); + await act(async () => router.replace('/users/2')); expect(subscribeLoaderSpy.mock.calls.map(([path]) => path)).toEqual(['/users/1', '/users/2']); expect(latestData).toEqual({ id: 2 }); @@ -504,7 +510,7 @@ describe(useLoaderData, () => { expect(renders).toBe(rendersBeforeOldSettle); }); - it(`uses the loader function's return types`, () => { + it(`uses the loader function's return types`, async () => { const asyncLoader = async () => { return { user: { id: 1, name: 'async user' }, timestamp: Date.now() }; }; @@ -513,7 +519,7 @@ describe(useLoaderData, () => { '/index': { user: { id: 1, name: 'async user' }, timestamp: 123456789 }, }; - const { result } = renderHook(() => useLoaderData(), ['index'], { + const { result } = await renderHookAsync(() => useLoaderData(), ['index'], { initialUrl: '/', }); diff --git a/packages/expo-router/src/hooks/useLoaderData.ts b/packages/expo-router/src/hooks/useLoaderData.ts index 8dae245679e09a..5766dc5c0f6535 100644 --- a/packages/expo-router/src/hooks/useLoaderData.ts +++ b/packages/expo-router/src/hooks/useLoaderData.ts @@ -94,5 +94,17 @@ export function useLoaderData = any>(): LoaderFunc } const result = readLoaderData>(ctx, resolvedPath, fetchLoader); - return result instanceof Promise ? use(result) : result; + // React can replay a suspended render once its promise settles. The store then holds the + // settled value, so the replay must still call `use()`, or React throws "Update hook called + // on initial render" for every hook after this one. A fulfilled thenable makes React reuse + // the original promise without suspending. + return use(result instanceof Promise ? result : fulfilled(result)); +} + +function fulfilled(value: T): PromiseLike { + return { + status: 'fulfilled', + value, + then: (onFulfilled) => Promise.resolve(value).then(onFulfilled), + } as PromiseLike; // `status` and `value` are the React thenable extensions, not part of `PromiseLike`. } diff --git a/packages/expo-router/src/hooks/useRouter.ts b/packages/expo-router/src/hooks/useRouter.ts index 764db0db8bd5c1..dbed2735626822 100644 --- a/packages/expo-router/src/hooks/useRouter.ts +++ b/packages/expo-router/src/hooks/useRouter.ts @@ -1,7 +1,7 @@ 'use client'; +import { useRouterActions } from '../global-state/useRouterActions'; import type { ImperativeRouter } from '../imperative-api'; -import { router } from '../imperative-api'; import { usePreviewInfo } from '../link/preview/PreviewRouteContext'; const displayWarningForProp = (prop: string) => { @@ -55,6 +55,7 @@ const routerWithWarnings: ImperativeRouter = { */ export function useRouter(): ImperativeRouter { const { isPreview } = usePreviewInfo(); + const router = useRouterActions(); if (isPreview) { return routerWithWarnings; } diff --git a/packages/expo-router/src/imperative-api.tsx b/packages/expo-router/src/imperative-api.tsx index 763d65ae318487..0f86bf1a7f52b7 100644 --- a/packages/expo-router/src/imperative-api.tsx +++ b/packages/expo-router/src/imperative-api.tsx @@ -1,5 +1,6 @@ import type { ImperativeRouter } from './global-state/router'; -import { router } from './global-state/router'; +import { router as internalRouter } from './global-state/router'; export type { ImperativeRouter }; -export { router }; +// Hide internal `goBack` and `linkTo` methods from the public API and typedoc. +export const router: ImperativeRouter = internalRouter; diff --git a/packages/expo-router/src/link/__tests__/Link.test.ios.tsx b/packages/expo-router/src/link/__tests__/Link.test.ios.tsx index 0b86d6d3af4e80..07b3605905a8bb 100644 --- a/packages/expo-router/src/link/__tests__/Link.test.ios.tsx +++ b/packages/expo-router/src/link/__tests__/Link.test.ios.tsx @@ -1,7 +1,14 @@ -import { screen, act, waitFor, fireEvent, render } from '@testing-library/react-native'; +import { + screen, + act, + waitFor, + fireEvent, + render as renderWithoutQueue, +} from '@testing-library/react-native'; import React from 'react'; import { Button, Platform, Text, View } from 'react-native'; +import { RoutingQueueProvider } from '../../global-state/routingQueueContext'; import { useLocalSearchParams, useRouter } from '../../hooks'; import { router } from '../../imperative-api'; import Stack from '../../layouts/Stack'; @@ -19,6 +26,10 @@ import { NativeLinkPreview, } from '../preview/native'; +function render(element: React.ReactElement) { + return renderWithoutQueue(element, { wrapper: RoutingQueueProvider }); +} + // Render and observe the props of the Link component. jest.mock('../preview/native', () => { diff --git a/packages/expo-router/src/link/__tests__/Link.test.web.tsx b/packages/expo-router/src/link/__tests__/Link.test.web.tsx index 02593dff3664cf..3cf20307708980 100644 --- a/packages/expo-router/src/link/__tests__/Link.test.web.tsx +++ b/packages/expo-router/src/link/__tests__/Link.test.web.tsx @@ -2,23 +2,16 @@ import { fireEvent, render } from '@testing-library/react'; import { StyleSheet, Text, View } from 'react-native'; -import { linkTo } from '../../global-state/routing'; import { Link } from '../Link'; -jest.mock('../../global-state/routing', () => { - const actual = jest.requireActual( - '../../global-state/routing' - ) as typeof import('../../global-state/routing'); - return { - ...actual, - linkTo: jest.fn(), - }; -}); +const mockLinkTo = jest.fn(); -const mockedLinkTo = linkTo as jest.MockedFunction; +jest.mock('../../global-state/useRouterActions', () => ({ + useRouterActions: () => ({ linkTo: mockLinkTo }), +})); beforeEach(() => { - mockedLinkTo.mockClear(); + mockLinkTo.mockClear(); }); it('renders a Link', () => { @@ -230,7 +223,7 @@ describe('web click navigation', () => { fireEvent.click(getByTestId('link'), { button: 0 }); - expect(mockedLinkTo).toHaveBeenCalledWith('/foo', { + expect(mockLinkTo).toHaveBeenCalledWith('/foo', { dangerouslySingular: undefined, event: undefined, relativeToDirectory: undefined, @@ -253,7 +246,7 @@ describe('web click navigation', () => { fireEvent.click(getByTestId('link'), event); - expect(mockedLinkTo).not.toHaveBeenCalled(); + expect(mockLinkTo).not.toHaveBeenCalled(); }); it('does not intercept links with a target', () => { @@ -265,7 +258,7 @@ describe('web click navigation', () => { fireEvent.click(getByTestId('link'), { button: 0 }); - expect(mockedLinkTo).not.toHaveBeenCalled(); + expect(mockLinkTo).not.toHaveBeenCalled(); }); it.each(['https://expo.dev', '//expo.dev/router', 'mailto:hello@example.com'])( @@ -279,7 +272,7 @@ describe('web click navigation', () => { fireEvent.click(getByTestId('link'), { button: 0 }); - expect(mockedLinkTo).toHaveBeenCalled(); + expect(mockLinkTo).toHaveBeenCalled(); } ); }); diff --git a/packages/expo-router/src/link/useLinkToPathProps.tsx b/packages/expo-router/src/link/useLinkToPathProps.tsx index 660977ca8286d6..c2ca19bb69ffe8 100644 --- a/packages/expo-router/src/link/useLinkToPathProps.tsx +++ b/packages/expo-router/src/link/useLinkToPathProps.tsx @@ -5,7 +5,7 @@ import { Platform } from 'react-native'; import { emitDomLinkEvent } from '../domComponents/emitDomEvent'; import { appendBaseUrl } from '../fork/getPathFromState-forks'; import type { LinkToOptions } from '../global-state/routing'; -import { linkTo } from '../global-state/routing'; +import { useRouterActions } from '../global-state/useRouterActions'; import { stripGroupSegmentsFromPath } from '../matchers'; import { shouldLinkExternally } from '../utils/url'; @@ -38,6 +38,7 @@ type UseLinkToPathPropsOptions = LinkToOptions & { }; export default function useLinkToPathProps({ href, ...options }: UseLinkToPathPropsOptions) { + const router = useRouterActions(); const onPress = (event?: MouseEvent | GestureResponderEvent) => { // TODO: Align external links: anchors stay in the same tab, while a non-anchor `asChild` // falls back to `linkTo` and `Linking.openURL`, which opens a new tab. @@ -45,7 +46,7 @@ export default function useLinkToPathProps({ href, ...options }: UseLinkToPathPr if (emitDomLinkEvent(href, options)) { return; } - linkTo(href, options); + router.linkTo(href, options); } }; 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 03f5e124f807ff..f55d0c4c8e1d2f 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 @@ -2,7 +2,7 @@ import { act, render } from '@testing-library/react-native'; import * as React from 'react'; import { RouterRegistryProvider } from '../../../global-state/routerRegistry'; -import { routingQueue } from '../../../global-state/routingQueue'; +import { RoutingQueueProvider } from '../../../global-state/routingQueueContext'; import { CommonActions, type DefaultRouterOptions, @@ -28,7 +28,6 @@ jest.mock('nanoid/non-secure', () => { beforeEach(() => { MockRouterKey.current = 0; - routingQueue.queue = []; require('nanoid/non-secure').__key = 0; }); @@ -135,12 +134,15 @@ test('preserves a complete initial state by identity', () => { } render( - - - {() => null} - - , - { wrapper: RouterRegistryProvider } + + + + + {() => null} + + + + ); expect(ref.current?.getRootState()).toBe(initialState); @@ -201,19 +203,21 @@ test('handle dispatching with ref', () => { }; const element = ( - - - - {() => null} - {() => null} - {() => null} - {() => null} - - - + + + + + {() => null} + {() => null} + {() => null} + {() => null} + + + + ); render(element).update(element); @@ -951,80 +955,6 @@ test.skip('invokes the unhandled action listener with the unhandled action', () }); }); -test('logs an error when a sibling root container drains the shared routing queue', () => { - const error = jest.spyOn(console, 'error').mockImplementation(() => {}); - const TestNavigator = (props: any) => { - const { state, descriptors, NavigationContent } = useNavigationBuilder(MockRouter, props); - - return ( - - {state.routes.map((route) => descriptors[route.key]!.render())} - - ); - }; - - const ref = createNavigationContainerRef(); - - const onStateChange = jest.fn(); - - render( - <> - - - {() => null} - {() => null} - - - - - {() => null} - {() => null} - - - - ); - - act(() => { - ref.current?.navigate('lex'); - }); - - expect(onStateChange).toHaveBeenCalledWith({ - index: 1, - key: 'navigator-7', - routeNames: ['qux', 'lex'], - routes: [ - { key: 'qux-6', name: 'qux' }, - { key: 'lex-0', name: 'lex', params: undefined }, - ], - stale: false, - routeKeySeq: 0, - type: 'test', - }); - - expect(ref.current?.getRootState()).toEqual({ - index: 1, - key: 'navigator-7', - routeNames: ['qux', 'lex'], - routes: [ - { key: 'qux-6', name: 'qux' }, - { key: 'lex-0', name: 'lex', params: undefined }, - ], - stale: false, - routeKeySeq: 0, - type: 'test', - }); - - expect(error).toHaveBeenCalledTimes(1); - expect(error).toHaveBeenCalledWith( - [ - 'Looks like you have multiple navigation containers draining the shared routing queue. Only one container will receive queued actions, while the others will drop them. Make sure that:', - "- You don't have multiple NavigationContainers in the app", - '- Only a single instance of the root component is rendered', - ].join('\n') - ); - error.mockRestore(); -}); - test('warns for duplicate route names nested inside each other', () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(MockRouter, props); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/__fixtures__/BaseNavigationContainer.tsx b/packages/expo-router/src/react-navigation/core/__tests__/__fixtures__/BaseNavigationContainer.tsx index e5de60d852c643..0d5a17808348e5 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/__fixtures__/BaseNavigationContainer.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/__fixtures__/BaseNavigationContainer.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { nanoid } from 'nanoid/non-secure'; import { RouterRegistryProvider } from '../../../../global-state/routerRegistry'; +import { RoutingQueueProvider } from '../../../../global-state/routingQueueContext'; import type { NavigationState, ParamListBase, PartialState } from '../../../routers'; import type { NavigationContainerRef } from '../../types'; import { BaseNavigationContainer as BaseNavigationContainerImpl } from '../../BaseNavigationContainer'; @@ -141,12 +142,14 @@ export function BaseNavigationContainer(props: Props) { ); return ( - - - + + + + + ); } 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 8e7bbbe22c8b7e..6a8ea80e8a1b99 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 @@ -1,7 +1,8 @@ import { act, render } from '@testing-library/react-native'; import * as React from 'react'; -import { routingQueue } from '../../../global-state/routingQueue'; +import type { RoutingIntent } from '../../../global-state/routingQueue'; +import { RoutingQueueApiContext } from '../../../global-state/routingQueueContext'; import { CommonActions, type NavigationState, @@ -299,38 +300,59 @@ test('ignores dispatches from a preloaded stack screen until it is promoted', () }; const ref = createNavigationContainerRef(); const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const enqueue = jest.fn(); + + function CaptureEnqueue({ children }: React.PropsWithChildren) { + const parentApi = React.use(RoutingQueueApiContext)!; + const api = React.useMemo( + () => ({ + ...parentApi, + enqueue: (intent: RoutingIntent) => { + enqueue(intent); + parentApi.enqueue(intent); + }, + }), + [parentApi] + ); + + return ( + {children} + ); + } render( - - {() => null} - - + + + {() => null} + + + ); act(() => ref.current?.dispatch(CommonActions.preload('second'))); const preloadedNavigation = navigation; const preloadedState = ref.current?.getRootState(); - const add = jest.spyOn(routingQueue, 'add'); + enqueue.mockClear(); act(() => preloadedNavigation.goBack()); expect(warn).toHaveBeenCalledWith( "Ignored a navigation action dispatched from the preloaded screen 'second'. 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." ); - expect(add).not.toHaveBeenCalled(); + expect(enqueue).not.toHaveBeenCalled(); expect(ref.current?.getRootState()).toEqual(preloadedState); act(() => ref.current?.navigate('second')); expect(navigation).toBe(preloadedNavigation); - add.mockClear(); + enqueue.mockClear(); act(() => preloadedNavigation.dispatch(CommonActions.goBack())); - expect(add).toHaveBeenCalledTimes(1); - expect(add).toHaveBeenCalledWith({ + expect(enqueue).toHaveBeenCalledTimes(1); + expect(enqueue).toHaveBeenCalledWith({ type: 'NAVIGATOR_ACTION', payload: expect.objectContaining({ action: expect.objectContaining({ diff --git a/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx b/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx index 65535dc27be5a0..a443db791db535 100644 --- a/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx +++ b/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx @@ -7,7 +7,7 @@ import { isValidElementType } from 'react-is'; import { useRouteNode } from '../../Route'; import { useComponent } from '../../fork/useComponent'; import { type RouterRegistryEntry, useRegisterRouter } from '../../global-state/routerRegistry'; -import { routingQueue } from '../../global-state/routingQueue'; +import { useEnqueueRoutingIntent } from '../../global-state/routingQueueContext'; import { resetNavigatorState } from '../../global-state/stateUtils'; import useLatestCallback from '../../utils/useLatestCallback'; import { @@ -262,6 +262,7 @@ export function useNavigationBuilder< ) { useRegisterNavigator(); const routeNode = useRouteNode(); + const enqueue = useEnqueueRoutingIntent(); const { children, @@ -530,7 +531,7 @@ export function useNavigationBuilder< pendingRouteNamesRef.current = undefined; } else if (!isArrayEqual(pendingRouteNamesRef.current ?? [], routeNames)) { pendingRouteNamesRef.current = routeNames; - routingQueue.add({ + enqueue({ type: 'ACTION', payload: { action: { diff --git a/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx b/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx index 1f66d93bc25d98..d7a93a6eac2ffd 100644 --- a/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx +++ b/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { use } from 'react'; -import { routingQueue } from '../../global-state/routingQueue'; +import { useEnqueueRoutingIntent } from '../../global-state/routingQueueContext'; import { CommonActions, type NavigationAction, @@ -37,6 +37,7 @@ export function useNavigationHelpers< EventMap extends Record, >({ id: navigatorId, handleAction, getState, emitter, router }: Options) { const parentNavigationHelpers = use(NavigationContext); + const enqueue = useEnqueueRoutingIntent(); return React.useMemo(() => { const dispatchSync = (action: Action) => { @@ -44,7 +45,7 @@ export function useNavigationHelpers< }; const dispatch = (action: Action) => { - routingQueue.add({ + enqueue({ type: 'NAVIGATOR_ACTION', payload: { action, @@ -104,5 +105,5 @@ export function useNavigationHelpers< } as NavigationHelpers & ActionHelpers; return navigationHelpers; - }, [router, parentNavigationHelpers, emitter.emit, getState, handleAction, navigatorId]); + }, [enqueue, router, parentNavigationHelpers, emitter.emit, getState, handleAction, navigatorId]); } diff --git a/packages/expo-router/src/react-navigation/elements/__tests__/Button.test.native.tsx b/packages/expo-router/src/react-navigation/elements/__tests__/Button.test.native.tsx index d9c5b2c3e10427..92f46e62152861 100644 --- a/packages/expo-router/src/react-navigation/elements/__tests__/Button.test.native.tsx +++ b/packages/expo-router/src/react-navigation/elements/__tests__/Button.test.native.tsx @@ -1,6 +1,7 @@ import { fireEvent, render } from '@testing-library/react-native'; import type { ComponentProps } from 'react'; +import { RoutingQueueProvider } from '../../../global-state/routingQueueContext'; import { router } from '../../../imperative-api'; import { DefaultTheme, ThemeProvider } from '../../native'; import { Button } from '../Button'; @@ -22,9 +23,11 @@ const mockedNavigate = router.navigate as jest.MockedFunction) { return render( - -