From 368577d9e050c90331121bdba293ecac234ef338 Mon Sep 17 00:00:00 2001 From: Marian Date: Fri, 19 Dec 2025 10:29:29 +0200 Subject: [PATCH] feat: add openUniversalLink method to Linking API (iOS only) This adds a new method to the Linking API that allows opening URLs as universal links on iOS using UIApplicationOpenURLOptionUniversalLinksOnly. Changes: - Add openUniversalLink method to Linking.js with iOS-only implementation - Add native implementation in RCTLinkingManager.mm with iOS 10.0+ availability check - Add TypeScript definitions in Linking.d.ts - Update TurboModule spec in NativeLinkingManager.js - Add Jest mock for openUniversalLink - Add RNTester example demonstrating the feature - Regenerate ReactNativeApi.d.ts snapshot The method returns a Promise indicating whether the URL was successfully opened as a universal link. Related: https://reactnative.dev/docs/linking#openuniversallink --- .../Libraries/Linking/Linking.d.ts | 7 +++ .../react-native/Libraries/Linking/Linking.js | 17 ++++++ .../Libraries/LinkingIOS/RCTLinkingManager.mm | 14 +++++ packages/react-native/ReactNativeApi.d.ts | 5 +- packages/react-native/jest/mocks/Linking.js | 4 ++ .../modules/NativeLinkingManager.js | 3 ++ .../js/examples/Linking/LinkingExample.js | 54 +++++++++++++++++++ 7 files changed, 102 insertions(+), 2 deletions(-) diff --git a/packages/react-native/Libraries/Linking/Linking.d.ts b/packages/react-native/Libraries/Linking/Linking.d.ts index 55d7aa33cb58..be3ea3529c7e 100644 --- a/packages/react-native/Libraries/Linking/Linking.d.ts +++ b/packages/react-native/Libraries/Linking/Linking.d.ts @@ -55,6 +55,13 @@ export interface LinkingImpl extends NativeEventEmitter { action: string, extras?: Array<{key: string; value: string | number | boolean}>, ): Promise; + + /** + * Open a URL as a universal link if possible. + * Returns a Promise that resolves to a boolean indicating whether the URL was opened. + * @platform ios + */ + openUniversalLink(url: string): Promise; } export const Linking: LinkingImpl; diff --git a/packages/react-native/Libraries/Linking/Linking.js b/packages/react-native/Libraries/Linking/Linking.js index 016e3186a138..268c05a28b90 100644 --- a/packages/react-native/Libraries/Linking/Linking.js +++ b/packages/react-native/Libraries/Linking/Linking.js @@ -114,6 +114,23 @@ class LinkingImpl extends NativeEventEmitter { } } + /** + * Open a URL as a universal link if possible. + * Returns a Promise that resolves to a boolean indicating whether the URL was opened. + * + * @platform ios + * + * See https://reactnative.dev/docs/linking#openuniversallink + */ + openUniversalLink(url: string): Promise { + this._validateURL(url); + if (Platform.OS === 'ios') { + return nullthrows(NativeLinkingManager).openUniversalLink(url); + } else { + return new Promise((resolve, reject) => reject(new Error('Unsupported'))); + } + } + _validateURL(url: string): void { invariant( typeof url === 'string', diff --git a/packages/react-native/Libraries/LinkingIOS/RCTLinkingManager.mm b/packages/react-native/Libraries/LinkingIOS/RCTLinkingManager.mm index db689678d652..be9e1d889c65 100644 --- a/packages/react-native/Libraries/LinkingIOS/RCTLinkingManager.mm +++ b/packages/react-native/Libraries/LinkingIOS/RCTLinkingManager.mm @@ -177,6 +177,20 @@ - (void)handleOpenURLNotification:(NSNotification *)notification }]; } +RCT_EXPORT_METHOD(openUniversalLink : (NSURL *)URL resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) +{ + if (URL == nil) { + reject(RCTErrorUnspecified, @"Invalid URL passed to openUniversalLink", nil); + return; + } + + [RCTSharedApplication() openURL:URL + options:@{UIApplicationOpenURLOptionUniversalLinksOnly : @YES} + completionHandler:^(BOOL success) { + resolve(@(success)); + }]; +} + RCT_EXPORT_METHOD( sendIntent : (NSString *)action extras : (NSArray *_Nullable)extras resolve : (RCTPromiseResolveBlock) resolve reject : (RCTPromiseRejectBlock)reject) diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 1663252c3ed2..bbc9169af6b8 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -3019,6 +3019,7 @@ declare class LinkingImpl extends NativeEventEmitter { constructor() getInitialURL(): Promise openSettings(): Promise + openUniversalLink(url: string): Promise openURL(url: string): Promise sendIntent( action: string, @@ -6070,7 +6071,7 @@ export { LayoutChangeEvent, // c674f902 LayoutConformanceProps, // 055f03b8 LayoutRectangle, // 6601b294 - Linking, // b5645d2b + Linking, // 569f787d ListRenderItem, // b5353fd8 ListRenderItemInfo, // e8595b03 ListViewToken, // 833d3481 diff --git a/packages/react-native/jest/mocks/Linking.js b/packages/react-native/jest/mocks/Linking.js index c558c824a149..cc7619321d8b 100644 --- a/packages/react-native/jest/mocks/Linking.js +++ b/packages/react-native/jest/mocks/Linking.js @@ -23,6 +23,10 @@ const Linking = { $FlowFixMe, >, sendIntent: jest.fn() as JestMockFn<$FlowFixMe, $FlowFixMe>, + openUniversalLink: jest.fn(() => Promise.resolve(false)) as JestMockFn< + $FlowFixMe, + $FlowFixMe, + >, }; export default Linking; diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeLinkingManager.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeLinkingManager.js index 60d1aa0f1fef..7c0206f156aa 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeLinkingManager.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeLinkingManager.js @@ -19,6 +19,9 @@ export interface Spec extends TurboModule { +openURL: (url: string) => Promise; +openSettings: () => Promise; + // iOS only + +openUniversalLink: (url: string) => Promise; + // Events +addListener: (eventName: string) => void; +removeListeners: (count: number) => void; diff --git a/packages/rn-tester/js/examples/Linking/LinkingExample.js b/packages/rn-tester/js/examples/Linking/LinkingExample.js index 34a45fcbd8aa..0e362641cd2d 100644 --- a/packages/rn-tester/js/examples/Linking/LinkingExample.js +++ b/packages/rn-tester/js/examples/Linking/LinkingExample.js @@ -65,6 +65,40 @@ class OpenSettingsExample extends React.Component<$ReadOnly<{}>> { } } +const OpenUniversalLinkButton = ({ + url, +}: $ReadOnly<{ + url: string, +}>) => { + const [isOpening, setIsOpening] = useState(false); + + const handlePress = async () => { + setIsOpening(true); + try { + const opened = await Linking.openUniversalLink(url); + if (!opened) { + console.log( + `Could not open universal link: ${url}. The app may not be installed or the URL is not a valid universal link.`, + ); + } + } catch (e) { + console.log(`Error opening universal link: ${e.message}`); + } finally { + setIsOpening(false); + } + }; + + return ( + + + + {isOpening ? `Opening ${url}...` : `Open Universal Link: ${url}`} + + + + ); +}; + const SendIntentButton = ({ action, extras, @@ -143,6 +177,9 @@ const styles = StyleSheet.create({ buttonIntent: { backgroundColor: '#009688', }, + buttonUniversalLink: { + backgroundColor: '#FF5722', + }, text: { color: 'white', }, @@ -170,4 +207,21 @@ exports.examples = [ return ; }, }, + { + title: 'Open Universal Link (iOS only)', + description: + 'Opens a URL as a universal link. Returns true if the URL was opened by an app, false otherwise.', + render(): React.MixedElement { + return Platform.OS === 'ios' ? ( + + + + + ) : ( + This feature is only available on iOS. + ); + }, + }, ] as Array;