diff --git a/src/app/connect/ConnectPanel.tsx b/src/app/connect/ConnectPanel.tsx index 5fe2f77..e5a5c77 100644 --- a/src/app/connect/ConnectPanel.tsx +++ b/src/app/connect/ConnectPanel.tsx @@ -76,7 +76,11 @@ export function ConnectPanel() { {connecting ? "Connecting..." : "Connect Freighter"} )} - {error &&

{error}

} + {error && ( +

+ {error} +

+ )} ); diff --git a/src/app/issues/[id]/IssueActions.tsx b/src/app/issues/[id]/IssueActions.tsx index 3b91ae4..89f4665 100644 --- a/src/app/issues/[id]/IssueActions.tsx +++ b/src/app/issues/[id]/IssueActions.tsx @@ -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(null); const [notice, setNotice] = useState(null); @@ -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); @@ -106,8 +111,16 @@ export function IssueActions({ bounty }: { bounty: Bounty }) { )} - {notice &&

{notice}

} - {error &&

{error}

} + {notice && ( +

+ {notice} +

+ )} + {error && ( +

+ {error} +

+ )}

Funding and claiming write to the live mergefi-backend API. Merge detection and payout release happen automatically via GitHub diff --git a/src/app/milestones/MilestoneActions.tsx b/src/app/milestones/MilestoneActions.tsx index 70c4303..2991bb5 100644 --- a/src/app/milestones/MilestoneActions.tsx +++ b/src/app/milestones/MilestoneActions.tsx @@ -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(null); @@ -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`, { @@ -37,14 +41,18 @@ export function MilestoneFundButton({ milestoneId }: { milestoneId: string }) { - {error &&

{error}

} + {error && ( +

+ {error} +

+ )} ); } 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(null); @@ -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`, { @@ -70,9 +82,15 @@ export function PoolDepositButton({ poolId }: { poolId: string }) { } } + const inputId = `pool-deposit-${poolId}`; + return (
+ {pending || connecting ? "Confirming..." : "Deposit"} - {error &&

{error}

} + {error && ( +

+ {error} +

+ )}
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 2a2387e..ec2a3dd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -118,7 +118,10 @@ export default function HomePage() {
c.handle)} /> - Joined by 341 contributors{" "} + Joined by{" "} + + {platformStats.activeContributors.toLocaleString()} contributors + {" "} already earning
diff --git a/src/context/WalletContext.test.tsx b/src/context/WalletContext.test.tsx index eeb0c7a..04a9e45 100644 --- a/src/context/WalletContext.test.tsx +++ b/src/context/WalletContext.test.tsx @@ -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"; @@ -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("not-read-yet"); + return (
{address ?? "disconnected"}
{String(connecting)}
{error ?? "none"}
+
{readAfterConnect}
+
); } @@ -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( + + + , + ); + + 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)", () => { diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx index 23bb1fa..c8427ac 100644 --- a/src/context/WalletContext.tsx +++ b/src/context/WalletContext.tsx @@ -5,6 +5,7 @@ import { useCallback, useContext, useEffect, + useRef, useState, } from "react"; import { connectWallet as freighterConnect } from "@/lib/wallet"; @@ -22,6 +23,16 @@ interface WalletContextValue { error: string | null; connect: () => Promise; 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(null); @@ -32,6 +43,12 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { const [network, setNetwork] = useState(null); const [connecting, setConnecting] = useState(false); const [error, setError] = useState(null); + const errorRef = useRef(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 @@ -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(); @@ -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.", ); } @@ -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); @@ -123,7 +140,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { return ( {children}