Skip to content

Commit dffa983

Browse files
author
Nuel-ship-it
authored
feat: QR modal wallet-connection auto-detection via polling (#211) (#258)
- Add isWalletConnected() helper to freighter.ts (checks WalletConnect then Freighter) - Replace placeholder effect in QRModal with 2s polling loop - firedRef ensures onConnected fires exactly once per open cycle - Interval cleared on unmount and when open becomes false - pollingInterval prop (default 2000ms) makes interval configurable - 6 unit tests: fires once, no double-fire, not connected, stops on close, no leak, custom interval
1 parent 8f8b70e commit dffa983

3 files changed

Lines changed: 199 additions & 8 deletions

File tree

src/__tests__/QRModal.test.tsx

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* Unit tests for QRModal polling (#211).
3+
*
4+
* Covers:
5+
* - onConnected fires exactly once when wallet connects
6+
* - polling stops (no extra calls) after connection detected
7+
* - polling stops when open becomes false
8+
* - no interval leak across open/close cycles
9+
* - pollingInterval prop controls the delay
10+
*/
11+
12+
import React from "react";
13+
import { render, act } from "@testing-library/react";
14+
import QRModal from "@/components/QRModal";
15+
16+
// ── mock isWalletConnected ────────────────────────────────────────────────────
17+
const mockIsWalletConnected = jest.fn();
18+
jest.mock("@/lib/freighter", () => ({
19+
isWalletConnected: (...args: unknown[]) => mockIsWalletConnected(...args),
20+
}));
21+
22+
// ── mock FocusTrap (renders children) ────────────────────────────────────────
23+
jest.mock("@/components/FocusTrap", () => ({
24+
__esModule: true,
25+
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
26+
}));
27+
28+
// ── mock qrcode.react ─────────────────────────────────────────────────────────
29+
jest.mock("qrcode.react", () => ({
30+
QRCodeCanvas: () => <canvas />,
31+
}));
32+
33+
const baseProps = {
34+
open: true,
35+
uri: "wc:test-uri",
36+
onClose: jest.fn(),
37+
};
38+
39+
beforeEach(() => {
40+
jest.useFakeTimers();
41+
mockIsWalletConnected.mockResolvedValue(false);
42+
});
43+
44+
afterEach(() => {
45+
jest.useRealTimers();
46+
jest.clearAllMocks();
47+
});
48+
49+
test("onConnected fires exactly once when wallet connects", async () => {
50+
const onConnected = jest.fn();
51+
mockIsWalletConnected.mockResolvedValue(true);
52+
53+
render(<QRModal {...baseProps} onConnected={onConnected} pollingInterval={2000} />);
54+
55+
await act(async () => {
56+
jest.advanceTimersByTime(2000);
57+
});
58+
59+
expect(onConnected).toHaveBeenCalledTimes(1);
60+
});
61+
62+
test("onConnected does not fire again if connection stays true", async () => {
63+
const onConnected = jest.fn();
64+
mockIsWalletConnected.mockResolvedValue(true);
65+
66+
render(<QRModal {...baseProps} onConnected={onConnected} pollingInterval={2000} />);
67+
68+
// Advance tick-by-tick so the async callback resolves and sets firedRef before the next tick
69+
for (let i = 0; i < 4; i++) {
70+
await act(async () => {
71+
jest.advanceTimersByTime(2000);
72+
});
73+
}
74+
75+
expect(onConnected).toHaveBeenCalledTimes(1);
76+
});
77+
78+
test("onConnected does not fire while wallet not connected", async () => {
79+
const onConnected = jest.fn();
80+
mockIsWalletConnected.mockResolvedValue(false);
81+
82+
render(<QRModal {...baseProps} onConnected={onConnected} pollingInterval={2000} />);
83+
84+
await act(async () => {
85+
jest.advanceTimersByTime(6000); // 3 ticks, never connected
86+
});
87+
88+
expect(onConnected).not.toHaveBeenCalled();
89+
});
90+
91+
test("polling stops when open becomes false", async () => {
92+
const onConnected = jest.fn();
93+
mockIsWalletConnected.mockResolvedValue(false);
94+
95+
const { rerender } = render(
96+
<QRModal {...baseProps} onConnected={onConnected} pollingInterval={2000} />
97+
);
98+
99+
// close modal
100+
rerender(
101+
<QRModal {...baseProps} open={false} onConnected={onConnected} pollingInterval={2000} />
102+
);
103+
104+
// wallet connects, but modal is closed
105+
mockIsWalletConnected.mockResolvedValue(true);
106+
107+
await act(async () => {
108+
jest.advanceTimersByTime(6000);
109+
});
110+
111+
expect(onConnected).not.toHaveBeenCalled();
112+
});
113+
114+
test("no interval leak: re-opening fires onConnected exactly once per cycle", async () => {
115+
const onConnected = jest.fn();
116+
117+
// First open: wallet not connected
118+
const { rerender } = render(
119+
<QRModal {...baseProps} onConnected={onConnected} pollingInterval={2000} />
120+
);
121+
122+
await act(async () => {
123+
jest.advanceTimersByTime(2000);
124+
});
125+
expect(onConnected).toHaveBeenCalledTimes(0);
126+
127+
// Close modal
128+
rerender(
129+
<QRModal {...baseProps} open={false} onConnected={onConnected} pollingInterval={2000} />
130+
);
131+
132+
// Re-open, wallet now connected
133+
mockIsWalletConnected.mockResolvedValue(true);
134+
rerender(
135+
<QRModal {...baseProps} open={true} onConnected={onConnected} pollingInterval={2000} />
136+
);
137+
138+
await act(async () => {
139+
jest.advanceTimersByTime(2000);
140+
});
141+
142+
// Should fire exactly once for the new cycle, not multiple times from leaked intervals
143+
expect(onConnected).toHaveBeenCalledTimes(1);
144+
});
145+
146+
test("pollingInterval prop controls tick frequency", async () => {
147+
const onConnected = jest.fn();
148+
mockIsWalletConnected.mockResolvedValue(true);
149+
150+
render(<QRModal {...baseProps} onConnected={onConnected} pollingInterval={500} />);
151+
152+
// Should not have fired before 500ms
153+
await act(async () => {
154+
jest.advanceTimersByTime(400);
155+
});
156+
expect(onConnected).toHaveBeenCalledTimes(0);
157+
158+
await act(async () => {
159+
jest.advanceTimersByTime(100); // reaches 500ms
160+
});
161+
expect(onConnected).toHaveBeenCalledTimes(1);
162+
});

src/components/QRModal.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
"use client";
22

3-
import { useEffect } from "react";
3+
import { useEffect, useRef } from "react";
44
import FocusTrap from "./FocusTrap";
55
import { QRCodeCanvas } from "qrcode.react";
6+
import { isWalletConnected } from "@/lib/freighter";
67

78
type QRModalProps = {
89
open: boolean;
910
uri: string;
1011
onClose: () => void;
1112
onCopied?: () => void;
1213
onConnected?: () => void;
14+
pollingInterval?: number;
1315
};
1416

1517
export default function QRModal({
@@ -18,16 +20,28 @@ export default function QRModal({
1820
onClose,
1921
onCopied,
2022
onConnected,
23+
pollingInterval = 2000,
2124
}: QRModalProps) {
22-
// Escape and focus trapping handled by FocusTrap when open
25+
const firedRef = useRef(false);
2326

2427
useEffect(() => {
25-
if (!open) return;
26-
// If the wallet successfully connected elsewhere in the UI,
27-
// consumers can trigger onConnected manually. This effect is left
28-
// as a placeholder for future integration and does not auto-fire.
29-
void onConnected;
30-
}, [open, onConnected]);
28+
if (!open) {
29+
firedRef.current = false;
30+
return;
31+
}
32+
33+
const id = setInterval(async () => {
34+
if (firedRef.current) return;
35+
const connected = await isWalletConnected();
36+
if (connected) {
37+
firedRef.current = true;
38+
clearInterval(id);
39+
onConnected?.();
40+
}
41+
}, pollingInterval);
42+
43+
return () => clearInterval(id);
44+
}, [open, onConnected, pollingInterval]);
3145

3246
if (!open) return null;
3347

src/lib/freighter.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,21 @@ export async function disconnectWalletConnect(): Promise<void> {
9191
}
9292
}
9393

94+
/** Returns true if any supported wallet is currently connected. */
95+
export async function isWalletConnected(): Promise<boolean> {
96+
if (typeof window === "undefined") return false;
97+
try {
98+
const wcKey = await getWalletConnectPublicKey();
99+
if (wcKey) return true;
100+
} catch { /* fall through */ }
101+
try {
102+
const key = await getFreighterPublicKey();
103+
return !!key;
104+
} catch {
105+
return false;
106+
}
107+
}
108+
94109
/** Sign a transaction with the currently connected wallet adapter. */
95110
export async function signTransaction(xdr: string): Promise<string> {
96111
if (typeof window === "undefined") throw new Error("Browser only");

0 commit comments

Comments
 (0)