);
}
+
+// Memoized: AppShell re-renders on every wallet / own-CTA change, and this
+// component doesn't depend on either.
+export default memo(Footer);
diff --git a/src/components/Navbar/Navbar.test.tsx b/src/components/Navbar/Navbar.test.tsx
index 8a37785..ea2da99 100644
--- a/src/components/Navbar/Navbar.test.tsx
+++ b/src/components/Navbar/Navbar.test.tsx
@@ -17,6 +17,7 @@ const walletMock = vi.hoisted(() => ({
vi.mock("@/context/StellarWalletContext", () => ({
useStellarWallet: vi.fn(() => walletMock),
+ useStellarWalletActions: vi.fn(() => walletMock),
}));
vi.mock("@/hooks/useSorobanQuery", () => ({
diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx
index 63c0bcd..b4df438 100644
--- a/src/components/Navbar/Navbar.tsx
+++ b/src/components/Navbar/Navbar.tsx
@@ -16,13 +16,16 @@ import {
useDisclosure,
} from "@chakra-ui/react";
import { ChevronDownIcon, HamburgerIcon } from "@chakra-ui/icons";
-import { useStellarWallet } from "@/context/StellarWalletContext";
+import {
+ useStellarWallet,
+ useStellarWalletActions,
+} from "@/context/StellarWalletContext";
import NextLink from "next/link";
import { usePathname } from "next/navigation";
import ThemeToggle from "@/components/ThemeToggle/ThemeToggle";
import { usePlatformStats } from "@/hooks/useSorobanQuery";
import { useToast } from "@/hooks/useToast";
-import { useEffect, useState } from "react";
+import { memo, useCallback, useEffect, useState } from "react";
const MORE_LINKS = [
{ href: "/prices", label: "Prices" },
@@ -49,7 +52,7 @@ function shortenStellarAddress(address: string) {
return `${address.slice(0, 4)}…${address.slice(-4)}`;
}
-function Logo() {
+const Logo = memo(function Logo() {
return (
);
-}
+});
-function NavLink({
+const NavLink = memo(function NavLink({
href,
children,
onClick,
@@ -95,9 +98,9 @@ function NavLink({
{children}
);
-}
+});
-function MoreMenu() {
+const MoreMenu = memo(function MoreMenu() {
const pathname = usePathname();
const isActive = MORE_LINKS.some((link) => isActiveRoute(pathname, link.href));
return (
@@ -130,9 +133,9 @@ function MoreMenu() {
);
-}
+});
-function StatPill({ label, value, pulse = false }: { label: string; value: string; pulse?: boolean }) {
+const StatPill = memo(function StatPill({ label, value, pulse = false }: { label: string; value: string; pulse?: boolean }) {
return (
{pulse && (
@@ -142,7 +145,7 @@ function StatPill({ label, value, pulse = false }: { label: string; value: strin
{value}
);
-}
+});
function formatCount(value: number | undefined | null): string {
return value || value === 0 ? value.toLocaleString() : "—";
@@ -152,12 +155,12 @@ function formatCount(value: number | undefined | null): string {
// wallet pill used to be a plain, non-interactive display of the shortened
// address, with the only disconnect affordance living in the separate
// floating ConnectWalletButton. Fold both into a menu on the pill itself.
-function WalletMenu({ publicKey }: { publicKey: string }) {
- const { disconnect } = useStellarWallet();
+const WalletMenu = memo(function WalletMenu({ publicKey }: { publicKey: string }) {
+ const { disconnect } = useStellarWalletActions();
const [copied, setCopied] = useState(false);
- const toast = useToast();
+ const { error: toastError } = useToast();
- const handleCopy = async () => {
+ const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(publicKey);
setCopied(true);
@@ -166,9 +169,9 @@ function WalletMenu({ publicKey }: { publicKey: string }) {
// navigator.clipboard is unavailable in non-secure/hardened contexts —
// surface it instead of failing silently, and include the address so
// the user can still copy it manually.
- toast.error("Couldn't copy address", publicKey);
+ toastError("Couldn't copy address", publicKey);
}
- };
+ }, [publicKey, toastError]);
return (
);
-}
+});
function MobileNavDrawer({
isOpen,
diff --git a/src/components/OfflineBanner/OfflineBanner.tsx b/src/components/OfflineBanner/OfflineBanner.tsx
index e96e251..9828530 100644
--- a/src/components/OfflineBanner/OfflineBanner.tsx
+++ b/src/components/OfflineBanner/OfflineBanner.tsx
@@ -1,7 +1,7 @@
"use client";
import { Alert, AlertIcon } from "@chakra-ui/react";
-import { useSyncExternalStore } from "react";
+import { memo, useSyncExternalStore } from "react";
function subscribe(callback: () => void) {
window.addEventListener("online", callback);
@@ -23,7 +23,7 @@ export function useOnlineStatus(): boolean {
// Issue #528: tell the user plainly when the device is offline, instead of
// leaving every query to fail with its own error.
-export default function OfflineBanner() {
+function OfflineBanner() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
@@ -44,3 +44,7 @@ export default function OfflineBanner() {
);
}
+
+// Memoized: AppShell re-renders on every wallet / own-CTA change, and this
+// component doesn't depend on either.
+export default memo(OfflineBanner);
diff --git a/src/components/RefetchIndicator/RefetchIndicator.tsx b/src/components/RefetchIndicator/RefetchIndicator.tsx
index 2454fdd..65efe6e 100644
--- a/src/components/RefetchIndicator/RefetchIndicator.tsx
+++ b/src/components/RefetchIndicator/RefetchIndicator.tsx
@@ -3,6 +3,7 @@
import { keyframes } from "@emotion/react";
import { Box } from "@chakra-ui/react";
import { useIsFetching } from "@tanstack/react-query";
+import { memo } from "react";
const slide = keyframes`
0% { transform: translateX(-100%); }
@@ -16,7 +17,7 @@ const slide = keyframes`
* this shows it is being updated. The animation is dropped for users who
* prefer reduced motion, leaving a static bar.
*/
-export default function RefetchIndicator() {
+function RefetchIndicator() {
const refetching =
useIsFetching({ predicate: (query) => query.state.data !== undefined }) > 0;
@@ -44,3 +45,7 @@ export default function RefetchIndicator() {
);
}
+
+// Memoized: AppShell re-renders on every wallet / own-CTA change, and this
+// component doesn't depend on either.
+export default memo(RefetchIndicator);
diff --git a/src/components/RpcUnreachableBanner/RpcUnreachableBanner.tsx b/src/components/RpcUnreachableBanner/RpcUnreachableBanner.tsx
index d29c93d..3732528 100644
--- a/src/components/RpcUnreachableBanner/RpcUnreachableBanner.tsx
+++ b/src/components/RpcUnreachableBanner/RpcUnreachableBanner.tsx
@@ -2,12 +2,13 @@
import { useRpcHealth } from "@/hooks/useSorobanQuery";
import { Alert, AlertIcon } from "@chakra-ui/react";
+import { memo } from "react";
// Issue #248: when the Soroban RPC is down, every page independently shows
// its own query error instead of one unified, immediate signal. A single
// global banner is a clearer, faster way for a user to understand "this is
// a whole-app connectivity issue," not something wrong with one page.
-export default function RpcUnreachableBanner() {
+function RpcUnreachableBanner() {
const { isUnreachable } = useRpcHealth();
if (!isUnreachable) return null;
@@ -29,3 +30,7 @@ export default function RpcUnreachableBanner() {
);
}
+
+// Memoized: AppShell re-renders on every wallet / own-CTA change, and this
+// component doesn't depend on either.
+export default memo(RpcUnreachableBanner);
diff --git a/src/components/SkipLink/SkipLink.tsx b/src/components/SkipLink/SkipLink.tsx
index 4e452c1..ccf4c83 100644
--- a/src/components/SkipLink/SkipLink.tsx
+++ b/src/components/SkipLink/SkipLink.tsx
@@ -1,6 +1,7 @@
"use client";
import { Link } from "@chakra-ui/react";
+import { memo } from "react";
export const MAIN_CONTENT_ID = "main-content";
@@ -12,7 +13,7 @@ export const MAIN_CONTENT_ID = "main-content";
* keyboard focus, so the next Tab would land back in the navbar. Focus the
* target explicitly (it has tabIndex={-1}) so the skip actually takes effect.
*/
-export default function SkipLink() {
+function SkipLink() {
return (
);
}
+
+// Memoized: AppShell re-renders on every wallet / own-CTA change, and this
+// component doesn't depend on either.
+export default memo(SkipLink);
diff --git a/src/context/ErrorContext.tsx b/src/context/ErrorContext.tsx
index 730d0dc..7dfcfa3 100644
--- a/src/context/ErrorContext.tsx
+++ b/src/context/ErrorContext.tsx
@@ -11,6 +11,7 @@ import {
createContext,
useContext,
useEffect,
+ useMemo,
type ReactNode,
} from "react";
@@ -31,8 +32,10 @@ export function ErrorProvider({ children }: { children: ReactNode }) {
return cleanup;
}, []);
+ const value = useMemo(() => ({ toast }), [toast]);
+
return (
-
+
{children}
);
diff --git a/src/context/OwnConnectButtonContext.tsx b/src/context/OwnConnectButtonContext.tsx
index fb00879..f4dd93c 100644
--- a/src/context/OwnConnectButtonContext.tsx
+++ b/src/context/OwnConnectButtonContext.tsx
@@ -27,17 +27,17 @@ import {
type ReactNode,
} from "react";
-interface OwnConnectButtonContextValue {
- /** Number of mounted subtrees that currently declare their own CTA. */
- count: number;
- /** Register (+1) or deregister (-1) a declaration. */
- update: (delta: 1 | -1) => void;
-}
+// Count and updater live in separate contexts: pages that only signal (via
+// useOwnConnectButton) read the stable updater and so don't re-render each
+// time the count changes — only AppShell, which reads the count, does.
+
+/** Number of mounted subtrees that currently declare their own CTA. */
+const OwnConnectButtonCountContext = createContext(0);
-const OwnConnectButtonContext = createContext({
- count: 0,
- update: () => undefined,
-});
+/** Register (+1) or deregister (-1) a declaration. */
+const OwnConnectButtonUpdateContext = createContext<(delta: 1 | -1) => void>(
+ () => undefined,
+);
/** Provider — place once near the root (inside AppShell). */
export function OwnConnectButtonProvider({ children }: { children: ReactNode }) {
@@ -56,9 +56,11 @@ export function OwnConnectButtonProvider({ children }: { children: ReactNode })
}, []);
return (
-
- {children}
-
+
+
+ {children}
+
+
);
}
@@ -68,8 +70,7 @@ export function OwnConnectButtonProvider({ children }: { children: ReactNode })
* button.
*/
export function useHasOwnConnectButton(): boolean {
- const { count } = useContext(OwnConnectButtonContext);
- return count > 0;
+ return useContext(OwnConnectButtonCountContext) > 0;
}
/**
@@ -91,7 +92,7 @@ export function useHasOwnConnectButton(): boolean {
* }, [showingOwnCTA, signal]);
*/
export function useOwnConnectButton(): (active: boolean) => void {
- const { update } = useContext(OwnConnectButtonContext);
+ const update = useContext(OwnConnectButtonUpdateContext);
const activeRef = useRef(false);
// Clean up on unmount no matter what
diff --git a/src/context/StellarWalletContext.tsx b/src/context/StellarWalletContext.tsx
index ef7365a..bbeddd5 100644
--- a/src/context/StellarWalletContext.tsx
+++ b/src/context/StellarWalletContext.tsx
@@ -35,10 +35,19 @@ type StellarWalletContextValue = {
disconnect: () => void;
};
+type StellarWalletActions = Pick;
+
const StellarWalletContext = createContext(
null
);
+// connect/disconnect are stable callbacks, so components that only trigger
+// wallet actions read them from this context and skip the re-render that
+// every publicKey / networkName change causes for StellarWalletContext.
+const StellarWalletActionsContext = createContext(
+ null
+);
+
export const FREIGHTER_CONNECT_TIMEOUT_MS = 15_000;
/** How often Freighter is polled for external wallet changes (#539). */
@@ -423,10 +432,14 @@ export function StellarWalletProvider({ children }: { children: ReactNode }) {
[publicKey, walletApi, networkName, isNetworkMismatch, connect, disconnect]
);
+ const actions = useMemo(() => ({ connect, disconnect }), [connect, disconnect]);
+
return (
-
- {children}
-
+
+
+ {children}
+
+
);
}
@@ -437,3 +450,17 @@ export function useStellarWallet() {
}
return ctx;
}
+
+/**
+ * Just the wallet actions. Unlike useStellarWallet(), consumers of this hook
+ * do not re-render when the connected account or network changes.
+ */
+export function useStellarWalletActions() {
+ const ctx = useContext(StellarWalletActionsContext);
+ if (!ctx) {
+ throw new Error(
+ "useStellarWalletActions must be used within StellarWalletProvider"
+ );
+ }
+ return ctx;
+}
diff --git a/src/hooks/useToast.tsx b/src/hooks/useToast.tsx
index b1dbd77..62e7097 100644
--- a/src/hooks/useToast.tsx
+++ b/src/hooks/useToast.tsx
@@ -12,7 +12,7 @@ import {
withRetry
} from "@/lib/error-handler";
import { Box, Button, Text, useToast as useChakraToast } from "@chakra-ui/react";
-import { useCallback } from "react";
+import { useCallback, useMemo } from "react";
export type NotificationType = "success" | "error" | "info" | "warning";
@@ -243,14 +243,20 @@ export function useToast() {
[chakraToast, handleError]
);
- return {
- success,
- error,
- info,
- warning,
- handleError,
- withErrorHandling,
- };
+ // Memoized so the returned object keeps its identity across renders —
+ // ErrorProvider hands it to every useErrorHandler() consumer via context,
+ // and a fresh object here would re-render all of them on each render.
+ return useMemo(
+ () => ({
+ success,
+ error,
+ info,
+ warning,
+ handleError,
+ withErrorHandling,
+ }),
+ [success, error, info, warning, handleError, withErrorHandling],
+ );
}
export type UseToastReturn = ReturnType;