From b70d69cb5a9f4ebc445f6b265f081ce9337d9f1a Mon Sep 17 00:00:00 2001 From: Kien Ngo Date: Mon, 9 Dec 2024 22:34:22 +0700 Subject: [PATCH] update --- .changeset/fluffy-pets-tie.md | 5 + .../src/react/web/ui/prebuilt/Chain/icon.tsx | 14 ++- .../react/web/ui/prebuilt/Chain/name.test.tsx | 45 +++++++-- .../src/react/web/ui/prebuilt/Chain/name.tsx | 48 +++++++--- .../react/web/ui/prebuilt/NFT/description.tsx | 3 +- .../src/react/web/ui/prebuilt/NFT/media.tsx | 3 +- .../src/react/web/ui/prebuilt/NFT/name.tsx | 3 +- .../src/react/web/ui/prebuilt/Token/icon.tsx | 15 ++- .../react/web/ui/prebuilt/Token/name.test.tsx | 86 +++++++++++++++++ .../src/react/web/ui/prebuilt/Token/name.tsx | 90 +++++++++++------ .../web/ui/prebuilt/Token/symbol.test.tsx | 96 +++++++++++++++---- .../react/web/ui/prebuilt/Token/symbol.tsx | 93 +++++++++++------- 12 files changed, 393 insertions(+), 108 deletions(-) create mode 100644 .changeset/fluffy-pets-tie.md create mode 100644 packages/thirdweb/src/react/web/ui/prebuilt/Token/name.test.tsx diff --git a/.changeset/fluffy-pets-tie.md b/.changeset/fluffy-pets-tie.md new file mode 100644 index 00000000000..453fbf82b60 --- /dev/null +++ b/.changeset/fluffy-pets-tie.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Fix caching issues for headless component; improve code coverage diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Chain/icon.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/icon.tsx index 356fe69b58b..8b09fe73032 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/Chain/icon.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/icon.tsx @@ -2,6 +2,7 @@ import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; import type { JSX } from "react"; import { getChainMetadata } from "../../../../../chains/utils.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; +import { getFunctionId } from "../../../../../utils/function-id.js"; import { resolveScheme } from "../../../../../utils/ipfs.js"; import { useChainContext } from "./provider.js"; @@ -119,7 +120,18 @@ export function ChainIcon({ }: ChainIconProps) { const { chain } = useChainContext(); const iconQuery = useQuery({ - queryKey: ["_internal_chain_icon_", chain.id] as const, + queryKey: [ + "_internal_chain_icon_", + chain.id, + { + resolver: + typeof iconResolver === "string" + ? iconResolver + : typeof iconResolver === "function" + ? getFunctionId(iconResolver) + : undefined, + }, + ] as const, queryFn: async () => { if (typeof iconResolver === "string") { return iconResolver; diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.test.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.test.tsx index b8278e4e635..1279dbceb3f 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.test.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.test.tsx @@ -2,17 +2,17 @@ import { describe, expect, it } from "vitest"; import { render, screen, waitFor } from "~test/react-render.js"; import { ethereum } from "../../../../../chains/chain-definitions/ethereum.js"; import { defineChain } from "../../../../../chains/utils.js"; -import { ChainName } from "./name.js"; +import { ChainName, fetchChainName } from "./name.js"; import { ChainProvider } from "./provider.js"; describe.runIf(process.env.TW_SECRET_KEY)("ChainName component", () => { - it("should return the correct chain name, if the name exists in the chain object", () => { + it("should return the correct chain name, if the name exists in the chain object", async () => { render( , ); - waitFor(() => + await waitFor(() => expect( screen.getByText("Ethereum", { exact: true, @@ -22,13 +22,13 @@ describe.runIf(process.env.TW_SECRET_KEY)("ChainName component", () => { ); }); - it("should return the correct chain name, if the name is loaded from the server", () => { + it("should return the correct chain name, if the name is loaded from the server", async () => { render( , ); - waitFor(() => + await waitFor(() => expect( screen.getByText("Ethereum Mainnet", { exact: true, @@ -38,13 +38,13 @@ describe.runIf(process.env.TW_SECRET_KEY)("ChainName component", () => { ); }); - it("should return the correct FORMATTED chain name", () => { + it("should return the correct FORMATTED chain name", async () => { render( `${str}-formatted`} /> , ); - waitFor(() => + await waitFor(() => expect( screen.getByText("Ethereum-formatted", { exact: true, @@ -54,14 +54,14 @@ describe.runIf(process.env.TW_SECRET_KEY)("ChainName component", () => { ); }); - it("should fallback properly when fail to resolve chain name", () => { + it("should fallback properly when fail to resolve chain name", async () => { render( oops} /> , ); - waitFor(() => + await waitFor(() => expect( screen.getByText("oops", { exact: true, @@ -70,4 +70,31 @@ describe.runIf(process.env.TW_SECRET_KEY)("ChainName component", () => { ).toBeInTheDocument(), ); }); + + it("fetchChainName should respect nameResolver as a string", async () => { + const res = await fetchChainName({ + chain: ethereum, + nameResolver: "eth_mainnet", + }); + expect(res).toBe("eth_mainnet"); + }); + + it("fetchChainName should respect nameResolver as a non-async function", async () => { + const res = await fetchChainName({ + chain: ethereum, + nameResolver: () => "eth_mainnet", + }); + expect(res).toBe("eth_mainnet"); + }); + + it("fetchChainName should respect nameResolver as an async function", async () => { + const res = await fetchChainName({ + chain: ethereum, + nameResolver: async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return "eth_mainnet"; + }, + }); + expect(res).toBe("eth_mainnet"); + }); }); diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.tsx index 0bd4518128b..7cbfa0e6a0f 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Chain/name.tsx @@ -3,7 +3,9 @@ import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; import type React from "react"; import type { JSX } from "react"; +import type { Chain } from "../../../../../chains/types.js"; import { getChainMetadata } from "../../../../../chains/utils.js"; +import { getFunctionId } from "../../../../../utils/function-id.js"; import { useChainContext } from "./provider.js"; /** @@ -155,19 +157,19 @@ export function ChainName({ }: ChainNameProps) { const { chain } = useChainContext(); const nameQuery = useQuery({ - queryKey: ["_internal_chain_name_", chain.id] as const, - queryFn: async () => { - if (typeof nameResolver === "string") { - return nameResolver; - } - if (typeof nameResolver === "function") { - return nameResolver(); - } - if (chain.name) { - return chain.name; - } - return getChainMetadata(chain).then((data) => data.name); - }, + queryKey: [ + "_internal_chain_name_", + chain.id, + { + resolver: + typeof nameResolver === "string" + ? nameResolver + : typeof nameResolver === "function" + ? getFunctionId(nameResolver) + : undefined, + }, + ] as const, + queryFn: async () => fetchChainName({ chain, nameResolver }), ...queryOptions, }); @@ -183,3 +185,23 @@ export function ChainName({ return {displayValue}; } + +/** + * @internal Exported for tests only + */ +export async function fetchChainName(props: { + chain: Chain; + nameResolver?: string | (() => string) | (() => Promise); +}) { + const { nameResolver, chain } = props; + if (typeof nameResolver === "string") { + return nameResolver; + } + if (typeof nameResolver === "function") { + return nameResolver(); + } + if (chain.name) { + return chain.name; + } + return getChainMetadata(chain).then((data) => data.name); +} diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/description.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/description.tsx index 7f2b245cfe0..7a9a23e41e4 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/description.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/description.tsx @@ -3,6 +3,7 @@ import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; import type { JSX } from "react"; import type { ThirdwebContract } from "../../../../../contract/contract.js"; +import { getFunctionId } from "../../../../../utils/function-id.js"; import { useNFTContext } from "./provider.js"; import { getNFTInfo } from "./utils.js"; @@ -100,7 +101,7 @@ export function NFTDescription({ typeof descriptionResolver === "string" ? descriptionResolver : typeof descriptionResolver === "function" - ? descriptionResolver.toString() + ? getFunctionId(descriptionResolver) : undefined, }, ], diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/media.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/media.tsx index 814de3b124c..d1b894dabcc 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/media.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/media.tsx @@ -1,6 +1,7 @@ import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; import type { JSX } from "react"; import type { ThirdwebContract } from "../../../../../contract/contract.js"; +import { getFunctionId } from "../../../../../utils/function-id.js"; import { MediaRenderer } from "../../MediaRenderer/MediaRenderer.js"; import type { MediaRendererProps } from "../../MediaRenderer/types.js"; import { useNFTContext } from "./provider.js"; @@ -138,7 +139,7 @@ export function NFTMedia({ typeof mediaResolver === "object" ? mediaResolver : typeof mediaResolver === "function" - ? mediaResolver.toString() + ? getFunctionId(mediaResolver) : undefined, }, ], diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/name.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/name.tsx index 8d5da597a20..65c06e8dc0d 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/name.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/name.tsx @@ -1,6 +1,7 @@ import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; import type { JSX } from "react"; import type { ThirdwebContract } from "../../../../../contract/contract.js"; +import { getFunctionId } from "../../../../../utils/function-id.js"; import { useNFTContext } from "./provider.js"; import { getNFTInfo } from "./utils.js"; @@ -100,7 +101,7 @@ export function NFTName({ typeof nameResolver === "string" ? nameResolver : typeof nameResolver === "function" - ? nameResolver.toString() + ? getFunctionId(nameResolver) : undefined, }, ], diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Token/icon.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Token/icon.tsx index 4c9bb4cff6a..d272bbaa0a3 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/Token/icon.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Token/icon.tsx @@ -4,6 +4,7 @@ import { getChainMetadata } from "../../../../../chains/utils.js"; import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; import { getContract } from "../../../../../contract/contract.js"; import { getContractMetadata } from "../../../../../extensions/common/read/getContractMetadata.js"; +import { getFunctionId } from "../../../../../utils/function-id.js"; import { resolveScheme } from "../../../../../utils/ipfs.js"; import { useTokenContext } from "./provider.js"; @@ -115,7 +116,19 @@ export function TokenIcon({ }: TokenIconProps) { const { address, client, chain } = useTokenContext(); const iconQuery = useQuery({ - queryKey: ["_internal_token_icon_", chain.id, address] as const, + queryKey: [ + "_internal_token_icon_", + chain.id, + address, + { + resolver: + typeof iconResolver === "string" + ? iconResolver + : typeof iconResolver === "function" + ? getFunctionId(iconResolver) + : undefined, + }, + ] as const, queryFn: async () => { if (typeof iconResolver === "string") { return iconResolver; diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Token/name.test.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Token/name.test.tsx new file mode 100644 index 00000000000..38c45c0e83c --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Token/name.test.tsx @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { ANVIL_CHAIN } from "~test/chains.js"; +import {} from "~test/react-render.js"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { + UNISWAPV3_FACTORY_CONTRACT, + USDT_CONTRACT, +} from "~test/test-contracts.js"; +import { ethereum } from "../../../../../chains/chain-definitions/ethereum.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; +import { fetchTokenName } from "./name.js"; + +const client = TEST_CLIENT; + +describe.runIf(process.env.TW_SECRET_KEY)("TokenName component", () => { + it("fetchTokenName should respect the nameResolver being a string", async () => { + const res = await fetchTokenName({ + address: "thing", + client, + chain: ANVIL_CHAIN, + nameResolver: "tw", + }); + expect(res).toBe("tw"); + }); + + it("fetchTokenName should respect the nameResolver being a non-async function", async () => { + const res = await fetchTokenName({ + address: "thing", + client, + chain: ANVIL_CHAIN, + nameResolver: () => "tw", + }); + + expect(res).toBe("tw"); + }); + + it("fetchTokenName should respect the nameResolver being an async function", async () => { + const res = await fetchTokenName({ + address: "thing", + client, + chain: ANVIL_CHAIN, + nameResolver: async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return "tw"; + }, + }); + + expect(res).toBe("tw"); + }); + + it("fetchTokenName should work for contract with `name` function", async () => { + const res = await fetchTokenName({ + address: USDT_CONTRACT.address, + client, + chain: USDT_CONTRACT.chain, + }); + + expect(res).toBe("Tether USD"); + }); + + it("fetchTokenName should work for native token", async () => { + const res = await fetchTokenName({ + address: NATIVE_TOKEN_ADDRESS, + client, + chain: ethereum, + }); + + expect(res).toBe("Ether"); + }); + + it("fetchTokenName should try to fallback to the contract metadata if fails to resolves from `name()`", async () => { + // todo: find a contract with name in contractMetadata, but does not have a name function + }); + + it("fetchTokenName should throw in the end where all fallback solutions failed to resolve to any name", async () => { + await expect(() => + fetchTokenName({ + address: UNISWAPV3_FACTORY_CONTRACT.address, + client, + chain: UNISWAPV3_FACTORY_CONTRACT.chain, + }), + ).rejects.toThrowError( + "Failed to resolve name from both name() and contract metadata", + ); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Token/name.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Token/name.tsx index 806fcba3537..87fb5b91ab5 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/Token/name.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Token/name.tsx @@ -3,11 +3,14 @@ import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; import type React from "react"; import type { JSX } from "react"; +import type { Chain } from "../../../../../chains/types.js"; import { getChainMetadata } from "../../../../../chains/utils.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; import { getContract } from "../../../../../contract/contract.js"; import { getContractMetadata } from "../../../../../extensions/common/read/getContractMetadata.js"; import { name } from "../../../../../extensions/common/read/name.js"; +import { getFunctionId } from "../../../../../utils/function-id.js"; import { useTokenContext } from "./provider.js"; /** @@ -157,33 +160,21 @@ export function TokenName({ }: TokenNameProps) { const { address, client, chain } = useTokenContext(); const nameQuery = useQuery({ - queryKey: ["_internal_token_name_", chain.id, address] as const, - queryFn: async () => { - if (typeof nameResolver === "string") { - return nameResolver; - } - if (typeof nameResolver === "function") { - return nameResolver(); - } - if (address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()) { - // Don't wanna use `getChainNativeCurrencyName` because it has some side effect (it catches error and defaults to "ETH") - return getChainMetadata(chain).then((data) => data.nativeCurrency.name); - } - // Try to fetch the name from both the `name()` function and the contract metadata - // then prioritize the `name()` - const contract = getContract({ address, client, chain }); - const [_name, contractMetadata] = await Promise.all([ - name({ contract }), - getContractMetadata({ contract }), - ]); - if (!_name && !contractMetadata.name) { - throw new Error( - "Failed to resolve name from both name() and contract metadata", - ); - } - - return _name || contractMetadata.name; - }, + queryKey: [ + "_internal_token_name_", + chain.id, + address, + { + resolver: + typeof nameResolver === "string" + ? nameResolver + : typeof nameResolver === "function" + ? getFunctionId(nameResolver) + : undefined, + }, + ] as const, + queryFn: async () => + fetchTokenName({ address, chain, client, nameResolver }), ...queryOptions, }); @@ -195,7 +186,48 @@ export function TokenName({ return fallbackComponent || null; } - const displayValue = formatFn ? formatFn(nameQuery.data) : nameQuery.data; + if (formatFn && typeof formatFn === "function") { + return {formatFn(nameQuery.data)}; + } + + return {nameQuery.data}; +} - return {displayValue}; +/** + * @internal Exported for tests only + */ +export async function fetchTokenName(props: { + address: string; + client: ThirdwebClient; + chain: Chain; + nameResolver?: string | (() => string) | (() => Promise); +}) { + const { nameResolver, address, client, chain } = props; + if (typeof nameResolver === "string") { + return nameResolver; + } + if (typeof nameResolver === "function") { + return nameResolver(); + } + if (address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()) { + // Don't wanna use `getChainName` because it has some side effect (it catches error and defaults to "ETH") + return getChainMetadata(chain).then((data) => data.nativeCurrency.name); + } + + // Try to fetch the name from both the `name` function and the contract metadata + // then prioritize its result + const contract = getContract({ address, client, chain }); + const [_name, contractMetadata] = await Promise.all([ + name({ contract }).catch(() => undefined), + getContractMetadata({ contract }).catch(() => undefined), + ]); + if (typeof _name === "string") { + return _name; + } + if (typeof contractMetadata?.name === "string") { + return contractMetadata.name; + } + throw new Error( + "Failed to resolve name from both name() and contract metadata", + ); } diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Token/symbol.test.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Token/symbol.test.tsx index 0a09e414c18..44b95ba71e2 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/Token/symbol.test.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Token/symbol.test.tsx @@ -1,30 +1,86 @@ import { describe, expect, it } from "vitest"; -import { render, screen, waitFor } from "~test/react-render.js"; +import { ANVIL_CHAIN } from "~test/chains.js"; +import {} from "~test/react-render.js"; import { TEST_CLIENT } from "~test/test-clients.js"; +import { + UNISWAPV3_FACTORY_CONTRACT, + USDT_CONTRACT, +} from "~test/test-contracts.js"; import { ethereum } from "../../../../../chains/chain-definitions/ethereum.js"; import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; -import { TokenProvider } from "./provider.js"; -import { TokenSymbol } from "./symbol.js"; +import { fetchTokenSymbol } from "./symbol.js"; + +const client = TEST_CLIENT; describe.runIf(process.env.TW_SECRET_KEY)("TokenSymbol component", () => { - it("should pass the address correctly to the children props", () => { - render( - - - , - ); + it("fetchTokenSymbol should respect the symbolResolver being a string", async () => { + const res = await fetchTokenSymbol({ + address: "thing", + client, + chain: ANVIL_CHAIN, + symbolResolver: "tw", + }); + expect(res).toBe("tw"); + }); + + it("fetchTokenSymbol should respect the symbolResolver being a non-async function", async () => { + const res = await fetchTokenSymbol({ + address: "thing", + client, + chain: ANVIL_CHAIN, + symbolResolver: () => "tw", + }); + + expect(res).toBe("tw"); + }); + + it("fetchTokenSymbol should respect the symbolResolver being an async function", async () => { + const res = await fetchTokenSymbol({ + address: "thing", + client, + chain: ANVIL_CHAIN, + symbolResolver: async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return "tw"; + }, + }); + + expect(res).toBe("tw"); + }); + + it("fetchTokenSymbol should work for contract with `symbol` function", async () => { + const res = await fetchTokenSymbol({ + address: USDT_CONTRACT.address, + client, + chain: USDT_CONTRACT.chain, + }); + + expect(res).toBe("USDT"); + }); + + it("fetchTokenSymbol should work for native token", async () => { + const res = await fetchTokenSymbol({ + address: NATIVE_TOKEN_ADDRESS, + client, + chain: ethereum, + }); + + expect(res).toBe("ETH"); + }); + + it("fetchTokenSymbol should try to fallback to the contract metadata if fails to resolves from `symbol()`", async () => { + // todo: find a contract with symbol in contractMetadata, but does not have a symbol function + }); - waitFor(() => - expect( - screen.getByText("ETH", { - exact: true, - selector: "span", - }), - ).toBeInTheDocument(), + it("fetchTokenSymbol should throw in the end where all fallback solutions failed to resolve to any symbol", async () => { + await expect(() => + fetchTokenSymbol({ + address: UNISWAPV3_FACTORY_CONTRACT.address, + client, + chain: UNISWAPV3_FACTORY_CONTRACT.chain, + }), + ).rejects.toThrowError( + "Failed to resolve symbol from both symbol() and contract metadata", ); }); }); diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Token/symbol.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Token/symbol.tsx index f770a600401..2975ad60570 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/Token/symbol.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Token/symbol.tsx @@ -3,11 +3,14 @@ import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; import type React from "react"; import type { JSX } from "react"; +import type { Chain } from "../../../../../chains/types.js"; import { getChainMetadata } from "../../../../../chains/utils.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; import { getContract } from "../../../../../contract/contract.js"; import { getContractMetadata } from "../../../../../extensions/common/read/getContractMetadata.js"; import { symbol } from "../../../../../extensions/common/read/symbol.js"; +import { getFunctionId } from "../../../../../utils/function-id.js"; import { useTokenContext } from "./provider.js"; /** @@ -154,36 +157,21 @@ export function TokenSymbol({ }: TokenSymbolProps) { const { address, client, chain } = useTokenContext(); const symbolQuery = useQuery({ - queryKey: ["_internal_token_symbol_", chain.id, address] as const, - queryFn: async () => { - if (typeof symbolResolver === "string") { - return symbolResolver; - } - if (typeof symbolResolver === "function") { - return symbolResolver(); - } - if (address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()) { - // Don't wanna use `getChainSymbol` because it has some side effect (it catches error and defaults to "ETH") - return getChainMetadata(chain).then( - (data) => data.nativeCurrency.symbol, - ); - } - - // Try to fetch the symbol from both the `symbol` function and the contract metadata - // then prioritize the `symbol()` - const contract = getContract({ address, client, chain }); - const [_symbol, contractMetadata] = await Promise.all([ - symbol({ contract }), - getContractMetadata({ contract }), - ]); - if (!_symbol && !contractMetadata.symbol) { - throw new Error( - "Failed to resolve symbol from both symbol() and contract metadata", - ); - } - - return _symbol || contractMetadata.symbol; - }, + queryKey: [ + "_internal_token_symbol_", + chain.id, + address, + { + resolver: + typeof symbolResolver === "string" + ? symbolResolver + : typeof symbolResolver === "function" + ? getFunctionId(symbolResolver) + : undefined, + }, + ] as const, + queryFn: async () => + fetchTokenSymbol({ symbolResolver, address, chain, client }), ...queryOptions, }); @@ -195,7 +183,48 @@ export function TokenSymbol({ return fallbackComponent || null; } - const displayValue = formatFn ? formatFn(symbolQuery.data) : symbolQuery.data; + if (formatFn && typeof formatFn === "function") { + return {formatFn(symbolQuery.data)}; + } + + return {symbolQuery.data}; +} + +/** + * @internal Exported for tests only + */ +export async function fetchTokenSymbol(props: { + address: string; + client: ThirdwebClient; + chain: Chain; + symbolResolver?: string | (() => string) | (() => Promise); +}): Promise { + const { symbolResolver, address, client, chain } = props; + if (typeof symbolResolver === "string") { + return symbolResolver; + } + if (typeof symbolResolver === "function") { + return symbolResolver(); + } + if (address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()) { + // Don't wanna use `getChainSymbol` because it has some side effect (it catches error and defaults to "ETH") + return getChainMetadata(chain).then((data) => data.nativeCurrency.symbol); + } - return {displayValue}; + // Try to fetch the symbol from both the `symbol` function and the contract metadata + // then prioritize its result + const contract = getContract({ address, client, chain }); + const [_symbol, contractMetadata] = await Promise.all([ + symbol({ contract }).catch(() => undefined), + getContractMetadata({ contract }).catch(() => undefined), + ]); + if (typeof _symbol === "string") { + return _symbol; + } + if (typeof contractMetadata?.symbol === "string") { + return contractMetadata.symbol; + } + throw new Error( + "Failed to resolve symbol from both symbol() and contract metadata", + ); }