Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react): multichain query hooks #515

Merged
merged 1 commit into from
Feb 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
) => {
const query = builder(new Query());

void get(queryPayloadAtom(config, options?.chainId ?? chainId, query));
void get(
queryPayloadAtom(config, [
{
query,
chainId: options?.chainId ?? chainId,
},
]),
);

Check warning on line 46 in packages/react/src/hooks/use-query-loader.ts

View check run for this annotation

Codecov / codecov/patch

packages/react/src/hooks/use-query-loader.ts#L39-L46

Added lines #L39 - L46 were not covered by tests
},
[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,

Check warning on line 69 in packages/react/src/hooks/use-query-options.ts

View check run for this annotation

Codecov / codecov/patch

packages/react/src/hooks/use-query-options.ts#L69

Added line #L69 was not covered by tests
};
}),
[contextChainId, mayBeOptions, queryOrOptions],
);
}
Loading
Loading