diff --git a/src/app/farm/page.tsx b/src/app/farm/page.tsx index 201b80e..52b0de4 100644 --- a/src/app/farm/page.tsx +++ b/src/app/farm/page.tsx @@ -49,7 +49,7 @@ import { useToast, } from "@chakra-ui/react"; import { useOwnConnectButton } from "@/context/OwnConnectButtonContext"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; const ACCENT = "#4AE292"; @@ -534,15 +534,17 @@ export default function Farm() { }); }, [pools, userPositions]); - const handleDepositClick = (pool: LivePoolRow) => { + // Stable references so the memoized FarmPoolRow children + // don't receive a new callback on every wallet or query update. + const handleDepositClick = useCallback((pool: LivePoolRow) => { setSelectedFarm(pool); setIsDepositOpen(true); - }; + }, []); - const handleDepositClose = () => { + const handleDepositClose = useCallback(() => { setIsDepositOpen(false); setSelectedFarm(null); - }; + }, []); const hasPositions = myPositions.length > 0; diff --git a/src/app/history/page.tsx b/src/app/history/page.tsx index 696386a..54f161f 100644 --- a/src/app/history/page.tsx +++ b/src/app/history/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; import { Alert, AlertIcon, @@ -77,7 +77,7 @@ function EmptyState({ children }: { children: React.ReactNode }) { ); } -function HistoryCard({ entry }: { entry: TxHistoryEntry }) { +const HistoryCard = memo(function HistoryCard({ entry }: { entry: TxHistoryEntry }) { return ( ); -} +}); export default function HistoryPage() { // Chakra's `ssr` option is inverted from what the name suggests: `ssr: false` diff --git a/src/app/page.tsx b/src/app/page.tsx index 59d03dd..2023ba5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { useEffect } from "react"; +import { memo, useEffect } from "react"; import { Flex, HStack, @@ -37,9 +37,10 @@ const fadeInUp = { // A layer of slow-drifting, multi-hue gradient blobs plus a faint dot grid, // scoped to this page's own stacking context so it never touches the shared // AppShell chrome (nav/footer) that other routes' visual-regression -// screenshots depend on. +// screenshots depend on. Memoized so wallet / stats updates on the page +// don't re-render the infinitely-animating orbs. -function GradientOrb({ +const GradientOrb = memo(function GradientOrb({ color, size, top, @@ -82,9 +83,9 @@ function GradientOrb({ transition={{ duration, delay, repeat: Infinity, ease: "easeInOut" }} /> ); -} +}); -function AmbientBackground() { +const AmbientBackground = memo(function AmbientBackground() { return ( ); -} +}); // ── Icons ──────────────────────────────────────────────────────────────────── // Small inline line icons — no new icon-package dependency for four glyphs. diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index ed68772..f65fe92 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -1,7 +1,8 @@ import { Flex, Link as ChakraLink, Text } from "@chakra-ui/react"; import NextLink from "next/link"; +import { memo } from "react"; -export default function Footer() { +function Footer() { return ( ); } + +// 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 ( @@ -201,7 +204,7 @@ function WalletMenu({ publicKey }: { publicKey: string }) { ); -} +}); 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;