From dd630ba92dafb5bb3049c430743384b6f4376c04 Mon Sep 17 00:00:00 2001 From: Oyinkans0la12 <147610565+Oyinkans0la12@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:34:18 +0000 Subject: [PATCH] perf(web): memoize RegistryClient instance in useProfile (#660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useProfile re-constructed a new RegistryClient on every queryFn/mutationFn call, including each interval refetch. Construct the client once per mount with useMemo (mirroring usePool) and reuse it across the profile, isVerified, and register call sites, guarding against a null client when the contract ID is not configured. Add a test that spies on the RegistryClient constructor to verify exactly one instance is created per mount and reused across re-renders, refetches, and mutations. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- apps/web/hooks/useProfile.test.ts | 37 +++++++++++++++++++++++++++++++ apps/web/hooks/useProfile.ts | 25 +++++++++++++++------ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/apps/web/hooks/useProfile.test.ts b/apps/web/hooks/useProfile.test.ts index 69b8714b..eb60e588 100644 --- a/apps/web/hooks/useProfile.test.ts +++ b/apps/web/hooks/useProfile.test.ts @@ -235,6 +235,43 @@ describe("useProfile", () => { ).rejects.toThrow("On-chain error"); }); + it("constructs exactly one RegistryClient instance per mount", async () => { + act(() => { + useWalletStore.getState().connect("G123", "testnet"); + }); + + vi.mocked(RegistryClient).mockImplementation(function () { + return { + getProfile: vi.fn().mockResolvedValue(null), + isVerified: vi.fn().mockResolvedValue(false), + registerIssuer: vi.fn().mockResolvedValue("tx_hash"), + registerBuyer: vi.fn().mockResolvedValue("tx_hash"), + }; + } as any); + + const { result, rerender, unmount } = renderHook(() => useProfile()); + + // Re-renders, refetches, and mutations must all reuse the memoized client. + rerender(); + await act(async () => { + await result.current.register({ + role: "issuer", + metadata: { name: "Test Issuer" }, + }); + await result.current.refetchProfile(); + }); + + expect(vi.mocked(RegistryClient)).toHaveBeenCalledTimes(1); + expect(vi.mocked(RegistryClient)).toHaveBeenCalledWith( + process.env.NEXT_PUBLIC_REGISTRY_CONTRACT_ID || "", + ); + + // A fresh mount constructs a new (single) client instance. + unmount(); + renderHook(() => useProfile()); + expect(vi.mocked(RegistryClient)).toHaveBeenCalledTimes(2); + }); + it("refetchProfile refetches both queries", async () => { const refetch1 = vi.fn().mockResolvedValue(undefined); const refetch2 = vi.fn().mockResolvedValue(undefined); diff --git a/apps/web/hooks/useProfile.ts b/apps/web/hooks/useProfile.ts index 7965a49a..19d9dd4a 100644 --- a/apps/web/hooks/useProfile.ts +++ b/apps/web/hooks/useProfile.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { RegistryClient, Profile } from "@trusttrove/sdk"; import { useWalletStore } from "@/store/wallet"; @@ -66,13 +67,23 @@ export function useProfile() { const address = useWalletStore((s) => s.address); const { error: appError, handleError, clearError } = useAppError(); + // Construct a single SDK client per mount and reuse it across all queries + // and mutations so refetches/polls don't churn new client objects. + const registryClient = useMemo(() => { + try { + return new RegistryClient(registryContractID); + } catch { + return null; + } + }, []); + const profileQuery = useQuery({ queryKey: ["profile", address], queryFn: async (): Promise => { if (!address) return null; - const client = new RegistryClient(registryContractID); + if (!registryClient) throw new Error("Registry client not available"); try { - const profile = await client.getProfile(address, address); + const profile = await registryClient.getProfile(address, address); return profile; } catch (err) { if (isProfileNotFoundError(err)) { @@ -90,9 +101,9 @@ export function useProfile() { queryKey: ["isVerified", address], queryFn: async (): Promise => { if (!address) return false; - const client = new RegistryClient(registryContractID); + if (!registryClient) throw new Error("Registry client not available"); try { - const verified = await client.isVerified(address, address); + const verified = await registryClient.isVerified(address, address); return verified; } catch (err) { captureError(err); @@ -111,11 +122,11 @@ export function useProfile() { metadata: Record; }) => { if (!address) throw new Error("Wallet not connected"); - const client = new RegistryClient(registryContractID); + if (!registryClient) throw new Error("Registry client not available"); if (role === "issuer") { - return client.registerIssuer(address, metadata, address); + return registryClient.registerIssuer(address, metadata, address); } else { - return client.registerBuyer(address, metadata, address); + return registryClient.registerBuyer(address, metadata, address); } }, onSuccess: () => {