(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}