From 185abf7d9fb147cc0e5cfc118c27a28930fc5704 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Fri, 20 Feb 2026 11:10:37 +0100 Subject: [PATCH 01/10] Omit built files from VS Code search (#43301) # Why VS Code search results in this monorepo include build artifacts (`packages/**/build`) and generated docs data (`docs/public/static/data`), adding noise when searching for code. # How Added `search.exclude` entries to `.vscode/settings.json` to omit these directories from search results. # Test Plan Open the repo in VS Code and confirm that searching no longer returns results from `packages/**/build` or `docs/public/static/data`. # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- .vscode/settings.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 0eef29335b4b02..3be4823decdbc0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,8 @@ { "typescript.tsdk": "node_modules/typescript/lib", - "eslint.workingDirectories": [ - { "pattern": "*" }, - ] + "eslint.workingDirectories": [{ "pattern": "*" }], + "search.exclude": { + "packages/**/build": true, + "docs/public/static/data": true + } } From 8b2b6be7236dc66681cb91f8dfb51e5792ddb234 Mon Sep 17 00:00:00 2001 From: Aman Mittal Date: Fri, 20 Feb 2026 15:57:10 +0530 Subject: [PATCH 02/10] [docs] Bump EAS CLI version (#43299) # Why Bump EAS CLI version in doc's metadata. # How Run `yarn run eas-cli-sync`. # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- docs/pages/eas/cli.mdx | 2 +- docs/ui/components/EASCLIReference/data/eas-cli-commands.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/pages/eas/cli.mdx b/docs/pages/eas/cli.mdx index 5425d0b1af033b..bec621a6140a83 100644 --- a/docs/pages/eas/cli.mdx +++ b/docs/pages/eas/cli.mdx @@ -2,7 +2,7 @@ title: EAS CLI reference sidebar_title: EAS CLI description: EAS CLI is a command-line tool that allows you to interact with Expo Application Services (EAS) from your terminal. -cliVersion: 18.0.1 +cliVersion: 18.0.3 --- import { EASCLIReference } from '~/ui/components/EASCLIReference'; diff --git a/docs/ui/components/EASCLIReference/data/eas-cli-commands.json b/docs/ui/components/EASCLIReference/data/eas-cli-commands.json index 3e4132020273c9..e77103359a96b1 100644 --- a/docs/ui/components/EASCLIReference/data/eas-cli-commands.json +++ b/docs/ui/components/EASCLIReference/data/eas-cli-commands.json @@ -1,8 +1,8 @@ { "source": { "url": "https://raw.githubusercontent.com/expo/eas-cli/main/packages/eas-cli/README.md", - "fetchedAt": "2026-02-12T11:55:31.163Z", - "cliVersion": "18.0.1" + "fetchedAt": "2026-02-20T08:06:30.449Z", + "cliVersion": "18.0.3" }, "totalCommands": 102, "commands": [ From 8517d281881e8a66068b50be19ead3b88820aeae Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Fri, 20 Feb 2026 10:47:53 +0000 Subject: [PATCH 03/10] [docs] Remove alpha notices for `experiments.autolinkingModuleResolution` (#43292) # Why The `experiments.autolinkingModuleResolution` option will be auto-enabled for monorepos in SDK 55. It's also not considered "alpha-quality" any longer, and the `experiments` flag is sufficient to communicate that it's (usually) not the default, and we shouldn't warn people that it's an early preview anymore. This is safe to enable regardless of whether apps are on SDK 54 or 55, and we'll backport any fixes (no fixes had to be backported so far) # How - Remove alpha callouts for `autolinkingModuleResolution` - Add notes that the option is enabled for monorepos by default --- docs/pages/guides/monorepos.mdx | 4 ++-- docs/pages/modules/autolinking.mdx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/pages/guides/monorepos.mdx b/docs/pages/guides/monorepos.mdx index 271eb002d6441d..a65db7fc20c89a 100644 --- a/docs/pages/guides/monorepos.mdx +++ b/docs/pages/guides/monorepos.mdx @@ -240,12 +240,12 @@ For [npm](https://docs.npmjs.com/cli/v9/configuring-npm/package-json#overrides), #### Deduplicating auto-linked native modules -> **important** This is an alpha feature starting in SDK 54 and later. The process will be automated and have better support in future versions. - Often, duplicate dependencies won't cause any problems. However, native modules should never be duplicated, because only one version of a native module can be compiled for an app build at a time. Unlike JavaScript dependencies, native builds cannot contain two conflicting versions of a single native module. From **SDK 54**, you can set `experiments.autolinkingModuleResolution` to `true` in your **app.json** to apply autolinking to Expo CLI and Metro bundler automatically. This will force dependencies that Metro resolves to match the native modules that [autolinking](/modules/autolinking/) links for your native builds. +From **SDK 55**, this is enabled automatically for apps in monorepos. + ### Script '...' does not exist React Native uses packages to ship both JavaScript and native files. These native files also need to be linked, like the [**react-native/react.Gradle**](https://github.com/facebook/react-native/blob/v0.70.6/react.gradle) file from **android/app/build.Gradle**. Usually, this path is hardcoded to something like: diff --git a/docs/pages/modules/autolinking.mdx b/docs/pages/modules/autolinking.mdx index 4f789050ff38ff..21520b2ee55b00 100644 --- a/docs/pages/modules/autolinking.mdx +++ b/docs/pages/modules/autolinking.mdx @@ -257,14 +257,14 @@ For example, with the `--platform ios` option, it returns an object in **react-n ## Dependency resolution and conflicts -> **important** This is an alpha feature starting in SDK 54 and later. - Autolinking and Node resolution have different goals and the module resolution algorithm in Node and Metro can sometimes come into conflict. If your app contains duplicate installations of a native module that is picked up by autolinking, your JavaScript bundle may contain both versions of the native module, while autolinking and your native app will only contain one version. This might cause runtime crashes and risks incompatibilities. This is an especially common problem with isolated dependencies or monorepos, and you should [check for and deduplicate native modules in your dependencies](/guides/monorepos/#duplicate-native-packages-within-monorepos). From **SDK 54**, you can set `experiments.autolinkingModuleResolution` to `true` in your [app config](/workflow/configuration/) to apply autolinking to Expo CLI and Metro bundler automatically. This will force dependencies that Metro resolves to match the native modules that **autolinking** resolves. +From **SDK 55**, the `experiments.autolinkingModuleResolution` flag is enabled by default for apps in monorepos. + ## Common questions ### How to set up the autolinking in my app? From ba4a4f680e48666ba685568ed5c8f2cf0e6c42db Mon Sep 17 00:00:00 2001 From: Alan Hughes <30924086+alanjhughes@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:56:37 +0000 Subject: [PATCH 04/10] [core][ios] Fix PersistentFileLog crash (#43283) --- packages/expo-modules-core/CHANGELOG.md | 2 ++ .../ios/Core/Logging/PersistentFileLog.swift | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/expo-modules-core/CHANGELOG.md b/packages/expo-modules-core/CHANGELOG.md index 53dc292ec4e360..e88e5e56891593 100644 --- a/packages/expo-modules-core/CHANGELOG.md +++ b/packages/expo-modules-core/CHANGELOG.md @@ -8,6 +8,8 @@ ### 🐛 Bug fixes +- [iOS] Fix crash from `PersistentFileLog`. ([#43283](https://github.com/expo/expo/pull/43283) by [@alanjhughes](https://github.com/alanjhughes)) + ### 💡 Others - Fixed view updates for Jetpack Compose integration. ([#42732](https://github.com/expo/expo/pull/42732) by [@kudo](https://github.com/kudo)) diff --git a/packages/expo-modules-core/ios/Core/Logging/PersistentFileLog.swift b/packages/expo-modules-core/ios/Core/Logging/PersistentFileLog.swift index dcb2a9d9fee1b9..893857805d8ed5 100644 --- a/packages/expo-modules-core/ios/Core/Logging/PersistentFileLog.swift +++ b/packages/expo-modules-core/ios/Core/Logging/PersistentFileLog.swift @@ -118,9 +118,11 @@ public class PersistentFileLog { private func appendTextToFile(text: String) throws { if let data = text.data(using: .utf8) { if let fileHandle = FileHandle(forWritingAtPath: filePath) { - fileHandle.seekToEndOfFile() - try fileHandle.write(data) - fileHandle.closeFile() + try EXUtilities.catchException { + fileHandle.seekToEndOfFile() + fileHandle.write(data) + fileHandle.closeFile() + } } } } From acb0b60df4051e7348dce141ed606445509144c4 Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:32:30 +0100 Subject: [PATCH 05/10] [expo-router] Extract usePreviewTransition from native stack navigator (#43182) # Why Extracts link preview adapter logic - synthesizing react-navigation state during link preview transition - from native stack fork into a separate hook in order to simplify the code and make it testable. # How 1. Extract the logic from native stack to `usePreviewTransition` 2. Changes the way formsheet header overrides are applied - changes are applied to the spread object # Test Plan 1. Unit tests 2. Manual testing of link preview, zoom transition with gestures disabled and formsheets headers **Link preview and zoom transitions** - `yarn ios:link-preview` https://github.com/user-attachments/assets/35709f9d-366d-49e8-a554-3e6a7f1c5207 **Formsheet headers** - `yarn ios:native-navigation` Header is transparent by default Simulator Screenshot - iPhone 17 Pro - 2026-02-17
at 11 02 50 # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- packages/expo-router/CHANGELOG.md | 2 + .../createNativeStackNavigator.d.ts.map | 2 +- .../createNativeStackNavigator.js | 117 ++--- .../createNativeStackNavigator.js.map | 2 +- .../native-stack/usePreviewTransition.d.ts | 21 + .../usePreviewTransition.d.ts.map | 1 + .../fork/native-stack/usePreviewTransition.js | 119 +++++ .../native-stack/usePreviewTransition.js.map | 1 + .../usePreviewTransition.test.ios.tsx | 417 ++++++++++++++++++ .../createNativeStackNavigator.tsx | 134 ++---- .../fork/native-stack/usePreviewTransition.ts | 112 +++++ 11 files changed, 744 insertions(+), 184 deletions(-) create mode 100644 packages/expo-router/build/fork/native-stack/usePreviewTransition.d.ts create mode 100644 packages/expo-router/build/fork/native-stack/usePreviewTransition.d.ts.map create mode 100644 packages/expo-router/build/fork/native-stack/usePreviewTransition.js create mode 100644 packages/expo-router/build/fork/native-stack/usePreviewTransition.js.map create mode 100644 packages/expo-router/src/fork/native-stack/__tests__/usePreviewTransition.test.ios.tsx create mode 100644 packages/expo-router/src/fork/native-stack/usePreviewTransition.ts diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index f2e67563931165..f421bd07d47cb8 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -14,6 +14,8 @@ ### 💡 Others +- extract usePreviewTransition from NativeStackNavigator ([#43182](https://github.com/expo/expo/pull/43182) by [@Ubax](https://github.com/Ubax)) + ## 55.0.0-preview.8 — 2026-02-16 ### 🎉 New features diff --git a/packages/expo-router/build/fork/native-stack/createNativeStackNavigator.d.ts.map b/packages/expo-router/build/fork/native-stack/createNativeStackNavigator.d.ts.map index 7ec12172b72b06..7e763f70957b66 100644 --- a/packages/expo-router/build/fork/native-stack/createNativeStackNavigator.d.ts.map +++ b/packages/expo-router/build/fork/native-stack/createNativeStackNavigator.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"createNativeStackNavigator.d.ts","sourceRoot":"","sources":["../../../src/fork/native-stack/createNativeStackNavigator.tsx"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAGlB,KAAK,oBAAoB,EAGzB,KAAK,YAAY,EACjB,KAAK,cAAc,EAEpB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,KAAK,6BAA6B,EAClC,KAAK,4BAA4B,EACjC,KAAK,yBAAyB,EAE9B,KAAK,yBAAyB,EAC/B,MAAM,gCAAgC,CAAC;AAExC,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAY/B,iBAAS,oBAAoB,CAAC,EAC5B,EAAE,EACF,gBAAgB,EAChB,QAAQ,EACR,MAAM,EACN,eAAe,EACf,aAAa,EACb,YAAY,EACZ,eAAe,EACf,GAAG,IAAI,EACR,EAAE,yBAAyB,qBA8K3B;AAED,wBAAgB,0BAA0B,CACxC,KAAK,CAAC,SAAS,SAAS,aAAa,EACrC,KAAK,CAAC,WAAW,SAAS,MAAM,GAAG,SAAS,GAAG,SAAS,EACxD,KAAK,CAAC,OAAO,SAAS,oBAAoB,GAAG;IAC3C,SAAS,EAAE,SAAS,CAAC;IACrB,WAAW,EAAE,WAAW,CAAC;IACzB,KAAK,EAAE,oBAAoB,CAAC,SAAS,CAAC,CAAC;IACvC,aAAa,EAAE,4BAA4B,CAAC;IAC5C,QAAQ,EAAE,6BAA6B,CAAC;IACxC,cAAc,EAAE;SACb,SAAS,IAAI,MAAM,SAAS,GAAG,yBAAyB,CAAC,SAAS,EAAE,SAAS,EAAE,WAAW,CAAC;KAC7F,CAAC;IACF,SAAS,EAAE,OAAO,oBAAoB,CAAC;CACxC,EACD,KAAK,CAAC,MAAM,SAAS,YAAY,CAAC,OAAO,CAAC,GAAG,YAAY,CAAC,OAAO,CAAC,EAClE,MAAM,CAAC,EAAE,MAAM,GAAG,cAAc,CAAC,OAAO,EAAE,MAAM,CAAC,CAElD"} \ No newline at end of file +{"version":3,"file":"createNativeStackNavigator.d.ts","sourceRoot":"","sources":["../../../src/fork/native-stack/createNativeStackNavigator.tsx"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAGlB,KAAK,oBAAoB,EAGzB,KAAK,YAAY,EACjB,KAAK,cAAc,EAEpB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,KAAK,6BAA6B,EAClC,KAAK,4BAA4B,EACjC,KAAK,yBAAyB,EAE9B,KAAK,yBAAyB,EAC/B,MAAM,gCAAgC,CAAC;AAExC,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAc/B,iBAAS,oBAAoB,CAAC,EAC5B,EAAE,EACF,gBAAgB,EAChB,QAAQ,EACR,MAAM,EACN,eAAe,EACf,aAAa,EACb,YAAY,EACZ,eAAe,EACf,GAAG,IAAI,EACR,EAAE,yBAAyB,qBAgH3B;AAED,wBAAgB,0BAA0B,CACxC,KAAK,CAAC,SAAS,SAAS,aAAa,EACrC,KAAK,CAAC,WAAW,SAAS,MAAM,GAAG,SAAS,GAAG,SAAS,EACxD,KAAK,CAAC,OAAO,SAAS,oBAAoB,GAAG;IAC3C,SAAS,EAAE,SAAS,CAAC;IACrB,WAAW,EAAE,WAAW,CAAC;IACzB,KAAK,EAAE,oBAAoB,CAAC,SAAS,CAAC,CAAC;IACvC,aAAa,EAAE,4BAA4B,CAAC;IAC5C,QAAQ,EAAE,6BAA6B,CAAC;IACxC,cAAc,EAAE;SACb,SAAS,IAAI,MAAM,SAAS,GAAG,yBAAyB,CAAC,SAAS,EAAE,SAAS,EAAE,WAAW,CAAC;KAC7F,CAAC;IACF,SAAS,EAAE,OAAO,oBAAoB,CAAC;CACxC,EACD,KAAK,CAAC,MAAM,SAAS,YAAY,CAAC,OAAO,CAAC,GAAG,YAAY,CAAC,OAAO,CAAC,EAClE,MAAM,CAAC,EAAE,MAAM,GAAG,cAAc,CAAC,OAAO,EAAE,MAAM,CAAC,CAElD"} \ No newline at end of file diff --git a/packages/expo-router/build/fork/native-stack/createNativeStackNavigator.js b/packages/expo-router/build/fork/native-stack/createNativeStackNavigator.js index d0ceb5f740959e..551e3802814eea 100644 --- a/packages/expo-router/build/fork/native-stack/createNativeStackNavigator.js +++ b/packages/expo-router/build/fork/native-stack/createNativeStackNavigator.js @@ -39,8 +39,9 @@ const native_stack_1 = require("@react-navigation/native-stack"); const expo_glass_effect_1 = require("expo-glass-effect"); const React = __importStar(require("react")); const descriptors_context_1 = require("./descriptors-context"); -const LinkPreviewContext_1 = require("../../link/preview/LinkPreviewContext"); +const usePreviewTransition_1 = require("./usePreviewTransition"); const navigationParams_1 = require("../../navigationParams"); +const GLASS = (0, expo_glass_effect_1.isLiquidGlassAvailable)(); function NativeStackNavigator({ id, initialRouteName, children, layout, screenListeners, screenOptions, screenLayout, UNSTABLE_router, ...rest }) { const { state, describe, descriptors, navigation, NavigationContent } = (0, native_1.useNavigationBuilder)(native_1.StackRouter, { id, @@ -79,92 +80,38 @@ function NativeStackNavigator({ id, initialRouteName, children, layout, screenLi }); }), [navigation, state.index, state.key]); // START FORK - const { openPreviewKey, setOpenPreviewKey } = (0, LinkPreviewContext_1.useLinkPreviewContext)(); - // This is used to track the preview screen that is currently transitioning on the native side - const [previewTransitioningScreenId, setPreviewTransitioningScreenId] = React.useState(); - React.useEffect(() => { - if (previewTransitioningScreenId) { - // This means that the state was updated after the preview transition - if (state.routes.some((route) => route.key === previewTransitioningScreenId)) { - // We no longer need to track the preview transitioning screen - setPreviewTransitioningScreenId(undefined); - } - } - }, [state, previewTransitioningScreenId]); - const navigationWrapper = React.useMemo(() => { - if (openPreviewKey) { - const emit = (...args) => { - const { target, type, data } = args[0]; - if (target === openPreviewKey && data && 'closing' in data && !data.closing) { - // onWillAppear - if (type === 'transitionStart') { - // The screen from preview will appear, so we need to start tracking it - setPreviewTransitioningScreenId(openPreviewKey); - } - // onAppear - else if (type === 'transitionEnd') { - // The screen from preview appeared. - // We can now restore the stack animation - setOpenPreviewKey(undefined); - } - } - return navigation.emit(...args); - }; - return { - ...navigation, - emit, - }; - } - return navigation; - }, [navigation, openPreviewKey, setOpenPreviewKey]); - const { computedState, computedDescriptors } = React.useMemo(() => { - // The preview screen was pushed on the native side, but react-navigation state was not updated yet - if (previewTransitioningScreenId) { - const preloadedRoute = state.preloadedRoutes.find((route) => route.key === previewTransitioningScreenId); - if (preloadedRoute) { - const newState = { - ...state, - // On native side the screen is already pushed, so we need to update the state - preloadedRoutes: state.preloadedRoutes.filter((route) => route.key !== previewTransitioningScreenId), - routes: [...state.routes, preloadedRoute], - index: state.index + 1, - }; - const newDescriptors = previewTransitioningScreenId in descriptors - ? descriptors - : { - ...descriptors, - // We need to add the descriptor. For react-navigation this is still preloaded screen - // Replicating the logic from https://github.com/react-navigation/react-navigation/blob/eaf1100ac7d99cb93ba11a999549dd0752809a78/packages/native-stack/src/views/NativeStackView.native.tsx#L489 - [previewTransitioningScreenId]: describe(preloadedRoute, true), - }; - return { - computedState: newState, - computedDescriptors: newDescriptors, - }; - } - } - // Map internal gesture option to React Navigation's gestureEnabled option - // This allows Expo Router to override gesture behavior without affecting user settings - const GLASS = (0, expo_glass_effect_1.isLiquidGlassAvailable)(); - Object.keys(descriptors).forEach((key) => { - const options = descriptors[key].options; + const { computedState, computedDescriptors, navigationWrapper } = (0, usePreviewTransition_1.usePreviewTransition)(state, navigation, descriptors, describe); + // Map internal gesture option to React Navigation's gestureEnabled option + // This allows Expo Router to override gesture behavior without affecting user settings + const finalDescriptors = React.useMemo(() => { + let needsNewMap = false; + const result = {}; + for (const key of Object.keys(computedDescriptors)) { + const descriptor = computedDescriptors[key]; + const options = descriptor.options; const internalGestureEnabled = options?.[navigationParams_1.INTERNAL_EXPO_ROUTER_GESTURE_ENABLED_OPTION_NAME]; - if (internalGestureEnabled !== undefined) { - options.gestureEnabled = internalGestureEnabled; + const needsGestureFix = internalGestureEnabled !== undefined; + const needsGlassFix = GLASS && options?.presentation === 'formSheet'; + if (needsGestureFix || needsGlassFix) { + needsNewMap = true; + const newOptions = { ...options }; + if (needsGestureFix) { + newOptions.gestureEnabled = internalGestureEnabled; + } + if (needsGlassFix) { + newOptions.headerTransparent ??= true; + newOptions.contentStyle ??= { backgroundColor: 'transparent' }; + newOptions.headerShadowVisible ??= false; + newOptions.headerLargeTitleShadowVisible ??= false; + } + result[key] = { ...descriptor, options: newOptions }; } - // Apply transparent defaults for formSheet presentation on iOS 26 with liquid glass - if (GLASS && options?.presentation === 'formSheet') { - options.headerTransparent ??= true; - options.contentStyle ??= { backgroundColor: 'transparent' }; - options.headerShadowVisible ??= false; - options.headerLargeTitleShadowVisible ??= false; + else { + result[key] = descriptor; } - }); - return { - computedState: state, - computedDescriptors: descriptors, - }; - }, [state, previewTransitioningScreenId, describe, descriptors]); + } + return needsNewMap ? result : computedDescriptors; + }, [computedDescriptors]); // END FORK return ( // START FORK @@ -173,7 +120,7 @@ function NativeStackNavigator({ id, initialRouteName, children, layout, screenLi ,\n StackRouterOptions,\n StackActionHelpers,\n NativeStackNavigationOptionsWithInternal,\n NativeStackNavigationEventMap\n >(StackRouter, {\n id,\n initialRouteName,\n children,\n layout,\n screenListeners,\n screenOptions,\n screenLayout,\n UNSTABLE_router,\n });\n\n React.useEffect(\n () =>\n // @ts-expect-error: there may not be a tab navigator in parent\n navigation?.addListener?.('tabPress', (e: any) => {\n const isFocused = navigation.isFocused();\n\n // Run the operation in the next frame so we're sure all listeners have been run\n // This is necessary to know if preventDefault() has been called\n requestAnimationFrame(() => {\n if (state.index > 0 && isFocused && !(e as EventArg<'tabPress', true>).defaultPrevented) {\n // When user taps on already focused tab and we're inside the tab,\n // reset the stack to replicate native behaviour\n // START FORK\n // navigation.dispatch({\n // ...StackActions.popToTop(),\n // target: state.key,\n // });\n // The popToTop will be automatically triggered on native side for native tabs\n if (e.data?.__internalTabsType !== 'native') {\n navigation.dispatch({\n ...StackActions.popToTop(),\n target: state.key,\n });\n }\n // END FORK\n }\n });\n }),\n [navigation, state.index, state.key]\n );\n\n // START FORK\n const { openPreviewKey, setOpenPreviewKey } = useLinkPreviewContext();\n\n // This is used to track the preview screen that is currently transitioning on the native side\n const [previewTransitioningScreenId, setPreviewTransitioningScreenId] = React.useState<\n string | undefined\n >();\n\n React.useEffect(() => {\n if (previewTransitioningScreenId) {\n // This means that the state was updated after the preview transition\n if (state.routes.some((route) => route.key === previewTransitioningScreenId)) {\n // We no longer need to track the preview transitioning screen\n setPreviewTransitioningScreenId(undefined);\n }\n }\n }, [state, previewTransitioningScreenId]);\n\n const navigationWrapper = React.useMemo(() => {\n if (openPreviewKey) {\n const emit: (typeof navigation)['emit'] = (...args) => {\n const { target, type, data } = args[0];\n if (target === openPreviewKey && data && 'closing' in data && !data.closing) {\n // onWillAppear\n if (type === 'transitionStart') {\n // The screen from preview will appear, so we need to start tracking it\n setPreviewTransitioningScreenId(openPreviewKey);\n }\n // onAppear\n else if (type === 'transitionEnd') {\n // The screen from preview appeared.\n // We can now restore the stack animation\n setOpenPreviewKey(undefined);\n }\n }\n return navigation.emit(...args);\n };\n return {\n ...navigation,\n emit,\n };\n }\n return navigation;\n }, [navigation, openPreviewKey, setOpenPreviewKey]);\n\n const { computedState, computedDescriptors } = React.useMemo(() => {\n // The preview screen was pushed on the native side, but react-navigation state was not updated yet\n if (previewTransitioningScreenId) {\n const preloadedRoute = state.preloadedRoutes.find(\n (route) => route.key === previewTransitioningScreenId\n );\n if (preloadedRoute) {\n const newState = {\n ...state,\n // On native side the screen is already pushed, so we need to update the state\n preloadedRoutes: state.preloadedRoutes.filter(\n (route) => route.key !== previewTransitioningScreenId\n ),\n routes: [...state.routes, preloadedRoute],\n index: state.index + 1,\n };\n\n const newDescriptors =\n previewTransitioningScreenId in descriptors\n ? descriptors\n : {\n ...descriptors,\n // We need to add the descriptor. For react-navigation this is still preloaded screen\n // Replicating the logic from https://github.com/react-navigation/react-navigation/blob/eaf1100ac7d99cb93ba11a999549dd0752809a78/packages/native-stack/src/views/NativeStackView.native.tsx#L489\n [previewTransitioningScreenId]: describe(preloadedRoute, true),\n };\n\n return {\n computedState: newState,\n computedDescriptors: newDescriptors,\n };\n }\n }\n // Map internal gesture option to React Navigation's gestureEnabled option\n // This allows Expo Router to override gesture behavior without affecting user settings\n const GLASS = isLiquidGlassAvailable();\n Object.keys(descriptors).forEach((key) => {\n const options = descriptors[key].options;\n const internalGestureEnabled = options?.[INTERNAL_EXPO_ROUTER_GESTURE_ENABLED_OPTION_NAME];\n if (internalGestureEnabled !== undefined) {\n options.gestureEnabled = internalGestureEnabled;\n }\n\n // Apply transparent defaults for formSheet presentation on iOS 26 with liquid glass\n if (GLASS && options?.presentation === 'formSheet') {\n options.headerTransparent ??= true;\n options.contentStyle ??= { backgroundColor: 'transparent' };\n options.headerShadowVisible ??= false;\n options.headerLargeTitleShadowVisible ??= false;\n }\n });\n return {\n computedState: state,\n computedDescriptors: descriptors,\n };\n }, [state, previewTransitioningScreenId, describe, descriptors]);\n // END FORK\n\n return (\n // START FORK\n \n {/* END FORK */}\n \n \n \n {/* START FORK */}\n \n // END FORK\n );\n}\n\nexport function createNativeStackNavigator<\n const ParamList extends ParamListBase,\n const NavigatorID extends string | undefined = undefined,\n const TypeBag extends NavigatorTypeBagBase = {\n ParamList: ParamList;\n NavigatorID: NavigatorID;\n State: StackNavigationState;\n ScreenOptions: NativeStackNavigationOptions;\n EventMap: NativeStackNavigationEventMap;\n NavigationList: {\n [RouteName in keyof ParamList]: NativeStackNavigationProp;\n };\n Navigator: typeof NativeStackNavigator;\n },\n const Config extends StaticConfig = StaticConfig,\n>(config?: Config): TypedNavigator {\n return createNavigatorFactory(NativeStackNavigator)(config);\n}\n"]} \ No newline at end of file +{"version":3,"file":"createNativeStackNavigator.js","sourceRoot":"","sources":["../../../src/fork/native-stack/createNativeStackNavigator.tsx"],"names":[],"mappings":"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","sourcesContent":["import {\n createNavigatorFactory,\n type EventArg,\n type NavigatorTypeBagBase,\n type ParamListBase,\n type StackActionHelpers,\n StackActions,\n type StackNavigationState,\n StackRouter,\n type StackRouterOptions,\n type StaticConfig,\n type TypedNavigator,\n useNavigationBuilder,\n} from '@react-navigation/native';\nimport {\n type NativeStackNavigationEventMap,\n type NativeStackNavigationOptions,\n type NativeStackNavigationProp,\n NativeStackView,\n type NativeStackNavigatorProps,\n} from '@react-navigation/native-stack';\nimport { isLiquidGlassAvailable } from 'expo-glass-effect';\nimport * as React from 'react';\n\nimport { DescriptorsContext } from './descriptors-context';\nimport { usePreviewTransition } from './usePreviewTransition';\nimport {\n INTERNAL_EXPO_ROUTER_GESTURE_ENABLED_OPTION_NAME,\n type InternalNavigationOptions,\n} from '../../navigationParams';\n\nconst GLASS = isLiquidGlassAvailable();\n\ntype NativeStackNavigationOptionsWithInternal = NativeStackNavigationOptions &\n InternalNavigationOptions;\n\nfunction NativeStackNavigator({\n id,\n initialRouteName,\n children,\n layout,\n screenListeners,\n screenOptions,\n screenLayout,\n UNSTABLE_router,\n ...rest\n}: NativeStackNavigatorProps) {\n const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder<\n StackNavigationState,\n StackRouterOptions,\n StackActionHelpers,\n NativeStackNavigationOptionsWithInternal,\n NativeStackNavigationEventMap\n >(StackRouter, {\n id,\n initialRouteName,\n children,\n layout,\n screenListeners,\n screenOptions,\n screenLayout,\n UNSTABLE_router,\n });\n\n React.useEffect(\n () =>\n // @ts-expect-error: there may not be a tab navigator in parent\n navigation?.addListener?.('tabPress', (e: any) => {\n const isFocused = navigation.isFocused();\n\n // Run the operation in the next frame so we're sure all listeners have been run\n // This is necessary to know if preventDefault() has been called\n requestAnimationFrame(() => {\n if (state.index > 0 && isFocused && !(e as EventArg<'tabPress', true>).defaultPrevented) {\n // When user taps on already focused tab and we're inside the tab,\n // reset the stack to replicate native behaviour\n // START FORK\n // navigation.dispatch({\n // ...StackActions.popToTop(),\n // target: state.key,\n // });\n // The popToTop will be automatically triggered on native side for native tabs\n if (e.data?.__internalTabsType !== 'native') {\n navigation.dispatch({\n ...StackActions.popToTop(),\n target: state.key,\n });\n }\n // END FORK\n }\n });\n }),\n [navigation, state.index, state.key]\n );\n\n // START FORK\n const { computedState, computedDescriptors, navigationWrapper } = usePreviewTransition(\n state,\n navigation,\n descriptors,\n describe\n );\n\n // Map internal gesture option to React Navigation's gestureEnabled option\n // This allows Expo Router to override gesture behavior without affecting user settings\n const finalDescriptors = React.useMemo(() => {\n let needsNewMap = false;\n const result: typeof computedDescriptors = {};\n for (const key of Object.keys(computedDescriptors)) {\n const descriptor = computedDescriptors[key];\n const options = descriptor.options as NativeStackNavigationOptionsWithInternal;\n const internalGestureEnabled = options?.[INTERNAL_EXPO_ROUTER_GESTURE_ENABLED_OPTION_NAME];\n const needsGestureFix = internalGestureEnabled !== undefined;\n const needsGlassFix = GLASS && options?.presentation === 'formSheet';\n\n if (needsGestureFix || needsGlassFix) {\n needsNewMap = true;\n const newOptions = { ...options };\n if (needsGestureFix) {\n newOptions.gestureEnabled = internalGestureEnabled;\n }\n if (needsGlassFix) {\n newOptions.headerTransparent ??= true;\n newOptions.contentStyle ??= { backgroundColor: 'transparent' };\n newOptions.headerShadowVisible ??= false;\n newOptions.headerLargeTitleShadowVisible ??= false;\n }\n result[key] = { ...descriptor, options: newOptions };\n } else {\n result[key] = descriptor;\n }\n }\n return needsNewMap ? result : computedDescriptors;\n }, [computedDescriptors]);\n // END FORK\n\n return (\n // START FORK\n \n {/* END FORK */}\n \n \n \n {/* START FORK */}\n \n // END FORK\n );\n}\n\nexport function createNativeStackNavigator<\n const ParamList extends ParamListBase,\n const NavigatorID extends string | undefined = undefined,\n const TypeBag extends NavigatorTypeBagBase = {\n ParamList: ParamList;\n NavigatorID: NavigatorID;\n State: StackNavigationState;\n ScreenOptions: NativeStackNavigationOptions;\n EventMap: NativeStackNavigationEventMap;\n NavigationList: {\n [RouteName in keyof ParamList]: NativeStackNavigationProp;\n };\n Navigator: typeof NativeStackNavigator;\n },\n const Config extends StaticConfig = StaticConfig,\n>(config?: Config): TypedNavigator {\n return createNavigatorFactory(NativeStackNavigator)(config);\n}\n"]} \ No newline at end of file diff --git a/packages/expo-router/build/fork/native-stack/usePreviewTransition.d.ts b/packages/expo-router/build/fork/native-stack/usePreviewTransition.d.ts new file mode 100644 index 00000000000000..f451d6c0dfa5e7 --- /dev/null +++ b/packages/expo-router/build/fork/native-stack/usePreviewTransition.d.ts @@ -0,0 +1,21 @@ +import type { ParamListBase, StackNavigationState } from '@react-navigation/native'; +import type { NativeStackDescriptor, NativeStackDescriptorMap } from './descriptors-context'; +/** Mirrors the `describe` function returned by `useNavigationBuilder` */ +type DescribeFn = (route: StackNavigationState['preloadedRoutes'][number], placeholder: boolean) => NativeStackDescriptor; +/** + * Manages the preview transition state for link previews. + * + * Tracks when a preloaded screen is transitioning on the native side (after + * the preview is committed) but before React Navigation state is updated. + * During this window, the hook synthesizes state/descriptors to keep native + * and JS state in sync. + */ +export declare function usePreviewTransition any; +}>(state: StackNavigationState, navigation: TNavigation, descriptors: NativeStackDescriptorMap, describe: DescribeFn): { + computedState: StackNavigationState; + computedDescriptors: NativeStackDescriptorMap; + navigationWrapper: TNavigation; +}; +export {}; +//# sourceMappingURL=usePreviewTransition.d.ts.map \ No newline at end of file diff --git a/packages/expo-router/build/fork/native-stack/usePreviewTransition.d.ts.map b/packages/expo-router/build/fork/native-stack/usePreviewTransition.d.ts.map new file mode 100644 index 00000000000000..6ff5fde2ef5ddf --- /dev/null +++ b/packages/expo-router/build/fork/native-stack/usePreviewTransition.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"usePreviewTransition.d.ts","sourceRoot":"","sources":["../../../src/fork/native-stack/usePreviewTransition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAGpF,OAAO,KAAK,EAAE,qBAAqB,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AAG7F,yEAAyE;AACzE,KAAK,UAAU,GAAG,CAChB,KAAK,EAAE,oBAAoB,CAAC,aAAa,CAAC,CAAC,iBAAiB,CAAC,CAAC,MAAM,CAAC,EACrE,WAAW,EAAE,OAAO,KACjB,qBAAqB,CAAC;AAE3B;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,SAAS;IAAE,IAAI,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;CAAE,EACxF,KAAK,EAAE,oBAAoB,CAAC,aAAa,CAAC,EAC1C,UAAU,EAAE,WAAW,EACvB,WAAW,EAAE,wBAAwB,EACrC,QAAQ,EAAE,UAAU;;;;EAuFrB"} \ No newline at end of file diff --git a/packages/expo-router/build/fork/native-stack/usePreviewTransition.js b/packages/expo-router/build/fork/native-stack/usePreviewTransition.js new file mode 100644 index 00000000000000..c0323c93d700ca --- /dev/null +++ b/packages/expo-router/build/fork/native-stack/usePreviewTransition.js @@ -0,0 +1,119 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.usePreviewTransition = usePreviewTransition; +const React = __importStar(require("react")); +const LinkPreviewContext_1 = require("../../link/preview/LinkPreviewContext"); +/** + * Manages the preview transition state for link previews. + * + * Tracks when a preloaded screen is transitioning on the native side (after + * the preview is committed) but before React Navigation state is updated. + * During this window, the hook synthesizes state/descriptors to keep native + * and JS state in sync. + */ +function usePreviewTransition(state, navigation, descriptors, describe) { + const { openPreviewKey, setOpenPreviewKey } = (0, LinkPreviewContext_1.useLinkPreviewContext)(); + // Track the preview screen currently transitioning on the native side + const [previewTransitioningScreenId, setPreviewTransitioningScreenId] = React.useState(); + React.useEffect(() => { + if (previewTransitioningScreenId) { + // State was updated after the preview transition + if (state.routes.some((route) => route.key === previewTransitioningScreenId)) { + // No longer need to track the preview transitioning screen + setPreviewTransitioningScreenId(undefined); + } + } + }, [state, previewTransitioningScreenId]); + const navigationWrapper = React.useMemo(() => { + if (openPreviewKey) { + const emit = (...args) => { + const { target, type, data } = args[0]; + if (target === openPreviewKey && data && 'closing' in data && !data.closing) { + // onWillAppear + if (type === 'transitionStart') { + // The screen from preview will appear, so we need to start tracking it + setPreviewTransitioningScreenId(openPreviewKey); + } + // onAppear + else if (type === 'transitionEnd') { + // The screen from preview appeared. + // We can now restore the stack animation + setOpenPreviewKey(undefined); + } + } + return navigation.emit(...args); + }; + return { + ...navigation, + emit, + }; + } + return navigation; + }, [navigation, openPreviewKey, setOpenPreviewKey]); + const { computedState, computedDescriptors } = React.useMemo(() => { + // The preview screen was pushed on the native side, but react-navigation state was not updated yet + if (previewTransitioningScreenId) { + const preloadedRoute = state.preloadedRoutes.find((route) => route.key === previewTransitioningScreenId); + if (preloadedRoute) { + const newState = { + ...state, + // On native side the screen is already pushed, so we need to update the state + preloadedRoutes: state.preloadedRoutes.filter((route) => route.key !== previewTransitioningScreenId), + routes: [...state.routes, preloadedRoute], + index: state.index + 1, + }; + const newDescriptors = previewTransitioningScreenId in descriptors + ? descriptors + : { + ...descriptors, + // We need to add the descriptor. For react-navigation this is still preloaded screen + // Replicating the logic from https://github.com/react-navigation/react-navigation/blob/eaf1100ac7d99cb93ba11a999549dd0752809a78/packages/native-stack/src/views/NativeStackView.native.tsx#L489 + [previewTransitioningScreenId]: describe(preloadedRoute, true), + }; + return { + computedState: newState, + computedDescriptors: newDescriptors, + }; + } + } + return { + computedState: state, + computedDescriptors: descriptors, + }; + }, [state, previewTransitioningScreenId, describe, descriptors]); + return { computedState, computedDescriptors, navigationWrapper }; +} +//# sourceMappingURL=usePreviewTransition.js.map \ No newline at end of file diff --git a/packages/expo-router/build/fork/native-stack/usePreviewTransition.js.map b/packages/expo-router/build/fork/native-stack/usePreviewTransition.js.map new file mode 100644 index 00000000000000..ead2d1c1568baf --- /dev/null +++ b/packages/expo-router/build/fork/native-stack/usePreviewTransition.js.map @@ -0,0 +1 @@ +{"version":3,"file":"usePreviewTransition.js","sourceRoot":"","sources":["../../../src/fork/native-stack/usePreviewTransition.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoBA,oDA2FC;AA9GD,6CAA+B;AAG/B,8EAA8E;AAQ9E;;;;;;;GAOG;AACH,SAAgB,oBAAoB,CAClC,KAA0C,EAC1C,UAAuB,EACvB,WAAqC,EACrC,QAAoB;IAEpB,MAAM,EAAE,cAAc,EAAE,iBAAiB,EAAE,GAAG,IAAA,0CAAqB,GAAE,CAAC;IAEtE,sEAAsE;IACtE,MAAM,CAAC,4BAA4B,EAAE,+BAA+B,CAAC,GAAG,KAAK,CAAC,QAAQ,EAEnF,CAAC;IAEJ,KAAK,CAAC,SAAS,CAAC,GAAG,EAAE;QACnB,IAAI,4BAA4B,EAAE,CAAC;YACjC,iDAAiD;YACjD,IAAI,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,KAAK,4BAA4B,CAAC,EAAE,CAAC;gBAC7E,2DAA2D;gBAC3D,+BAA+B,CAAC,SAAS,CAAC,CAAC;YAC7C,CAAC;QACH,CAAC;IACH,CAAC,EAAE,CAAC,KAAK,EAAE,4BAA4B,CAAC,CAAC,CAAC;IAE1C,MAAM,iBAAiB,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,EAAE;QAC3C,IAAI,cAAc,EAAE,CAAC;YACnB,MAAM,IAAI,GAAgC,CAAC,GAAG,IAAI,EAAE,EAAE;gBACpD,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;gBACvC,IAAI,MAAM,KAAK,cAAc,IAAI,IAAI,IAAI,SAAS,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;oBAC5E,eAAe;oBACf,IAAI,IAAI,KAAK,iBAAiB,EAAE,CAAC;wBAC/B,uEAAuE;wBACvE,+BAA+B,CAAC,cAAc,CAAC,CAAC;oBAClD,CAAC;oBACD,WAAW;yBACN,IAAI,IAAI,KAAK,eAAe,EAAE,CAAC;wBAClC,oCAAoC;wBACpC,yCAAyC;wBACzC,iBAAiB,CAAC,SAAS,CAAC,CAAC;oBAC/B,CAAC;gBACH,CAAC;gBACD,OAAO,UAAU,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC;YAClC,CAAC,CAAC;YACF,OAAO;gBACL,GAAG,UAAU;gBACb,IAAI;aACL,CAAC;QACJ,CAAC;QACD,OAAO,UAAU,CAAC;IACpB,CAAC,EAAE,CAAC,UAAU,EAAE,cAAc,EAAE,iBAAiB,CAAC,CAAC,CAAC;IAEpD,MAAM,EAAE,aAAa,EAAE,mBAAmB,EAAE,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,EAAE;QAChE,mGAAmG;QACnG,IAAI,4BAA4B,EAAE,CAAC;YACjC,MAAM,cAAc,GAAG,KAAK,CAAC,eAAe,CAAC,IAAI,CAC/C,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,KAAK,4BAA4B,CACtD,CAAC;YACF,IAAI,cAAc,EAAE,CAAC;gBACnB,MAAM,QAAQ,GAAG;oBACf,GAAG,KAAK;oBACR,8EAA8E;oBAC9E,eAAe,EAAE,KAAK,CAAC,eAAe,CAAC,MAAM,CAC3C,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,KAAK,4BAA4B,CACtD;oBACD,MAAM,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,cAAc,CAAC;oBACzC,KAAK,EAAE,KAAK,CAAC,KAAK,GAAG,CAAC;iBACvB,CAAC;gBAEF,MAAM,cAAc,GAClB,4BAA4B,IAAI,WAAW;oBACzC,CAAC,CAAC,WAAW;oBACb,CAAC,CAAC;wBACE,GAAG,WAAW;wBACd,qFAAqF;wBACrF,gMAAgM;wBAChM,CAAC,4BAA4B,CAAC,EAAE,QAAQ,CAAC,cAAc,EAAE,IAAI,CAAC;qBAC/D,CAAC;gBAER,OAAO;oBACL,aAAa,EAAE,QAAQ;oBACvB,mBAAmB,EAAE,cAAc;iBACpC,CAAC;YACJ,CAAC;QACH,CAAC;QAED,OAAO;YACL,aAAa,EAAE,KAAK;YACpB,mBAAmB,EAAE,WAAW;SACjC,CAAC;IACJ,CAAC,EAAE,CAAC,KAAK,EAAE,4BAA4B,EAAE,QAAQ,EAAE,WAAW,CAAC,CAAC,CAAC;IAEjE,OAAO,EAAE,aAAa,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,CAAC;AACnE,CAAC","sourcesContent":["import type { ParamListBase, StackNavigationState } from '@react-navigation/native';\nimport * as React from 'react';\n\nimport type { NativeStackDescriptor, NativeStackDescriptorMap } from './descriptors-context';\nimport { useLinkPreviewContext } from '../../link/preview/LinkPreviewContext';\n\n/** Mirrors the `describe` function returned by `useNavigationBuilder` */\ntype DescribeFn = (\n route: StackNavigationState['preloadedRoutes'][number],\n placeholder: boolean\n) => NativeStackDescriptor;\n\n/**\n * Manages the preview transition state for link previews.\n *\n * Tracks when a preloaded screen is transitioning on the native side (after\n * the preview is committed) but before React Navigation state is updated.\n * During this window, the hook synthesizes state/descriptors to keep native\n * and JS state in sync.\n */\nexport function usePreviewTransition any }>(\n state: StackNavigationState,\n navigation: TNavigation,\n descriptors: NativeStackDescriptorMap,\n describe: DescribeFn\n) {\n const { openPreviewKey, setOpenPreviewKey } = useLinkPreviewContext();\n\n // Track the preview screen currently transitioning on the native side\n const [previewTransitioningScreenId, setPreviewTransitioningScreenId] = React.useState<\n string | undefined\n >();\n\n React.useEffect(() => {\n if (previewTransitioningScreenId) {\n // State was updated after the preview transition\n if (state.routes.some((route) => route.key === previewTransitioningScreenId)) {\n // No longer need to track the preview transitioning screen\n setPreviewTransitioningScreenId(undefined);\n }\n }\n }, [state, previewTransitioningScreenId]);\n\n const navigationWrapper = React.useMemo(() => {\n if (openPreviewKey) {\n const emit: (typeof navigation)['emit'] = (...args) => {\n const { target, type, data } = args[0];\n if (target === openPreviewKey && data && 'closing' in data && !data.closing) {\n // onWillAppear\n if (type === 'transitionStart') {\n // The screen from preview will appear, so we need to start tracking it\n setPreviewTransitioningScreenId(openPreviewKey);\n }\n // onAppear\n else if (type === 'transitionEnd') {\n // The screen from preview appeared.\n // We can now restore the stack animation\n setOpenPreviewKey(undefined);\n }\n }\n return navigation.emit(...args);\n };\n return {\n ...navigation,\n emit,\n };\n }\n return navigation;\n }, [navigation, openPreviewKey, setOpenPreviewKey]);\n\n const { computedState, computedDescriptors } = React.useMemo(() => {\n // The preview screen was pushed on the native side, but react-navigation state was not updated yet\n if (previewTransitioningScreenId) {\n const preloadedRoute = state.preloadedRoutes.find(\n (route) => route.key === previewTransitioningScreenId\n );\n if (preloadedRoute) {\n const newState = {\n ...state,\n // On native side the screen is already pushed, so we need to update the state\n preloadedRoutes: state.preloadedRoutes.filter(\n (route) => route.key !== previewTransitioningScreenId\n ),\n routes: [...state.routes, preloadedRoute],\n index: state.index + 1,\n };\n\n const newDescriptors =\n previewTransitioningScreenId in descriptors\n ? descriptors\n : {\n ...descriptors,\n // We need to add the descriptor. For react-navigation this is still preloaded screen\n // Replicating the logic from https://github.com/react-navigation/react-navigation/blob/eaf1100ac7d99cb93ba11a999549dd0752809a78/packages/native-stack/src/views/NativeStackView.native.tsx#L489\n [previewTransitioningScreenId]: describe(preloadedRoute, true),\n };\n\n return {\n computedState: newState,\n computedDescriptors: newDescriptors,\n };\n }\n }\n\n return {\n computedState: state,\n computedDescriptors: descriptors,\n };\n }, [state, previewTransitioningScreenId, describe, descriptors]);\n\n return { computedState, computedDescriptors, navigationWrapper };\n}\n"]} \ No newline at end of file diff --git a/packages/expo-router/src/fork/native-stack/__tests__/usePreviewTransition.test.ios.tsx b/packages/expo-router/src/fork/native-stack/__tests__/usePreviewTransition.test.ios.tsx new file mode 100644 index 00000000000000..9cf3167a3c794d --- /dev/null +++ b/packages/expo-router/src/fork/native-stack/__tests__/usePreviewTransition.test.ios.tsx @@ -0,0 +1,417 @@ +import type { ParamListBase, StackNavigationState } from '@react-navigation/native'; +import { renderHook, act, type RenderHookOptions } from '@testing-library/react-native'; + +import { useLinkPreviewContext } from '../../../link/preview/LinkPreviewContext'; +import type { NativeStackDescriptor, NativeStackDescriptorMap } from '../descriptors-context'; +import { usePreviewTransition } from '../usePreviewTransition'; + +type HookProps = { + state: StackNavigationState; + descriptors: NativeStackDescriptorMap; +}; + +jest.mock('../../../link/preview/LinkPreviewContext'); + +const mockUseLinkPreviewContext = useLinkPreviewContext as jest.Mock; + +function makeRoute(key: string) { + return { key, name: key, params: {} }; +} + +function makeState( + overrides: Partial> = {} +): StackNavigationState { + return { + stale: false, + type: 'stack', + key: 'stack-1', + index: 0, + routeNames: ['index'], + routes: [makeRoute('index-key')], + preloadedRoutes: [], + ...overrides, + }; +} + +function makeDescriptor(key: string): NativeStackDescriptor { + return { + render: jest.fn(), + options: {}, + route: makeRoute(key), + navigation: {} as any, + }; +} + +function makeDescriptors(keys: string[]): NativeStackDescriptorMap { + const result: NativeStackDescriptorMap = {}; + for (const key of keys) { + result[key] = makeDescriptor(key); + } + return result; +} + +function makeNavigation() { + return { emit: jest.fn((..._args: any[]) => ({ defaultPrevented: false })) }; +} + +describe('usePreviewTransition', () => { + let mockSetOpenPreviewKey: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockSetOpenPreviewKey = jest.fn(); + mockUseLinkPreviewContext.mockReturnValue({ + isStackAnimationDisabled: false, + openPreviewKey: undefined, + setOpenPreviewKey: mockSetOpenPreviewKey, + }); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('passes through original state, descriptors, and navigation when no preview is active', () => { + const state = makeState(); + const navigation = makeNavigation(); + const descriptors = makeDescriptors(['index-key']); + const describe = jest.fn(); + + const { result } = renderHook(() => + usePreviewTransition(state, navigation, descriptors, describe) + ); + + expect(result.current.computedState).toBe(state); + expect(result.current.computedDescriptors).toBe(descriptors); + expect(result.current.navigationWrapper).toBe(navigation); + expect(describe).not.toHaveBeenCalled(); + }); + + it('wraps navigation.emit when openPreviewKey is set', () => { + mockUseLinkPreviewContext.mockReturnValue({ + isStackAnimationDisabled: true, + openPreviewKey: 'preview-key', + setOpenPreviewKey: mockSetOpenPreviewKey, + }); + + const state = makeState(); + const navigation = makeNavigation(); + const descriptors = makeDescriptors(['index-key']); + const describe = jest.fn(); + + const { result } = renderHook(() => + usePreviewTransition(state, navigation, descriptors, describe) + ); + + // Navigation wrapper should be a new object, not the original + expect(result.current.navigationWrapper).not.toBe(navigation); + expect(result.current.navigationWrapper.emit).not.toBe(navigation.emit); + }); + + it('intercepts transitionStart and starts tracking the preview screen', () => { + mockUseLinkPreviewContext.mockReturnValue({ + isStackAnimationDisabled: true, + openPreviewKey: 'preview-key', + setOpenPreviewKey: mockSetOpenPreviewKey, + }); + + const preloadedRoute = makeRoute('preview-key'); + const state = makeState({ + preloadedRoutes: [preloadedRoute], + }); + const navigation = makeNavigation(); + const descriptors = makeDescriptors(['index-key']); + const previewDescriptor = makeDescriptor('preview-key'); + const describe = jest.fn().mockReturnValue(previewDescriptor); + + const { result } = renderHook(() => + usePreviewTransition(state, navigation, descriptors, describe) + ); + + // Fire transitionStart for the preview key + act(() => { + result.current.navigationWrapper.emit({ + type: 'transitionStart', + target: 'preview-key', + data: { closing: false }, + }); + }); + + // After transitionStart, the hook should synthesize state with the preloaded route + expect(result.current.computedState.routes).toHaveLength(2); + expect(result.current.computedState.routes[1].key).toBe('preview-key'); + expect(result.current.computedState.index).toBe(1); + expect(result.current.computedState.preloadedRoutes).toHaveLength(0); + + // Should have called describe for the new descriptor + expect(describe).toHaveBeenCalledWith(preloadedRoute, true); + expect(result.current.computedDescriptors['preview-key']).toBe(previewDescriptor); + + // Original emit should still have been called + expect(navigation.emit).toHaveBeenCalledTimes(1); + }); + + it('intercepts transitionEnd and calls setOpenPreviewKey(undefined)', () => { + mockUseLinkPreviewContext.mockReturnValue({ + isStackAnimationDisabled: true, + openPreviewKey: 'preview-key', + setOpenPreviewKey: mockSetOpenPreviewKey, + }); + + const state = makeState(); + const navigation = makeNavigation(); + const descriptors = makeDescriptors(['index-key']); + const describe = jest.fn(); + + const { result } = renderHook(() => + usePreviewTransition(state, navigation, descriptors, describe) + ); + + act(() => { + result.current.navigationWrapper.emit({ + type: 'transitionEnd', + target: 'preview-key', + data: { closing: false }, + }); + }); + + expect(mockSetOpenPreviewKey).toHaveBeenCalledWith(undefined); + expect(navigation.emit).toHaveBeenCalledTimes(1); + }); + + it('does not intercept events with closing: true', () => { + mockUseLinkPreviewContext.mockReturnValue({ + isStackAnimationDisabled: true, + openPreviewKey: 'preview-key', + setOpenPreviewKey: mockSetOpenPreviewKey, + }); + + const state = makeState(); + const navigation = makeNavigation(); + const descriptors = makeDescriptors(['index-key']); + const describe = jest.fn(); + + const { result } = renderHook(() => + usePreviewTransition(state, navigation, descriptors, describe) + ); + + act(() => { + result.current.navigationWrapper.emit({ + type: 'transitionStart', + target: 'preview-key', + data: { closing: true }, + }); + }); + + // State should remain unchanged - closing events are not intercepted + expect(result.current.computedState).toBe(state); + expect(mockSetOpenPreviewKey).not.toHaveBeenCalled(); + expect(navigation.emit).toHaveBeenCalledTimes(1); + }); + + it('does not intercept events for a different target', () => { + mockUseLinkPreviewContext.mockReturnValue({ + isStackAnimationDisabled: true, + openPreviewKey: 'preview-key', + setOpenPreviewKey: mockSetOpenPreviewKey, + }); + + const state = makeState(); + const navigation = makeNavigation(); + const descriptors = makeDescriptors(['index-key']); + const describe = jest.fn(); + + const { result } = renderHook(() => + usePreviewTransition(state, navigation, descriptors, describe) + ); + + act(() => { + result.current.navigationWrapper.emit({ + type: 'transitionStart', + target: 'other-key', + data: { closing: false }, + }); + }); + + // State should remain unchanged - different target + expect(result.current.computedState).toBe(state); + expect(navigation.emit).toHaveBeenCalledTimes(1); + }); + + it('reuses existing descriptor when already present in descriptors map', () => { + mockUseLinkPreviewContext.mockReturnValue({ + isStackAnimationDisabled: true, + openPreviewKey: 'preview-key', + setOpenPreviewKey: mockSetOpenPreviewKey, + }); + + const preloadedRoute = makeRoute('preview-key'); + const existingPreviewDescriptor = makeDescriptor('preview-key'); + const state = makeState({ + preloadedRoutes: [preloadedRoute], + }); + const navigation = makeNavigation(); + // Descriptors already include preview-key + const descriptors = { + ...makeDescriptors(['index-key']), + 'preview-key': existingPreviewDescriptor, + }; + const describe = jest.fn(); + + const { result } = renderHook(() => + usePreviewTransition(state, navigation, descriptors, describe) + ); + + // Fire transitionStart to begin tracking + act(() => { + result.current.navigationWrapper.emit({ + type: 'transitionStart', + target: 'preview-key', + data: { closing: false }, + }); + }); + + // Should NOT call describe since descriptor already exists + expect(describe).not.toHaveBeenCalled(); + // Should reuse the same descriptors object + expect(result.current.computedDescriptors).toBe(descriptors); + }); + + it('clears tracking when state.routes includes the transitioning screen', () => { + mockUseLinkPreviewContext.mockReturnValue({ + isStackAnimationDisabled: true, + openPreviewKey: 'preview-key', + setOpenPreviewKey: mockSetOpenPreviewKey, + }); + + const preloadedRoute = makeRoute('preview-key'); + const state = makeState({ + preloadedRoutes: [preloadedRoute], + }); + const navigation = makeNavigation(); + const descriptors = makeDescriptors(['index-key']); + const previewDescriptor = makeDescriptor('preview-key'); + const describe = jest.fn().mockReturnValue(previewDescriptor); + + const { result, rerender } = renderHook( + ({ state, descriptors }: HookProps) => + usePreviewTransition(state, navigation, descriptors, describe), + { initialProps: { state, descriptors } as HookProps } as RenderHookOptions + ); + + // Start tracking + act(() => { + result.current.navigationWrapper.emit({ + type: 'transitionStart', + target: 'preview-key', + data: { closing: false }, + }); + }); + + // Verify synthesized state + expect(result.current.computedState.routes).toHaveLength(2); + + // Now simulate React Navigation updating state to include preview-key in routes + const updatedState = makeState({ + index: 1, + routes: [makeRoute('index-key'), makeRoute('preview-key')], + preloadedRoutes: [], + }); + const updatedDescriptors = { + ...makeDescriptors(['index-key']), + 'preview-key': previewDescriptor, + }; + + rerender({ state: updatedState, descriptors: updatedDescriptors }); + + // After state update, the hook should pass through the real state directly + expect(result.current.computedState).toBe(updatedState); + expect(result.current.computedDescriptors).toBe(updatedDescriptors); + }); + + it('passes through emit events with no data property', () => { + mockUseLinkPreviewContext.mockReturnValue({ + isStackAnimationDisabled: true, + openPreviewKey: 'preview-key', + setOpenPreviewKey: mockSetOpenPreviewKey, + }); + + const state = makeState(); + const navigation = makeNavigation(); + const descriptors = makeDescriptors(['index-key']); + const describe = jest.fn(); + + const { result } = renderHook(() => + usePreviewTransition(state, navigation, descriptors, describe) + ); + + act(() => { + result.current.navigationWrapper.emit({ + type: 'transitionStart', + target: 'preview-key', + }); + }); + + // Without data property, the event should pass through without interception + expect(result.current.computedState).toBe(state); + expect(navigation.emit).toHaveBeenCalledTimes(1); + }); + + it('preserves navigationWrapper reference across re-renders when no preview is active', () => { + const state = makeState(); + const navigation = makeNavigation(); + const descriptors = makeDescriptors(['index-key']); + const describe = jest.fn(); + + const { result, rerender } = renderHook( + ({ state, descriptors }: HookProps) => + usePreviewTransition(state, navigation, descriptors, describe), + { initialProps: { state, descriptors } as HookProps } as RenderHookOptions + ); + + const firstWrapper = result.current.navigationWrapper; + expect(firstWrapper).toBe(navigation); + + // Rerender with new state/descriptors but no preview active + const newState = makeState({ index: 0 }); + const newDescriptors = makeDescriptors(['index-key']); + rerender({ state: newState, descriptors: newDescriptors }); + + // navigationWrapper should still be the same navigation reference + expect(result.current.navigationWrapper).toBe(navigation); + }); + + it('falls through to original state when no matching preloaded route exists', () => { + mockUseLinkPreviewContext.mockReturnValue({ + isStackAnimationDisabled: true, + openPreviewKey: 'preview-key', + setOpenPreviewKey: mockSetOpenPreviewKey, + }); + + // State has no preloadedRoutes matching preview-key + const state = makeState({ + preloadedRoutes: [makeRoute('other-preloaded')], + }); + const navigation = makeNavigation(); + const descriptors = makeDescriptors(['index-key']); + const describe = jest.fn(); + + const { result } = renderHook(() => + usePreviewTransition(state, navigation, descriptors, describe) + ); + + // Start tracking + act(() => { + result.current.navigationWrapper.emit({ + type: 'transitionStart', + target: 'preview-key', + data: { closing: false }, + }); + }); + + // No matching preloaded route → should fall through to original state + expect(result.current.computedState).toBe(state); + expect(result.current.computedDescriptors).toBe(descriptors); + expect(describe).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/expo-router/src/fork/native-stack/createNativeStackNavigator.tsx b/packages/expo-router/src/fork/native-stack/createNativeStackNavigator.tsx index eaade20c8f6a25..050c0b8bbb8531 100644 --- a/packages/expo-router/src/fork/native-stack/createNativeStackNavigator.tsx +++ b/packages/expo-router/src/fork/native-stack/createNativeStackNavigator.tsx @@ -23,12 +23,14 @@ import { isLiquidGlassAvailable } from 'expo-glass-effect'; import * as React from 'react'; import { DescriptorsContext } from './descriptors-context'; -import { useLinkPreviewContext } from '../../link/preview/LinkPreviewContext'; +import { usePreviewTransition } from './usePreviewTransition'; import { INTERNAL_EXPO_ROUTER_GESTURE_ENABLED_OPTION_NAME, type InternalNavigationOptions, } from '../../navigationParams'; +const GLASS = isLiquidGlassAvailable(); + type NativeStackNavigationOptionsWithInternal = NativeStackNavigationOptions & InternalNavigationOptions; @@ -92,106 +94,44 @@ function NativeStackNavigator({ ); // START FORK - const { openPreviewKey, setOpenPreviewKey } = useLinkPreviewContext(); - - // This is used to track the preview screen that is currently transitioning on the native side - const [previewTransitioningScreenId, setPreviewTransitioningScreenId] = React.useState< - string | undefined - >(); + const { computedState, computedDescriptors, navigationWrapper } = usePreviewTransition( + state, + navigation, + descriptors, + describe + ); - React.useEffect(() => { - if (previewTransitioningScreenId) { - // This means that the state was updated after the preview transition - if (state.routes.some((route) => route.key === previewTransitioningScreenId)) { - // We no longer need to track the preview transitioning screen - setPreviewTransitioningScreenId(undefined); - } - } - }, [state, previewTransitioningScreenId]); + // Map internal gesture option to React Navigation's gestureEnabled option + // This allows Expo Router to override gesture behavior without affecting user settings + const finalDescriptors = React.useMemo(() => { + let needsNewMap = false; + const result: typeof computedDescriptors = {}; + for (const key of Object.keys(computedDescriptors)) { + const descriptor = computedDescriptors[key]; + const options = descriptor.options as NativeStackNavigationOptionsWithInternal; + const internalGestureEnabled = options?.[INTERNAL_EXPO_ROUTER_GESTURE_ENABLED_OPTION_NAME]; + const needsGestureFix = internalGestureEnabled !== undefined; + const needsGlassFix = GLASS && options?.presentation === 'formSheet'; - const navigationWrapper = React.useMemo(() => { - if (openPreviewKey) { - const emit: (typeof navigation)['emit'] = (...args) => { - const { target, type, data } = args[0]; - if (target === openPreviewKey && data && 'closing' in data && !data.closing) { - // onWillAppear - if (type === 'transitionStart') { - // The screen from preview will appear, so we need to start tracking it - setPreviewTransitioningScreenId(openPreviewKey); - } - // onAppear - else if (type === 'transitionEnd') { - // The screen from preview appeared. - // We can now restore the stack animation - setOpenPreviewKey(undefined); - } + if (needsGestureFix || needsGlassFix) { + needsNewMap = true; + const newOptions = { ...options }; + if (needsGestureFix) { + newOptions.gestureEnabled = internalGestureEnabled; } - return navigation.emit(...args); - }; - return { - ...navigation, - emit, - }; - } - return navigation; - }, [navigation, openPreviewKey, setOpenPreviewKey]); - - const { computedState, computedDescriptors } = React.useMemo(() => { - // The preview screen was pushed on the native side, but react-navigation state was not updated yet - if (previewTransitioningScreenId) { - const preloadedRoute = state.preloadedRoutes.find( - (route) => route.key === previewTransitioningScreenId - ); - if (preloadedRoute) { - const newState = { - ...state, - // On native side the screen is already pushed, so we need to update the state - preloadedRoutes: state.preloadedRoutes.filter( - (route) => route.key !== previewTransitioningScreenId - ), - routes: [...state.routes, preloadedRoute], - index: state.index + 1, - }; - - const newDescriptors = - previewTransitioningScreenId in descriptors - ? descriptors - : { - ...descriptors, - // We need to add the descriptor. For react-navigation this is still preloaded screen - // Replicating the logic from https://github.com/react-navigation/react-navigation/blob/eaf1100ac7d99cb93ba11a999549dd0752809a78/packages/native-stack/src/views/NativeStackView.native.tsx#L489 - [previewTransitioningScreenId]: describe(preloadedRoute, true), - }; - - return { - computedState: newState, - computedDescriptors: newDescriptors, - }; + if (needsGlassFix) { + newOptions.headerTransparent ??= true; + newOptions.contentStyle ??= { backgroundColor: 'transparent' }; + newOptions.headerShadowVisible ??= false; + newOptions.headerLargeTitleShadowVisible ??= false; + } + result[key] = { ...descriptor, options: newOptions }; + } else { + result[key] = descriptor; } } - // Map internal gesture option to React Navigation's gestureEnabled option - // This allows Expo Router to override gesture behavior without affecting user settings - const GLASS = isLiquidGlassAvailable(); - Object.keys(descriptors).forEach((key) => { - const options = descriptors[key].options; - const internalGestureEnabled = options?.[INTERNAL_EXPO_ROUTER_GESTURE_ENABLED_OPTION_NAME]; - if (internalGestureEnabled !== undefined) { - options.gestureEnabled = internalGestureEnabled; - } - - // Apply transparent defaults for formSheet presentation on iOS 26 with liquid glass - if (GLASS && options?.presentation === 'formSheet') { - options.headerTransparent ??= true; - options.contentStyle ??= { backgroundColor: 'transparent' }; - options.headerShadowVisible ??= false; - options.headerLargeTitleShadowVisible ??= false; - } - }); - return { - computedState: state, - computedDescriptors: descriptors, - }; - }, [state, previewTransitioningScreenId, describe, descriptors]); + return needsNewMap ? result : computedDescriptors; + }, [computedDescriptors]); // END FORK return ( @@ -204,7 +144,7 @@ function NativeStackNavigator({ // START FORK state={computedState} navigation={navigationWrapper} - descriptors={computedDescriptors} + descriptors={finalDescriptors} // state={state} // navigation={navigation} // descriptors={descriptors} diff --git a/packages/expo-router/src/fork/native-stack/usePreviewTransition.ts b/packages/expo-router/src/fork/native-stack/usePreviewTransition.ts new file mode 100644 index 00000000000000..c36d0e73b1ec72 --- /dev/null +++ b/packages/expo-router/src/fork/native-stack/usePreviewTransition.ts @@ -0,0 +1,112 @@ +import type { ParamListBase, StackNavigationState } from '@react-navigation/native'; +import * as React from 'react'; + +import type { NativeStackDescriptor, NativeStackDescriptorMap } from './descriptors-context'; +import { useLinkPreviewContext } from '../../link/preview/LinkPreviewContext'; + +/** Mirrors the `describe` function returned by `useNavigationBuilder` */ +type DescribeFn = ( + route: StackNavigationState['preloadedRoutes'][number], + placeholder: boolean +) => NativeStackDescriptor; + +/** + * Manages the preview transition state for link previews. + * + * Tracks when a preloaded screen is transitioning on the native side (after + * the preview is committed) but before React Navigation state is updated. + * During this window, the hook synthesizes state/descriptors to keep native + * and JS state in sync. + */ +export function usePreviewTransition any }>( + state: StackNavigationState, + navigation: TNavigation, + descriptors: NativeStackDescriptorMap, + describe: DescribeFn +) { + const { openPreviewKey, setOpenPreviewKey } = useLinkPreviewContext(); + + // Track the preview screen currently transitioning on the native side + const [previewTransitioningScreenId, setPreviewTransitioningScreenId] = React.useState< + string | undefined + >(); + + React.useEffect(() => { + if (previewTransitioningScreenId) { + // State was updated after the preview transition + if (state.routes.some((route) => route.key === previewTransitioningScreenId)) { + // No longer need to track the preview transitioning screen + setPreviewTransitioningScreenId(undefined); + } + } + }, [state, previewTransitioningScreenId]); + + const navigationWrapper = React.useMemo(() => { + if (openPreviewKey) { + const emit: (typeof navigation)['emit'] = (...args) => { + const { target, type, data } = args[0]; + if (target === openPreviewKey && data && 'closing' in data && !data.closing) { + // onWillAppear + if (type === 'transitionStart') { + // The screen from preview will appear, so we need to start tracking it + setPreviewTransitioningScreenId(openPreviewKey); + } + // onAppear + else if (type === 'transitionEnd') { + // The screen from preview appeared. + // We can now restore the stack animation + setOpenPreviewKey(undefined); + } + } + return navigation.emit(...args); + }; + return { + ...navigation, + emit, + }; + } + return navigation; + }, [navigation, openPreviewKey, setOpenPreviewKey]); + + const { computedState, computedDescriptors } = React.useMemo(() => { + // The preview screen was pushed on the native side, but react-navigation state was not updated yet + if (previewTransitioningScreenId) { + const preloadedRoute = state.preloadedRoutes.find( + (route) => route.key === previewTransitioningScreenId + ); + if (preloadedRoute) { + const newState = { + ...state, + // On native side the screen is already pushed, so we need to update the state + preloadedRoutes: state.preloadedRoutes.filter( + (route) => route.key !== previewTransitioningScreenId + ), + routes: [...state.routes, preloadedRoute], + index: state.index + 1, + }; + + const newDescriptors = + previewTransitioningScreenId in descriptors + ? descriptors + : { + ...descriptors, + // We need to add the descriptor. For react-navigation this is still preloaded screen + // Replicating the logic from https://github.com/react-navigation/react-navigation/blob/eaf1100ac7d99cb93ba11a999549dd0752809a78/packages/native-stack/src/views/NativeStackView.native.tsx#L489 + [previewTransitioningScreenId]: describe(preloadedRoute, true), + }; + + return { + computedState: newState, + computedDescriptors: newDescriptors, + }; + } + } + + return { + computedState: state, + computedDescriptors: descriptors, + }; + }, [state, previewTransitioningScreenId, describe, descriptors]); + + return { computedState, computedDescriptors, navigationWrapper }; +} From 52af534eff1b8ca1360e2746a33fad0710e2f0ca Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:34:23 +0100 Subject: [PATCH 06/10] [expo-router] add notes about React 19 and React Compiler to Claude.md (#43200) # Why When writing code in `expo-router` Claude tends to use `useContext` instead of `use`, and hardly ever uses React 19 features. Additionally it often tries to use `any` to fix the types # How # Test Plan # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- packages/expo-router/CLAUDE.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/expo-router/CLAUDE.md b/packages/expo-router/CLAUDE.md index c63ff38a08f9ac..c1ec1f9ca0514a 100644 --- a/packages/expo-router/CLAUDE.md +++ b/packages/expo-router/CLAUDE.md @@ -190,8 +190,12 @@ jest.mock('react-native-screens', () => { ```ts let spy: jest.SpyInstance; -beforeEach(() => { spy = jest.spyOn(Module, 'fn'); }); // or jest.spyOn(console, 'warn').mockImplementation(() => {}) -afterEach(() => { spy.mockRestore(); }); +beforeEach(() => { + spy = jest.spyOn(Module, 'fn'); +}); // or jest.spyOn(console, 'warn').mockImplementation(() => {}) +afterEach(() => { + spy.mockRestore(); +}); ``` **Mock call assertions:** Use array index access. Comment non-zero indices: @@ -301,6 +305,12 @@ To run the docs site locally run `yarn dev` in the `docs/` directory of the mono - http://localhost:3002/versions/unversioned/sdk/router-split-view/ for split view - http://localhost:3002/versions/unversioned/sdk/router-ui/ for headless tabs +## Coding style + +- Always use latest React 19 hooks and patterns - `use` instead of `useContext`, `useId`, etc. +- Make sure the code works with and without React Compiler enabled. +- Don't use `any` types, unless strictly necessary. Use `unknown` instead and narrow types as much as possible. + ## Maintaining This Document When developing or planning features, document missing behaviors in this file. Update sections when implementations change or new patterns emerge. From cd20600c331e5fbb2487b1264502f6923a110c48 Mon Sep 17 00:00:00 2001 From: Alan Hughes <30924086+alanjhughes@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:04:39 +0000 Subject: [PATCH 07/10] [audio][android] Make in-memory preload cache (#43293) --- .../android/.idea/codeStyles/Project.xml | 1 - packages/expo-audio/CHANGELOG.md | 2 + .../java/expo/modules/audio/AudioModule.kt | 26 +++--- .../expo/modules/audio/AudioPreloadCache.kt | 89 ------------------- .../expo/modules/audio/AudioPreloadManager.kt | 55 ++++++++++++ .../main/java/expo/modules/audio/Playable.kt | 30 +++---- packages/expo-audio/build/ExpoAudio.d.ts | 4 +- packages/expo-audio/build/ExpoAudio.js | 4 +- packages/expo-audio/build/ExpoAudio.js.map | 2 +- packages/expo-audio/src/ExpoAudio.ts | 4 +- 10 files changed, 91 insertions(+), 126 deletions(-) delete mode 100644 packages/expo-audio/android/src/main/java/expo/modules/audio/AudioPreloadCache.kt create mode 100644 packages/expo-audio/android/src/main/java/expo/modules/audio/AudioPreloadManager.kt diff --git a/apps/bare-expo/android/.idea/codeStyles/Project.xml b/apps/bare-expo/android/.idea/codeStyles/Project.xml index a73b649c129db4..10c87e5f82a837 100644 --- a/apps/bare-expo/android/.idea/codeStyles/Project.xml +++ b/apps/bare-expo/android/.idea/codeStyles/Project.xml @@ -73,7 +73,6 @@ -