Skip to content

Commit

Permalink
feat(react): multichain query hooks (#515)
Browse files Browse the repository at this point in the history
  • Loading branch information
tien authored Feb 23, 2025
1 parent 91d8a77 commit 00d1414
Show file tree
Hide file tree
Showing 19 changed files with 557 additions and 79 deletions.
5 changes: 5 additions & 0 deletions .changeset/itchy-carrots-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@reactive-dot/react": minor
---

Added concurrent multi-chain query capability hooks.
22 changes: 22 additions & 0 deletions apps/docs/react/guides/multichain.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,28 @@ function Component() {
}
```

## Multi-chain query

You can query multiple chains concurrently by passing an array of query options to the [`useLazyLoadQuery`](/api/react/function/useLazyLoadQuery) hook. This prevents suspense waterfalls and improves performance.

```tsx
import { useLazyLoadQuery } from "@reactive-dot/react";

function Component() {
const [totalNominationPoolsValue, assets] = useLazyLoadQuery([
{
chainId: "polkadot",
query: (builder) =>
builder.readStorage("NominationPools", "TotalValueLocked", []),
},
{
chainId: "polkadot_asset_hub",
query: (builder) => builder.readStorageEntries("Assets", "Asset", []),
},
]);
}
```

## Chain narrowing

By default, ReactiveDOT merges type definitions from all the chains in the config. For instance, if your DApp is set up to work with Polkadot, Kusama, and Westend, the following code will fail because the Bounties pallet is available only on Polkadot and Kusama, not on Westend:
Expand Down
2 changes: 1 addition & 1 deletion examples/react/.papi/descriptors/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "0.1.0-autogenerated.14047238154392085370",
"version": "0.1.0-autogenerated.5420105234347264657",
"name": "@polkadot-api/descriptors",
"files": [
"dist"
Expand Down
Binary file not shown.
Binary file not shown.
10 changes: 10 additions & 0 deletions examples/react/.papi/polkadot-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,19 @@
"chain": "ksmcc3",
"metadata": ".papi/metadata/kusama.scale"
},
"kusama_asset_hub": {
"chain": "ksmcc3_asset_hub",
"metadata": ".papi/metadata/kusama_asset_hub.scale",
"genesis": "0x48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a"
},
"westend": {
"chain": "westend2",
"metadata": ".papi/metadata/westend.scale"
},
"westend_asset_hub": {
"chain": "westend2_asset_hub",
"metadata": ".papi/metadata/westend_asset_hub.scale",
"genesis": "0x67f9723393ef76214df0118c34bbbd3dbebc8ed46a10973a8c969d48fe7598c9"
}
}
}
2 changes: 2 additions & 0 deletions examples/react/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { config } from "./config";
import { MultichainQuery } from "./multichain-query";
import { Mutation } from "./mutation";
import { Query } from "./query";
import { WalletConnection } from "./wallet-connection";
Expand Down Expand Up @@ -73,6 +74,7 @@ function Example({ chainName }: ExampleProps) {
<Suspense fallback={<h2>Loading {chainName}...</h2>}>
<h2>{chainName}</h2>
<Query />
<MultichainQuery />
<Mutation />
</Suspense>
</ErrorBoundary>
Expand Down
18 changes: 16 additions & 2 deletions examples/react/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
kusama,
kusama_asset_hub,
polkadot,
polkadot_asset_hub,
polkadot_people,
westend,
westend_asset_hub,
} from "@polkadot-api/descriptors";
import { defineConfig } from "@reactive-dot/core";
import { createLightClientProvider } from "@reactive-dot/core/providers/light-client.js";
Expand All @@ -15,6 +17,10 @@ const lightClientProvider = createLightClientProvider();

const polkadotProvider = lightClientProvider.addRelayChain({ id: "polkadot" });

const kusamaProvider = lightClientProvider.addRelayChain({ id: "kusama" });

const westendProvider = lightClientProvider.addRelayChain({ id: "westend" });

export const config = defineConfig({
chains: {
polkadot: {
Expand All @@ -31,11 +37,19 @@ export const config = defineConfig({
},
kusama: {
descriptor: kusama,
provider: lightClientProvider.addRelayChain({ id: "kusama" }),
provider: kusamaProvider,
},
kusama_asset_hub: {
descriptor: kusama_asset_hub,
provider: kusamaProvider.addParachain({ id: "kusama_asset_hub" }),
},
westend: {
descriptor: westend,
provider: lightClientProvider.addRelayChain({ id: "westend" }),
provider: westendProvider,
},
westend_asset_hub: {
descriptor: westend_asset_hub,
provider: westendProvider.addParachain({ id: "westend_asset_hub" }),
},
},
targetChains: ["polkadot", "kusama", "westend"],
Expand Down
40 changes: 40 additions & 0 deletions examples/react/src/multichain-query.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useChainId, useLazyLoadQuery } from "@reactive-dot/react";
import { useMemo } from "react";

export function MultichainQuery() {
const chainId = useChainId();

const [parachains, assetHubParaId] = useLazyLoadQuery([
{
chainId: undefined,
query: (builder) => builder.readStorage("Paras", "Parachains", []),
},
{
chainId: useMemo(() => {
switch (chainId) {
case "polkadot":
case "polkadot_asset_hub":
case "polkadot_people":
return "polkadot_asset_hub";
case "kusama":
case "kusama_asset_hub":
return "kusama_asset_hub";
case "westend":
case "westend_asset_hub":
return "westend_asset_hub";
}
}, [chainId]),
query: (builder) =>
builder.readStorage("ParachainInfo", "ParachainId", []),
},
]);

return (
<dl>
<dt>Parachain IDs</dt>
<dd>{parachains.join()}</dd>
<dt>Asset Hub ID</dt>
<dd>{assetHubParaId.toString()}</dd>
</dl>
);
}
13 changes: 9 additions & 4 deletions packages/react/src/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,20 @@ import type {
InferQueryPayload,
} from "@reactive-dot/core/internal.js";

export type ChainHookOptions<
TChainId extends ChainId | undefined = ChainId | undefined,
> = {
type ChainOptions<TChainId extends ChainId | undefined> = {
/**
* Override default chain ID
*/
chainId?: TChainId | undefined;
chainId: TChainId | undefined;
};

export type ChainHookOptions<
TChainId extends ChainId | undefined = ChainId | undefined,
> = Partial<ChainOptions<TChainId>>;

export type QueryOptions<TChainId extends ChainId | undefined> =
ChainOptions<TChainId> & { query: QueryArgument<TChainId> };

export type QueryArgument<TChainId extends ChainId | undefined> =
| Query<
QueryInstruction<ChainDescriptorOf<TChainId>>[],
Expand Down
9 changes: 8 additions & 1 deletion packages/react/src/hooks/use-query-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,14 @@ export function useQueryLoader() {
) => {
const query = builder(new Query());

void get(queryPayloadAtom(config, options?.chainId ?? chainId, query));
void get(
queryPayloadAtom(config, [
{
query,
chainId: options?.chainId ?? chainId,
},
]),
);
},
[chainId, config],
);
Expand Down
65 changes: 65 additions & 0 deletions packages/react/src/hooks/use-query-options.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { ChainIdContext } from "../contexts/chain.js";
import { useQueryOptions } from "./use-query-options.js";
import { Query, ReactiveDotError } from "@reactive-dot/core";
import { renderHook } from "@testing-library/react";
import { expect, it } from "vitest";

it("throws error when no chainId is provided", () => {
const renderFunction = () =>
renderHook(() => useQueryOptions((q: Query) => q));

expect(renderFunction).toThrow(ReactiveDotError);
expect(renderFunction).toThrow("No chain ID provided");
});

it("handles single query with context chainId", () => {
const chainId = 1;
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ChainIdContext value={chainId}>{children}</ChainIdContext>
);

const { result } = renderHook(() => useQueryOptions((q: Query) => q), {
wrapper,
});

expect(result.current).toHaveLength(1);
expect(result.current[0].chainId).toBe(chainId);
expect(result.current[0].query).toBeInstanceOf(Query);
});

it("handles single query with explicit chainId", () => {
const chainId = 1;
const { result } = renderHook(() =>
useQueryOptions((q: Query) => q, { chainId }),
);

expect(result.current).toHaveLength(1);
expect(result.current[0].chainId).toBe(chainId);
expect(result.current[0].query).toBeInstanceOf(Query);
});

it("handles multiple queries with different chainIds", () => {
const options = [
{ chainId: 1, query: (q: Query) => q },
{ chainId: 2, query: (q: Query) => q },
];

const { result } = renderHook(() => useQueryOptions(options));

expect(result.current).toHaveLength(2);
expect(result.current[0].chainId).toBe(1);
expect(result.current[1].chainId).toBe(2);
expect(result.current[0].query).toBeInstanceOf(Query);
expect(result.current[1].query).toBeInstanceOf(Query);
});

it("handles Query instance directly", () => {
const chainId = 1;
const query = new Query();

const { result } = renderHook(() => useQueryOptions(query, { chainId }));

expect(result.current).toHaveLength(1);
expect(result.current[0].chainId).toBe(chainId);
expect(result.current[0].query).toBe(query);
});
74 changes: 74 additions & 0 deletions packages/react/src/hooks/use-query-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { ChainIdContext } from "../contexts/chain.js";
import type { QueryArgument, ChainHookOptions, QueryOptions } from "./types.js";
import { type ChainId, Query, ReactiveDotError } from "@reactive-dot/core";
import { use, useMemo } from "react";

/**
* @internal
*/
export function useQueryOptions<
TChainId extends ChainId | undefined,
TQuery extends QueryArgument<TChainId>,
>(
query: TQuery,
options?: ChainHookOptions<TChainId>,
): Array<{
chainId: ChainId;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
query: Query<any[], any> | undefined;
}>;
/**
* @internal
*/
export function useQueryOptions<
TChainIds extends Array<ChainId | undefined>,
const TOptions extends {
[P in keyof TChainIds]: QueryOptions<TChainIds[P]>;
},
>(
options: TOptions & {
[P in keyof TChainIds]: QueryOptions<TChainIds[P]>;
},
): Array<{
chainId: ChainId;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
query: Query<any[], any> | undefined;
}>;
/**
* @internal
*/
export function useQueryOptions(
queryOrOptions: // eslint-disable-next-line @typescript-eslint/no-explicit-any
| QueryArgument<any>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Array<ChainHookOptions<any> & { query: QueryArgument<any> }>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mayBeOptions?: ChainHookOptions<any>,
) {
const contextChainId = use(ChainIdContext);

return useMemo(
() =>
(Array.isArray(queryOrOptions)
? queryOrOptions
: [{ query: queryOrOptions, ...mayBeOptions }]
).map((options) => {
const chainId = options.chainId ?? contextChainId;

if (chainId === undefined) {
throw new ReactiveDotError("No chain ID provided");
}

return {
chainId,
query:
options.query instanceof Query
? options.query
: typeof options.query === "function"
? options.query(new Query()) || undefined
: undefined,
};
}),
[contextChainId, mayBeOptions, queryOrOptions],
);
}
Loading

0 comments on commit 00d1414

Please sign in to comment.