Skip to content

Commit d1845f3

Browse files
committed
[SDK] Chain headless components (#5495)
## Problem solved Short description of the bug fixed or feature added <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces headless components related to blockchain chain management in the `thirdweb` library, including `ChainProvider`, `ChainIcon`, and `ChainName`. It also adds tests for these components and updates documentation to reflect their usage. ### Detailed summary - Added headless components: `ChainProvider`, `ChainIcon`, `ChainName`. - Introduced `TokenIconProps` interface. - Updated `react.ts` to export new components and their props. - Added documentation for `ChainProvider`, `ChainIcon`, and `ChainName` in `page.mdx`. - Implemented tests for `TokenSymbol`, `ChainProvider`, `TokenProvider`, `ChainName`. - Enhanced `ChainProvider` and `ChainIcon` components with new props and functionalities. - Updated `ChainName` component to support custom name resolvers and formatting functions. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent dfc824d commit d1845f3

File tree

11 files changed

+640
-0
lines changed

11 files changed

+640
-0
lines changed

.changeset/clever-carrots-march.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"thirdweb": minor
3+
---
4+
5+
Add headless components: ChainProvider, ChainIcon & ChainName

apps/portal/src/app/react/v5/components/onchain/page.mdx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,27 @@ Build your own UI and interact with onchain data using headless components.
113113
description="Component to display the description of an NFT"
114114
/>
115115

116+
### Chains
117+
118+
<ArticleIconCard
119+
title="ChainProvider"
120+
icon={ReactIcon}
121+
href="/references/typescript/v5/ChainProvider"
122+
description="Component to provide the Chain context to your app"
123+
/>
124+
125+
<ArticleIconCard
126+
title="ChainIcon"
127+
icon={ReactIcon}
128+
href="/references/typescript/v5/ChainIcon"
129+
description="Component to display the icon of a chain"
130+
/>
131+
132+
<ArticleIconCard
133+
title="ChainName"
134+
icon={ReactIcon}
135+
href="/references/typescript/v5/ChainName"
136+
description="Component to display the name of a chain"
137+
/>
138+
116139
</Stack>

packages/thirdweb/src/exports/react.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,17 @@ export {
253253
TokenIcon,
254254
type TokenIconProps,
255255
} from "../react/web/ui/prebuilt/Token/icon.js";
256+
257+
// Chain
258+
export {
259+
ChainProvider,
260+
type ChainProviderProps,
261+
} from "../react/web/ui/prebuilt/Chain/provider.js";
262+
export {
263+
ChainName,
264+
type ChainNameProps,
265+
} from "../react/web/ui/prebuilt/Chain/name.js";
266+
export {
267+
ChainIcon,
268+
type ChainIconProps,
269+
} from "../react/web/ui/prebuilt/Chain/icon.js";
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
2+
import type { JSX } from "react";
3+
import { getChainMetadata } from "../../../../../chains/utils.js";
4+
import type { ThirdwebClient } from "../../../../../client/client.js";
5+
import { resolveScheme } from "../../../../../utils/ipfs.js";
6+
import { useChainContext } from "./provider.js";
7+
8+
/**
9+
* Props for the ChainIcon component
10+
* @chain
11+
* @component
12+
*/
13+
export interface ChainIconProps
14+
extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src"> {
15+
/**
16+
* You need a ThirdwebClient to resolve the icon which is hosted on IPFS.
17+
* (since most chain icons are hosted on IPFS, loading them via thirdweb gateway will ensure better performance)
18+
*/
19+
client: ThirdwebClient;
20+
/**
21+
* This prop can be a string or a (async) function that resolves to a string, representing the icon url of the chain
22+
* This is particularly useful if you already have a way to fetch the chain icon.
23+
*/
24+
iconResolver?: string | (() => string) | (() => Promise<string>);
25+
/**
26+
* This component will be shown while the avatar of the icon is being fetched
27+
* If not passed, the component will return `null`.
28+
*
29+
* You can pass a loading sign or spinner to this prop.
30+
* @example
31+
* ```tsx
32+
* <ChainIcon loadingComponent={<Spinner />} />
33+
* ```
34+
*/
35+
loadingComponent?: JSX.Element;
36+
/**
37+
* This component will be shown if the request for fetching the avatar is done
38+
* but could not retreive any result.
39+
* You can pass a dummy avatar/image to this prop.
40+
*
41+
* If not passed, the component will return `null`
42+
*
43+
* @example
44+
* ```tsx
45+
* <ChainIcon fallbackComponent={<DummyImage />} />
46+
* ```
47+
*/
48+
fallbackComponent?: JSX.Element;
49+
50+
/**
51+
* Optional query options for `useQuery`
52+
*/
53+
queryOptions?: Omit<UseQueryOptions<string>, "queryFn" | "queryKey">;
54+
}
55+
56+
/**
57+
* This component tries to resolve the icon of a given chain, then return an image.
58+
* @returns an <img /> with the src of the chain icon
59+
*
60+
* @example
61+
* ### Basic usage
62+
* ```tsx
63+
* import { ChainProvider, ChainIcon } from "thirdweb/react";
64+
*
65+
* <ChainProvider chain={chain}>
66+
* <ChainIcon />
67+
* </ChainProvider>
68+
* ```
69+
*
70+
* Result: An <img /> component with the src of the icon
71+
* ```html
72+
* <img src="chain-icon.png" />
73+
* ```
74+
*
75+
* ### Override the icon with the `iconResolver` prop
76+
* If you already have the icon url, you can skip the network requests and pass it directly to the ChainIcon
77+
* ```tsx
78+
* <ChainIcon iconResolver="/ethereum-icon.png" />
79+
* ```
80+
*
81+
* You can also pass in your own custom (async) function that retrieves the icon url
82+
* ```tsx
83+
* const getIcon = async () => {
84+
* const icon = getIconFromCoinMarketCap(chainId, etc);
85+
* return icon;
86+
* };
87+
*
88+
* <ChainIcon iconResolver={getIcon} />
89+
* ```
90+
*
91+
* ### Show a loading sign while the icon is being loaded
92+
* ```tsx
93+
* <ChainIcon loadingComponent={<Spinner />} />
94+
* ```
95+
*
96+
* ### Fallback to a dummy image if the chain icon fails to resolve
97+
* ```tsx
98+
* <ChainIcon fallbackComponent={<img src="blank-image.png" />} />
99+
* ```
100+
*
101+
* ### Usage with queryOptions
102+
* ChainIcon uses useQuery() from tanstack query internally.
103+
* It allows you to pass a custom queryOptions of your choice for more control of the internal fetching logic
104+
* ```tsx
105+
* <ChainIcon queryOptions={{ enabled: someLogic, retry: 3, }} />
106+
* ```
107+
*
108+
* @component
109+
* @chain
110+
* @beta
111+
*/
112+
export function ChainIcon({
113+
iconResolver,
114+
loadingComponent,
115+
fallbackComponent,
116+
queryOptions,
117+
client,
118+
...restProps
119+
}: ChainIconProps) {
120+
const { chain } = useChainContext();
121+
const iconQuery = useQuery({
122+
queryKey: ["_internal_chain_icon_", chain.id] as const,
123+
queryFn: async () => {
124+
if (typeof iconResolver === "string") {
125+
return iconResolver;
126+
}
127+
if (typeof iconResolver === "function") {
128+
return iconResolver();
129+
}
130+
// Check if the chain object already has "icon"
131+
if (chain.icon?.url) {
132+
return chain.icon.url;
133+
}
134+
const possibleUrl = await getChainMetadata(chain).then(
135+
(data) => data.icon?.url,
136+
);
137+
if (!possibleUrl) {
138+
throw new Error("Failed to resolve icon for chain");
139+
}
140+
return resolveScheme({ uri: possibleUrl, client });
141+
},
142+
...queryOptions,
143+
});
144+
145+
if (iconQuery.isLoading) {
146+
return loadingComponent || null;
147+
}
148+
149+
if (!iconQuery.data) {
150+
return fallbackComponent || null;
151+
}
152+
153+
return <img src={iconQuery.data} {...restProps} alt={restProps.alt} />;
154+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from "vitest";
2+
import { render, screen, waitFor } from "~test/react-render.js";
3+
import { ethereum } from "../../../../../chains/chain-definitions/ethereum.js";
4+
import { defineChain } from "../../../../../chains/utils.js";
5+
import { ChainName } from "./name.js";
6+
import { ChainProvider } from "./provider.js";
7+
8+
describe.runIf(process.env.TW_SECRET_KEY)("ChainName component", () => {
9+
it("should return the correct chain name, if the name exists in the chain object", () => {
10+
render(
11+
<ChainProvider chain={ethereum}>
12+
<ChainName />
13+
</ChainProvider>,
14+
);
15+
waitFor(() =>
16+
expect(
17+
screen.getByText("Ethereum", {
18+
exact: true,
19+
selector: "span",
20+
}),
21+
).toBeInTheDocument(),
22+
);
23+
});
24+
25+
it("should return the correct chain name, if the name is loaded from the server", () => {
26+
render(
27+
<ChainProvider chain={defineChain(1)}>
28+
<ChainName />
29+
</ChainProvider>,
30+
);
31+
waitFor(() =>
32+
expect(
33+
screen.getByText("Ethereum Mainnet", {
34+
exact: true,
35+
selector: "span",
36+
}),
37+
).toBeInTheDocument(),
38+
);
39+
});
40+
41+
it("should return the correct FORMATTED chain name", () => {
42+
render(
43+
<ChainProvider chain={ethereum}>
44+
<ChainName formatFn={(str: string) => `${str}-formatted`} />
45+
</ChainProvider>,
46+
);
47+
waitFor(() =>
48+
expect(
49+
screen.getByText("Ethereum-formatted", {
50+
exact: true,
51+
selector: "span",
52+
}),
53+
).toBeInTheDocument(),
54+
);
55+
});
56+
57+
it("should fallback properly when fail to resolve chain name", () => {
58+
render(
59+
<ChainProvider chain={defineChain(-1)}>
60+
<ChainName fallbackComponent={<span>oops</span>} />
61+
</ChainProvider>,
62+
);
63+
64+
waitFor(() =>
65+
expect(
66+
screen.getByText("oops", {
67+
exact: true,
68+
selector: "span",
69+
}),
70+
).toBeInTheDocument(),
71+
);
72+
});
73+
});

0 commit comments

Comments
 (0)