Skip to content
Open
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
15 changes: 15 additions & 0 deletions .changeset/stale-mangos-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@iota/dapp-kit': minor
---

Add cross-tab wallet state synchronization via a new `syncTabs` prop on `WalletProvider`.

When enabled, all open tabs automatically react to wallet connection changes made in any other tab - including connecting, switching accounts, and disconnecting - without requiring a page reload.

The feature is opt-in (disabled by default) to avoid breaking existing apps:

```tsx
<WalletProvider syncTabs={true}>
{children}
</WalletProvider>
```
14 changes: 12 additions & 2 deletions sdk/dapp-kit/src/components/WalletProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '../constants/walletDefaults.js';
import { WalletContext } from '../contexts/walletContext.js';
import { useAutoConnectWallet } from '../hooks/wallet/useAutoConnectWallet.js';
import { useStorageEventListener } from '../hooks/wallet/useStorageEventListener.js';
import { useUnsafeBurnerWallet } from '../hooks/wallet/useUnsafeBurnerWallet.js';
import { useWalletPropertiesChanged } from '../hooks/wallet/useWalletPropertiesChanged.js';
import { useWalletsChanged } from '../hooks/wallet/useWalletsChanged.js';
Expand Down Expand Up @@ -45,6 +46,9 @@ export type WalletProviderProps = {
/** The key to use to store the most recently connected wallet account. */
storageKey?: string;

/** Enables cross-tab wallet state synchronization via localStorage storage events. Defaults to false. */
syncTabs?: boolean;

/** The theme to use for styling UI components. Defaults to using the light theme. */
theme?: Theme | null;

Expand All @@ -62,6 +66,7 @@ export function WalletProvider({
storageKey = DEFAULT_STORAGE_KEY,
enableUnsafeBurner = false,
autoConnect = false,
syncTabs = false,
theme = lightTheme,
children,
chain,
Expand All @@ -86,6 +91,8 @@ export function WalletProvider({
preferredWallets={preferredWallets}
walletFilter={walletFilter}
enableUnsafeBurner={enableUnsafeBurner}
syncTabs={syncTabs}
storageKey={storageKey}
>
{/* TODO: We ideally don't want to inject styles if people aren't using the UI components */}
{theme ? <InjectedThemeStyles theme={theme} /> : null}
Expand All @@ -97,19 +104,22 @@ export function WalletProvider({

type WalletConnectionManagerProps = Pick<
WalletProviderProps,
'preferredWallets' | 'walletFilter' | 'enableUnsafeBurner' | 'children'
>;
'preferredWallets' | 'walletFilter' | 'enableUnsafeBurner' | 'syncTabs' | 'children'
> & { storageKey: string };

function WalletConnectionManager({
preferredWallets = DEFAULT_PREFERRED_WALLETS,
walletFilter = DEFAULT_WALLET_FILTER,
enableUnsafeBurner = false,
syncTabs = false,
storageKey,
children,
}: WalletConnectionManagerProps) {
useWalletsChanged(preferredWallets, walletFilter);
useWalletPropertiesChanged();
useUnsafeBurnerWallet(enableUnsafeBurner);
useAutoConnectWallet();
useStorageEventListener(syncTabs, storageKey);

return children;
}
109 changes: 109 additions & 0 deletions sdk/dapp-kit/src/hooks/wallet/useStorageEventListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { useContext, useEffect, useRef } from 'react';

import { WalletContext } from '../../contexts/walletContext.js';
import { getWalletUniqueIdentifier } from '../../utils/walletUtils.js';
import { useConnectWallet } from './useConnectWallet.js';
import { useWallets } from './useWallets.js';

/**
* Internal hook that listens to localStorage `storage` events from other browser tabs
* and synchronizes wallet connection state reactively. Only active when `enabled` is true.
*/
export function useStorageEventListener(enabled: boolean, storageKey: string) {
const store = useContext(WalletContext);
const { mutateAsync: connectWallet } = useConnectWallet();
const wallets = useWallets();

// Refs keep the handler stable (registered once) while always seeing the latest values.
const connectWalletRef = useRef(connectWallet);
const walletsRef = useRef(wallets);
const processingRef = useRef(false);

useEffect(() => {
connectWalletRef.current = connectWallet;
}, [connectWallet]);

useEffect(() => {
walletsRef.current = wallets;
}, [wallets]);

useEffect(() => {
if (!enabled || typeof window === 'undefined') return;

const handler = async (event: StorageEvent) => {
if (event.key !== storageKey) return;

// Drop events that arrive while we are already processing one.
if (processingRef.current) return;

if (!event.newValue) {
store?.getState().setWalletDisconnected();
return;
}

let lastConnectedWalletName: string | null = null;
let lastConnectedAccountAddress: string | null = null;
try {
const parsed = JSON.parse(event.newValue);
lastConnectedWalletName = parsed?.state?.lastConnectedWalletName ?? null;
lastConnectedAccountAddress = parsed?.state?.lastConnectedAccountAddress ?? null;
} catch {
return;
}

if (!lastConnectedWalletName || !lastConnectedAccountAddress) {
store?.getState().setWalletDisconnected();
return;
}

// Read current state imperatively — never from a closure — so we always
// compare against the latest committed value.
const state = store?.getState();
const currentAccountAddress = state?.currentAccount?.address;
if (currentAccountAddress === lastConnectedAccountAddress) return;

const currentWalletName = state?.currentWallet
? getWalletUniqueIdentifier(state.currentWallet)
: null;
const isSameWallet = currentWalletName === lastConnectedWalletName;

processingRef.current = true;
try {
if (isSameWallet && state?.currentWallet) {
// Wallet is already connected — just switch the active account locally.
// This avoids a full reconnect round-trip and does NOT write to localStorage,
// breaking the ping-pong loop.
const accountToSelect = state.currentWallet.accounts.find(
(a) => a.address === lastConnectedAccountAddress,
);
if (accountToSelect) {
state.setAccountSwitched(accountToSelect);
}
} else {
// Different wallet or not yet connected — full connect needed.
const wallet = walletsRef.current.find(
(w) => getWalletUniqueIdentifier(w) === lastConnectedWalletName,
);
if (wallet) {
await connectWalletRef.current({
wallet,
accountAddress: lastConnectedAccountAddress,
silent: true,
});
}
}
} finally {
processingRef.current = false;
}
};

window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);

// Intentionally excludes connectWallet and wallets — those are kept
// fresh via refs so the handler is registered exactly once per storageKey change.
}, [enabled, storageKey, store]);
}
Loading
Loading