From 086cb0672ecdfcc7fb12d180fda5bd799fbbee00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caner=20Yakupo=C4=9Flu?= Date: Tue, 29 Sep 2026 13:57:35 +0300 Subject: [PATCH] feat(freighter-connect): react to Freighter account changes without reload --- .../__tests__/freighterAccount.test.ts | 205 ++++++++++++++++++ .../freighter-connect/hooks/useFreighter.ts | 21 +- .../freighter-connect/lib/freighterAccount.ts | 127 +++++++++++ features/freighter-connect/types.ts | 2 + 4 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 features/freighter-connect/__tests__/freighterAccount.test.ts create mode 100644 features/freighter-connect/lib/freighterAccount.ts diff --git a/features/freighter-connect/__tests__/freighterAccount.test.ts b/features/freighter-connect/__tests__/freighterAccount.test.ts new file mode 100644 index 00000000..2e5ec992 --- /dev/null +++ b/features/freighter-connect/__tests__/freighterAccount.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + FREIGHTER_ACCOUNT_POLL_MS, + accountFromListenerEvent, + readFreighterAccountState, + subscribeFreighterAccountChanges, + type FreighterApi +} from "@/features/freighter-connect/lib/freighterAccount"; + +const KEY_A = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; +const KEY_B = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; + +// readFreighterAccountState awaits several wallet calls, so a single microtask +// turn is not enough to observe the initial poll emit. Drain the queue instead. +const flushAsync = async () => { + for (let i = 0; i < 20; i += 1) { + await Promise.resolve(); + } +}; + +describe("readFreighterAccountState", () => { + it("returns the same connected account when wallet state is unchanged", async () => { + const api: FreighterApi = { + isConnected: async () => true, + isAllowed: async () => true, + getPublicKey: async () => KEY_A, + getNetwork: async () => "TESTNET" + }; + + const first = await readFreighterAccountState(api); + const second = await readFreighterAccountState(api); + + expect(first).toEqual({ connected: true, publicKey: KEY_A, walletNetwork: "TESTNET" }); + expect(second).toEqual(first); + }); + + it("returns a changed public key when Freighter switches accounts", async () => { + let currentKey = KEY_A; + const api: FreighterApi = { + isConnected: async () => true, + getPublicKey: async () => currentKey, + getNetwork: async () => "TESTNET" + }; + + expect(await readFreighterAccountState(api)).toMatchObject({ publicKey: KEY_A }); + + currentKey = KEY_B; + + expect(await readFreighterAccountState(api)).toMatchObject({ publicKey: KEY_B }); + }); + + it("returns disconnected state when permission is revoked", async () => { + const api: FreighterApi = { + isConnected: async () => false, + isAllowed: async () => false, + getPublicKey: async () => KEY_A + }; + + expect(await readFreighterAccountState(api)).toEqual({ + connected: false, + publicKey: "", + walletNetwork: "" + }); + }); + + it("returns disconnected state when getPublicKey throws", async () => { + const api: FreighterApi = { + isConnected: async () => true, + getPublicKey: async () => { + throw new Error("revoked"); + } + }; + + expect(await readFreighterAccountState(api)).toEqual({ + connected: false, + publicKey: "", + walletNetwork: "" + }); + }); +}); + +describe("accountFromListenerEvent", () => { + it("maps a changed account to connected state", () => { + expect(accountFromListenerEvent(KEY_B)).toEqual({ connected: true, publicKey: KEY_B }); + }); + + it("maps empty account values to disconnected state", () => { + expect(accountFromListenerEvent("")).toEqual({ connected: false, publicKey: "" }); + expect(accountFromListenerEvent(" ")).toEqual({ connected: false, publicKey: "" }); + }); +}); + +describe("subscribeFreighterAccountChanges", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("updates through account listeners and removes them on cleanup", async () => { + let registeredListener: ((account: string) => void) | undefined; + const removeAccountListener = vi.fn(); + const api: FreighterApi = { + addAccountListener: (callback) => { + registeredListener = callback; + }, + removeAccountListener, + getNetwork: async () => "TESTNET" + }; + const onUpdate = vi.fn(); + + const unsubscribe = subscribeFreighterAccountChanges(api, onUpdate); + + registeredListener?.(KEY_A); + await Promise.resolve(); + registeredListener?.(KEY_B); + await Promise.resolve(); + + expect(onUpdate).toHaveBeenCalledWith({ + connected: true, + publicKey: KEY_A, + walletNetwork: "" + }); + expect(onUpdate).toHaveBeenCalledWith({ + connected: true, + publicKey: KEY_B, + walletNetwork: "" + }); + expect(onUpdate).toHaveBeenCalledWith({ + connected: true, + publicKey: KEY_B, + walletNetwork: "TESTNET" + }); + + unsubscribe(); + + expect(removeAccountListener).toHaveBeenCalledTimes(1); + expect(removeAccountListener.mock.calls[0][0]).toBe(registeredListener); + }); + + it("polls for account changes when listeners are unavailable", async () => { + let currentKey = KEY_A; + const api: FreighterApi = { + isConnected: async () => true, + getPublicKey: async () => currentKey, + getNetwork: async () => "TESTNET" + }; + const onUpdate = vi.fn(); + + const unsubscribe = subscribeFreighterAccountChanges(api, onUpdate); + + await flushAsync(); + expect(onUpdate).toHaveBeenCalledWith({ + connected: true, + publicKey: KEY_A, + walletNetwork: "TESTNET" + }); + + currentKey = KEY_B; + await vi.advanceTimersByTimeAsync(FREIGHTER_ACCOUNT_POLL_MS); + + expect(onUpdate).toHaveBeenCalledWith({ + connected: true, + publicKey: KEY_B, + walletNetwork: "TESTNET" + }); + + await vi.advanceTimersByTimeAsync(FREIGHTER_ACCOUNT_POLL_MS); + const callCount = onUpdate.mock.calls.length; + + unsubscribe(); + + await vi.advanceTimersByTimeAsync(FREIGHTER_ACCOUNT_POLL_MS); + expect(onUpdate.mock.calls.length).toBe(callCount); + }); + + it("polls revoked access back to disconnected state", async () => { + let connected = true; + const api: FreighterApi = { + isConnected: async () => connected, + getPublicKey: async () => { + if (!connected) { + throw new Error("revoked"); + } + + return KEY_A; + } + }; + const onUpdate = vi.fn(); + + subscribeFreighterAccountChanges(api, onUpdate); + + await Promise.resolve(); + connected = false; + await vi.advanceTimersByTimeAsync(FREIGHTER_ACCOUNT_POLL_MS); + + expect(onUpdate).toHaveBeenCalledWith({ + connected: false, + publicKey: "", + walletNetwork: "" + }); + }); +}); diff --git a/features/freighter-connect/hooks/useFreighter.ts b/features/freighter-connect/hooks/useFreighter.ts index b083e5a8..e3084453 100644 --- a/features/freighter-connect/hooks/useFreighter.ts +++ b/features/freighter-connect/hooks/useFreighter.ts @@ -1,7 +1,9 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import { subscribeFreighterAccountChanges } from "@/features/freighter-connect/lib/freighterAccount"; import { readWallet, requestAccess } from "@/features/freighter-connect/lib/freighter"; +import { readFreighterApi } from "@/features/freighter-connect/schema"; import type { FreighterErrorCode, WalletSnapshot @@ -27,15 +29,28 @@ export function useFreighter() { useEffect(() => { let active = true; + let unsubscribe: (() => void) | undefined; + + const syncWallet = () => { + void readWallet().then((result) => { + if (active) setState(toState(result)); + }); + }; // The extension injects itself asynchronously, so the wallet cannot be // read during render. - void readWallet().then((result) => { - if (active) setState(toState(result)); - }); + syncWallet(); + + const api = readFreighterApi(); + if (api.ok) { + unsubscribe = subscribeFreighterAccountChanges(api.value, () => { + syncWallet(); + }); + } return () => { active = false; + unsubscribe?.(); }; }, []); diff --git a/features/freighter-connect/lib/freighterAccount.ts b/features/freighter-connect/lib/freighterAccount.ts new file mode 100644 index 00000000..06b884c2 --- /dev/null +++ b/features/freighter-connect/lib/freighterAccount.ts @@ -0,0 +1,127 @@ +import type { FreighterApi } from "@/features/freighter-connect/types"; + +export interface FreighterAccountState { + connected: boolean; + publicKey: string; + walletNetwork: string; +} + +export const FREIGHTER_ACCOUNT_POLL_MS = 2000; + +export function accountFromListenerEvent( + account: string +): Pick { + const publicKey = account.trim(); + + if (!publicKey) { + return { connected: false, publicKey: "" }; + } + + return { connected: true, publicKey }; +} + +export async function readFreighterAccountState(api: FreighterApi): Promise { + const isConnected = api.isConnected ? await api.isConnected().catch(() => false) : false; + const isAllowed = api.isAllowed ? await api.isAllowed().catch(() => false) : false; + const connected = isConnected || isAllowed; + + if (!connected) { + return { connected: false, publicKey: "", walletNetwork: "" }; + } + + if (!api.getPublicKey) { + return { connected: true, publicKey: "", walletNetwork: "" }; + } + + let publicKey = ""; + + try { + publicKey = (await api.getPublicKey()).trim(); + } catch { + return { connected: false, publicKey: "", walletNetwork: "" }; + } + + if (!publicKey) { + return { connected: false, publicKey: "", walletNetwork: "" }; + } + + const walletNetwork = api.getNetwork ? await api.getNetwork().catch(() => "") : ""; + + return { connected: true, publicKey, walletNetwork }; +} + +export function subscribeFreighterAccountChanges( + api: FreighterApi, + onUpdate: (state: FreighterAccountState) => void +): () => void { + let disposed = false; + let lastSnapshot = ""; + + const emitIfChanged = (state: FreighterAccountState) => { + const snapshot = `${state.connected}:${state.publicKey}:${state.walletNetwork}`; + + if (snapshot === lastSnapshot) { + return; + } + + lastSnapshot = snapshot; + onUpdate(state); + }; + + const refreshFromWallet = () => { + if (disposed) { + return; + } + + void readFreighterAccountState(api).then((state) => { + if (!disposed) { + emitIfChanged(state); + } + }); + }; + + const listener = (account: string) => { + if (disposed) { + return; + } + + const next = accountFromListenerEvent(account); + emitIfChanged({ + connected: next.connected, + publicKey: next.publicKey, + walletNetwork: "" + }); + + if (next.connected && api.getNetwork) { + void api + .getNetwork() + .then((walletNetwork) => { + if (!disposed && next.connected) { + emitIfChanged({ + connected: true, + publicKey: next.publicKey, + walletNetwork + }); + } + }) + .catch(() => {}); + } + }; + + if (api.addAccountListener && api.removeAccountListener) { + api.addAccountListener(listener); + + return () => { + disposed = true; + api.removeAccountListener?.(listener); + }; + } + + refreshFromWallet(); + const pollTimer = setInterval(refreshFromWallet, FREIGHTER_ACCOUNT_POLL_MS); + + return () => { + disposed = true; + clearInterval(pollTimer); + }; +} diff --git a/features/freighter-connect/types.ts b/features/freighter-connect/types.ts index 1cfb522c..2a559d2c 100644 --- a/features/freighter-connect/types.ts +++ b/features/freighter-connect/types.ts @@ -9,6 +9,8 @@ export interface FreighterApi { setAllowed?: () => Promise; getPublicKey?: () => Promise; getNetwork?: () => Promise; + addAccountListener?: (callback: (account: string) => void) => void; + removeAccountListener?: (callback: (account: string) => void) => void; } export interface WalletSnapshot {