Skip to content

Commit

Permalink
feat(react): multichain query hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
tien committed Feb 23, 2025
1 parent 9de08c9 commit f533a74
Show file tree
Hide file tree
Showing 15 changed files with 435 additions and 76 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.
12 changes: 11 additions & 1 deletion 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.map((id) => id.toLocaleString()).join()}</dd>
<dt>Asset Hub ID</dt>
<dd>{assetHubParaId.toLocaleString()}</dd>
</dl>
);
}
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);
});
78 changes: 78 additions & 0 deletions packages/react/src/hooks/use-query-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { ChainIdContext } from "../contexts/chain.js";
import type { QueryArgument, ChainHookOptions } 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]: ChainHookOptions<TChainIds[P]> & {
query: QueryArgument<TChainIds[P]>;
};
},
>(
options: TOptions & {
[P in keyof TChainIds]: ChainHookOptions<TChainIds[P]> & {
query: QueryArgument<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 f533a74

Please sign in to comment.