Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/pretty-walls-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@onflow/fcl-react-native": minor
"@onflow/react-native-sdk": minor
---

Improved wc redirect flexibility and updated connect modal to be normal centered modal for better layout support.
6 changes: 6 additions & 0 deletions packages/fcl-react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,9 @@ npm install --save @onflow/fcl @onflow/types
For a detailed guide explaining how to use `@onflow/fcl` to interact with Flow please see the [Flow App Quick Start](https://developers.flow.com/tutorials/flow-app-quickstart)

Having trouble with something? Reach out to us on [Discord](https://discord.gg/k6cZ7QC), we are more than happy to help.

## WalletConnect Deeplinks

This package uses `wc-redirect` as the deeplink path for WalletConnect redirects (e.g., `myapp://wc-redirect`). When a wallet approves a connection or transaction, it redirects back to your app using this path.

If you're using Expo Router, you may want to intercept this path to prevent unwanted navigation. See `@onflow/react-native-sdk` README for details.
173 changes: 73 additions & 100 deletions packages/fcl-react-native/src/utils/react-native/ConnectModal.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {Image} from "expo-image"
import {createElement, useEffect, useRef, useState} from "react"
import {createElement, useEffect, useState} from "react"
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unused import 'useRef' was removed, but 'Animated' is also no longer used after removing the animation logic and should be removed from the imports as well.

Suggested change
import {createElement, useEffect, useState} from "react"
import {createElement} from "react"

Copilot uses AI. Check for mistakes.
import {
Animated,
Modal,
Pressable,
SafeAreaView,
ScrollView,
StyleSheet,
Expand Down Expand Up @@ -92,40 +92,15 @@ export const ConnectModal = ({
}) => {
const {services, isLoading} = useServiceDiscovery({fcl})

// Animation values
const backdropOpacity = useRef(new Animated.Value(0)).current
const slideAnim = useRef(new Animated.Value(300)).current

// Double-click protection
const [isAuthenticating, setIsAuthenticating] = useState(false)

// Animate backdrop and content when modal visibility changes
// Reset authentication state when modal opens
useEffect(() => {
if (visible) {
// Fade in backdrop instantly (fast)
Animated.timing(backdropOpacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}).start()

// Slide up content with spring animation
Animated.spring(slideAnim, {
toValue: 0,
tension: 65,
friction: 10,
useNativeDriver: true,
}).start()

// Reset authentication state when modal opens
setIsAuthenticating(false)
} else {
// Reset animations when modal closes
backdropOpacity.setValue(0)
slideAnim.setValue(300)
setIsAuthenticating(false)
}
}, [visible, backdropOpacity, slideAnim])
}, [visible])

const handleServiceSelect = service => {
// Prevent double-click: ignore if already authenticating
Expand All @@ -141,76 +116,70 @@ export const ConnectModal = ({
{
visible,
transparent: true,
animationType: "none",
animationType: "fade",
onRequestClose: onClose,
},
createElement(
Animated.View,
{style: [styles.backdrop, {opacity: backdropOpacity}]},
createElement(TouchableOpacity, {
style: styles.backdropTouchable,
activeOpacity: 1,
onPress: onClose,
}),
Pressable,
{style: styles.backdrop, onPress: onClose},
createElement(
SafeAreaView,
{style: styles.safeArea},
Pressable,
{style: styles.modalContainer, onPress: e => e.stopPropagation()},
createElement(
Animated.View,
{
style: [
styles.modalContent,
{transform: [{translateY: slideAnim}]},
],
},
// Header
SafeAreaView,
{style: styles.safeArea},
createElement(
View,
{style: styles.header},
createElement(Text, {style: styles.title}, title),
{style: styles.modalContent},
// Header
createElement(
TouchableOpacity,
{onPress: onClose, style: styles.closeButton},
createElement(Text, {style: styles.closeButtonText}, "✕")
)
),
// Content
createElement(
Wrapper,
null,
isLoading &&
(Loading
? createElement(Loading)
: createElement(
View,
{style: styles.loadingContainer},
createElement(
Text,
{style: styles.loadingText},
"Loading wallets..."
)
)),
!isLoading &&
services.length === 0 &&
(Empty
? createElement(Empty)
: createElement(
View,
{style: styles.emptyContainer},
createElement(
Text,
{style: styles.emptyText},
"No wallets found"
)
)),
!isLoading &&
services.map((service, index) => {
return createElement(ServiceCard, {
key: service?.provider?.address ?? service?.uid ?? index,
service,
onPress: () => handleServiceSelect(service),
View,
{style: styles.header},
createElement(Text, {style: styles.title}, title),
createElement(
TouchableOpacity,
{onPress: onClose, style: styles.closeButton},
createElement(Text, {style: styles.closeButtonText}, "✕")
)
),
// Content
createElement(
Wrapper,
null,
isLoading &&
(Loading
? createElement(Loading)
: createElement(
View,
{style: styles.loadingContainer},
createElement(
Text,
{style: styles.loadingText},
"Loading wallets..."
)
)),
!isLoading &&
services.length === 0 &&
(Empty
? createElement(Empty)
: createElement(
View,
{style: styles.emptyContainer},
createElement(
Text,
{style: styles.emptyText},
"No wallets found"
)
)),
!isLoading &&
services.map((service, index) => {
return createElement(ServiceCard, {
key: service?.provider?.address ?? service?.uid ?? index,
service,
onPress: () => handleServiceSelect(service),
})
})
})
)
)
)
)
Expand All @@ -222,23 +191,27 @@ const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
justifyContent: "center",
alignItems: "center",
padding: 16,
},
backdropTouchable: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
modalContainer: {
width: "100%",
maxWidth: 400,
maxHeight: "80%",
},
safeArea: {
maxHeight: "80%",
width: "100%",
},
modalContent: {
backgroundColor: "#ffffff",
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
borderRadius: 20,
overflow: "hidden",
shadowColor: "#000000",
shadowOffset: {width: 0, height: 4},
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 8,
},
header: {
flexDirection: "row",
Expand Down
4 changes: 3 additions & 1 deletion packages/fcl-react-native/src/walletconnect/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ const initClient = async ({
await initializeWalletConnect()

// Auto-detect redirect URI using expo-linking (always available as dependency)
const redirect = Linking.createURL("")
// We use a unique path that apps can intercept
// This allows apps to handle the redirect however they want (e.g., stay on current screen)
const redirect = Linking.createURL("wc-redirect")

// Build metadata
const clientMetadata = metadata || {
Expand Down
6 changes: 6 additions & 0 deletions packages/react-native-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ Here's a look at some of the hooks available. For a full list, see the [official
- `<Connect />` - Wallet connection button with built-in profile modal
- `<Profile />` - Displays connected wallet information with disconnect option

## 🔗 WalletConnect Deeplinks

This SDK uses `wc-redirect` as the deeplink path for WalletConnect redirects (e.g., `myapp://wc-redirect`). When a wallet approves a connection or transaction, it redirects back to your app using this path.

To prevent navigation flashes, you can intercept this path using Expo Router's `+native-intent.tsx`. See [Expo Router Native Intent documentation](https://docs.expo.dev/router/advanced/native-intent/) for details.

## 📚 Full Documentation

Looking for full API docs, examples, and usage tips?
Expand Down
32 changes: 31 additions & 1 deletion packages/react-native-sdk/src/provider/FlowProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ const defaultQueryOptions: DefaultOptions = {
},
}

// Singleton to preserve flowClient across remounts (e.g., deeplink navigation)
// This prevents auth state from being lost when expo-router causes remounts
let cachedFlowClient: ReturnType<typeof createFlowClient> | null = null
let cachedConfigKey: string | null = null

function getConfigKey(
cfg: FlowConfig,
flowJson?: Record<string, unknown>
): string {
// Create a stable key from config to detect if config actually changed
return JSON.stringify({
accessNodeUrl: cfg.accessNodeUrl,
flowNetwork: cfg.flowNetwork,
walletconnectProjectId: cfg.walletconnectProjectId,
})
}
Comment on lines +42 to +48
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The config key only includes three properties, but createFlowClient uses many more properties (discoveryWallet, discoveryWalletMethod, flowJson, etc.). If any of these other properties change, the cached client will be incorrectly reused with stale configuration.

Suggested change
// Create a stable key from config to detect if config actually changed
return JSON.stringify({
accessNodeUrl: cfg.accessNodeUrl,
flowNetwork: cfg.flowNetwork,
walletconnectProjectId: cfg.walletconnectProjectId,
})
}
// Create a stable key from all config inputs to detect if config actually changed
return JSON.stringify({
cfg,
flowJson,
})
}
}

Copilot uses AI. Check for mistakes.

Comment on lines +33 to +49
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Module-level singletons can cause issues in environments where multiple instances of FlowProvider might be mounted simultaneously (e.g., in testing, multi-window apps, or server-side rendering). Consider using React context or a WeakMap keyed by config to manage instances safely.

Suggested change
// Singleton to preserve flowClient across remounts (e.g., deeplink navigation)
// This prevents auth state from being lost when expo-router causes remounts
let cachedFlowClient: ReturnType<typeof createFlowClient> | null = null
let cachedConfigKey: string | null = null
function getConfigKey(
cfg: FlowConfig,
flowJson?: Record<string, unknown>
): string {
// Create a stable key from config to detect if config actually changed
return JSON.stringify({
accessNodeUrl: cfg.accessNodeUrl,
flowNetwork: cfg.flowNetwork,
walletconnectProjectId: cfg.walletconnectProjectId,
})
}
// Cache Flow clients per FlowConfig to preserve auth state across remounts
// while avoiding a single global singleton shared across all FlowProvider instances.
const flowClientCache = new WeakMap<
FlowConfig,
ReturnType<typeof createFlowClient>
>()

Copilot uses AI. Check for mistakes.
export function FlowProvider({
config: initialConfig = {},
queryClient: _queryClient,
Expand All @@ -43,7 +60,14 @@ export function FlowProvider({

const flowClient = useMemo(() => {
if (_flowClient) return _flowClient
return createFlowClient({

// Check if we can reuse cached client (same config)
const configKey = getConfigKey(initialConfig, flowJson)
if (cachedFlowClient && cachedConfigKey === configKey) {
return cachedFlowClient
}

const client = createFlowClient({
accessNodeUrl: initialConfig.accessNodeUrl!,
discoveryWallet: initialConfig.discoveryWallet,
discoveryWalletMethod: initialConfig.discoveryWalletMethod,
Expand All @@ -62,6 +86,12 @@ export function FlowProvider({
appDetailUrl: initialConfig.appDetailUrl,
serviceOpenIdScopes: initialConfig.serviceOpenIdScopes,
})

// Cache for reuse across remounts
cachedFlowClient = client
cachedConfigKey = configKey

return client
}, [_flowClient, initialConfig, flowJson])

// Set discovery.authn.endpoint in global FCL config for ServiceDiscovery
Expand Down
Loading