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: 5 additions & 1 deletion src/app/connect/ConnectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ export function ConnectPanel() {
{connecting ? "Connecting..." : "Connect Freighter"}
</Button>
)}
{error && <p className="mt-3 text-sm text-rose-600">{error}</p>}
{error && (
<p role="alert" className="mt-3 text-sm text-rose-600">
{error}
</p>
)}
</div>
</div>
);
Expand Down
21 changes: 17 additions & 4 deletions src/app/issues/[id]/IssueActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { Bounty } from "@/types";
export function IssueActions({ bounty }: { bounty: Bounty }) {
const router = useRouter();
const { user } = useAuth();
const { address, connect, connecting } = useWallet();
const { address, connect, connecting, getError: getWalletError } = useWallet();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
Expand All @@ -23,7 +23,12 @@ export function IssueActions({ bounty }: { bounty: Bounty }) {
try {
const walletAddress = address ?? (await connect());
if (!walletAddress) {
setError("Connect a Stellar wallet to continue.");
// connect() resolves to null on failure rather than throwing, but
// WalletContext already computed a specific reason (extension not
// installed, access denied, ...). getError() reads it synchronously
// off a ref rather than the (possibly stale, pre-await) `error`
// value from context, so it's guaranteed current here (#235).
setError(getWalletError() ?? "Connect a Stellar wallet to continue.");
return;
}
await action(walletAddress);
Expand Down Expand Up @@ -106,8 +111,16 @@ export function IssueActions({ bounty }: { bounty: Bounty }) {
</Button>
)}
</div>
{notice && <p className="mt-3 text-sm text-emerald-600 dark:text-emerald-400">{notice}</p>}
{error && <p className="mt-3 text-sm text-rose-600">{error}</p>}
{notice && (
<p role="status" aria-live="polite" className="mt-3 text-sm text-emerald-600 dark:text-emerald-400">
{notice}
</p>
)}
{error && (
<p role="alert" className="mt-3 text-sm text-rose-600">
{error}
</p>
)}
<p className="mt-3 text-xs text-slate-400 dark:text-slate-500">
Funding and claiming write to the live mergefi-backend API. Merge
detection and payout release happen automatically via GitHub
Expand Down
34 changes: 28 additions & 6 deletions src/app/milestones/MilestoneActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { apiPost, ApiRequestError } from "@/lib/api";

export function MilestoneFundButton({ milestoneId }: { milestoneId: string }) {
const router = useRouter();
const { address, connect, connecting } = useWallet();
const { address, connect, connecting, getError: getWalletError } = useWallet();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);

Expand All @@ -18,7 +18,11 @@ export function MilestoneFundButton({ milestoneId }: { milestoneId: string }) {
try {
const walletAddress = address ?? (await connect());
if (!walletAddress) {
setError("Connect a Stellar wallet to fund this milestone.");
// getError() reads WalletContext's specific failure reason off a
// ref, always current the instant connect() settles — unlike the
// `error` context value, which may still reflect a pre-await
// render (#235).
setError(getWalletError() ?? "Connect a Stellar wallet to fund this milestone.");
return;
}
await apiPost(`/milestones/${milestoneId}/fund`, {
Expand All @@ -37,14 +41,18 @@ export function MilestoneFundButton({ milestoneId }: { milestoneId: string }) {
<Button size="sm" variant="outline" onClick={handleFund} disabled={pending || connecting}>
{pending || connecting ? "Confirming in wallet..." : "Fund milestone"}
</Button>
{error && <p className="mt-2 text-xs text-rose-600">{error}</p>}
{error && (
<p role="alert" className="mt-2 text-xs text-rose-600">
{error}
</p>
)}
</div>
);
}

export function PoolDepositButton({ poolId }: { poolId: string }) {
const router = useRouter();
const { address, connect, connecting } = useWallet();
const { address, connect, connecting, getError: getWalletError } = useWallet();
const [amount, setAmount] = useState("100");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand All @@ -55,7 +63,11 @@ export function PoolDepositButton({ poolId }: { poolId: string }) {
try {
const walletAddress = address ?? (await connect());
if (!walletAddress) {
setError("Connect a Stellar wallet to deposit.");
// getError() reads WalletContext's specific failure reason off a
// ref, always current the instant connect() settles — unlike the
// `error` context value, which may still reflect a pre-await
// render (#235).
setError(getWalletError() ?? "Connect a Stellar wallet to deposit.");
return;
}
await apiPost(`/maintenance-pools/${poolId}/deposit`, {
Expand All @@ -70,9 +82,15 @@ export function PoolDepositButton({ poolId }: { poolId: string }) {
}
}

const inputId = `pool-deposit-${poolId}`;

return (
<div className="mt-4 flex items-center gap-2">
<label htmlFor={inputId} className="sr-only">
Deposit amount
</label>
<input
id={inputId}
type="number"
min="1"
value={amount}
Expand All @@ -82,7 +100,11 @@ export function PoolDepositButton({ poolId }: { poolId: string }) {
<Button size="sm" variant="outline" onClick={handleDeposit} disabled={pending || connecting}>
{pending || connecting ? "Confirming..." : "Deposit"}
</Button>
{error && <p className="text-xs text-rose-600">{error}</p>}
{error && (
<p role="alert" className="text-xs text-rose-600">
{error}
</p>
)}
</div>
);
}
5 changes: 4 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,10 @@ export default function HomePage() {
<div className="mt-8 flex items-center justify-center gap-3 text-sm text-slate-500 dark:text-slate-400">
<AvatarStack seeds={topContributors.map((c) => c.handle)} />
<span>
Joined by <strong className="text-slate-900 dark:text-white">341 contributors</strong>{" "}
Joined by{" "}
<strong className="text-slate-900 dark:text-white">
{platformStats.activeContributors.toLocaleString()} contributors
</strong>{" "}
already earning
</span>
</div>
Expand Down
37 changes: 36 additions & 1 deletion src/context/WalletContext.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from "react";
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
import { WalletProvider, useWallet } from "./WalletContext";
import { useAuth } from "@/context/AuthContext";
Expand Down Expand Up @@ -27,14 +28,27 @@ const mockApiRequest = apiRequest as jest.Mock;
const mockRefresh = jest.fn();

function TestConsumer() {
const { address, connecting, error, connect, disconnect } = useWallet();
const { address, connecting, error, connect, disconnect, getError } = useWallet();
const [readAfterConnect, setReadAfterConnect] = useState<string>("not-read-yet");

return (
<div>
<div data-testid="address">{address ?? "disconnected"}</div>
<div data-testid="connecting">{String(connecting)}</div>
<div data-testid="error">{error ?? "none"}</div>
<div data-testid="read-after-connect">{readAfterConnect}</div>
<button onClick={() => void connect()}>connect</button>
<button onClick={disconnect}>disconnect</button>
<button
onClick={async () => {
await connect();
// Mirrors IssueActions/MilestoneActions' pattern: read getError()
// synchronously right after the awaited connect() settles.
setReadAfterConnect(getError() ?? "none");
}}
>
connect-and-read-getError
</button>
</div>
);
}
Expand Down Expand Up @@ -207,6 +221,27 @@ describe("WalletContext — connect() (#231)", () => {
);
expect(screen.getByTestId("error")).toHaveTextContent("none");
});

it("getError() returns the fresh failure reason synchronously right after connect() settles (#235)", async () => {
mockConnectWallet.mockRejectedValue(new Error("Wallet access was not granted."));

render(
<WalletProvider>
<TestConsumer />
</WalletProvider>,
);

fireEvent.click(screen.getByText("connect-and-read-getError"));

// The consumer read getError() immediately after `await connect()`
// resolved in its own click handler — not from a later render's
// `error` prop — and still got the correct, specific message.
await waitFor(() =>
expect(screen.getByTestId("read-after-connect")).toHaveTextContent(
"Wallet access was not granted.",
),
);
});
});

describe("WalletContext — disconnect() (#230, #231)", () => {
Expand Down
27 changes: 22 additions & 5 deletions src/context/WalletContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { connectWallet as freighterConnect } from "@/lib/wallet";
Expand All @@ -22,6 +23,16 @@ interface WalletContextValue {
error: string | null;
connect: () => Promise<string | null>;
disconnect: () => void;
/**
* Synchronously reads the error connect() most recently set, bypassing
* React's render/commit timing. A caller that awaits connect() and gets
* null back can't rely on the `error` field above for the reason why —
* that's a value from whatever render created the closure, not
* necessarily updated yet by the time the awaited call resolves. This
* reads a ref updated in lockstep with every setError() call, so it's
* always current the instant connect()'s promise settles (#235).
*/
getError: () => string | null;
}

const WalletContext = createContext<WalletContextValue | null>(null);
Expand All @@ -32,6 +43,12 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
const [network, setNetwork] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const errorRef = useRef<string | null>(null);
const updateError = useCallback((message: string | null) => {
errorRef.current = message;
setError(message);
}, []);
const getError = useCallback(() => errorRef.current, []);

useEffect(() => {
// localStorage is unavailable during SSR, so this can't be a lazy
Expand Down Expand Up @@ -62,7 +79,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
useCrossTabStorage(WALLET_KEY, handleWalletKeyChangedElsewhere);

const connect = useCallback(async () => {
setError(null);
updateError(null);
setConnecting(true);
try {
const connection = await freighterConnect();
Expand All @@ -81,7 +98,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
// The wallet is still usable for signing this session even if the
// backend write failed, but the user needs to know their payout
// wallet wasn't actually saved to their profile (#229).
setError(
updateError(
"Wallet connected, but couldn't save it to your profile — try reconnecting.",
);
}
Expand All @@ -94,14 +111,14 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
// no distinct "not an Error" case that means "extension missing" to
// special-case here (#192). The non-Error fallback below only covers
// a genuinely unexpected non-Error throw.
setError(
updateError(
err instanceof Error ? err.message : "Unable to connect wallet. Please try again.",
);
return null;
} finally {
setConnecting(false);
}
}, [user, refresh]);
}, [user, refresh, updateError]);

const disconnect = useCallback(() => {
window.localStorage.removeItem(WALLET_KEY);
Expand All @@ -123,7 +140,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {

return (
<WalletContext.Provider
value={{ address, network, connecting, error, connect, disconnect }}
value={{ address, network, connecting, error, connect, disconnect, getError }}
>
{children}
</WalletContext.Provider>
Expand Down
Loading