From 5f1d2934a41efa94f4474d7b59906f5b970d4fdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n=20Kh=E1=BA=AFc?= Date: Wed, 12 Jun 2024 15:29:37 +1200 Subject: [PATCH] feat: query refreshing --- apps/docs/docs/getting-started/query.md | 27 +++++++ apps/example/src/App.tsx | 49 +++++++++++-- apps/example/src/index.css | 14 ++++ apps/example/src/index.tsx | 1 + packages/react/src/hooks/useQuery.ts | 94 +++++++++++++++++++++---- packages/react/src/index.ts | 2 +- packages/react/src/stores/query.ts | 89 ++++++++++++++--------- 7 files changed, 221 insertions(+), 55 deletions(-) create mode 100644 apps/example/src/index.css diff --git a/apps/docs/docs/getting-started/query.md b/apps/docs/docs/getting-started/query.md index 6448ad75..a646fe28 100644 --- a/apps/docs/docs/getting-started/query.md +++ b/apps/docs/docs/getting-started/query.md @@ -89,3 +89,30 @@ const Query = () => { ); }; ``` + +## Refreshing queries + +Certain query, like runtime API calls & reading of storage entries doesn't create any subscriptions. In order to get the latest data, they must be manually refreshed with the [`useQueryWithRefresh`](/api/react/function/useQueryWithRefresh) hook. + +```tsx +import { useTransition } from "react"; + +const QueryWithRefresh = () => { + const [isPending, startTransition] = useTransition(); + const [pendingRewards, refreshPendingRewards] = useQueryWithRefresh( + (builder) => builder.callApi("NominationPoolsApi", "pending_rewards", []), + ); + + return ( +
+

{pendingRewards.toLocaleString()}

+ +
+ ); +}; +``` diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx index a3ffad50..df831df8 100644 --- a/apps/example/src/App.tsx +++ b/apps/example/src/App.tsx @@ -8,11 +8,51 @@ import { useConnectedWallets, useMutation, useQuery, + useQueryWithRefresh, useWallets, } from "@reactive-dot/react"; import { formatDistance } from "date-fns"; import { Binary } from "polkadot-api"; -import { Suspense, useState } from "react"; +import { Suspense, useState, useTransition } from "react"; + +const PendingPoolRewards = () => { + const accounts = useAccounts(); + + const [isPending, startTransition] = useTransition(); + const [pendingRewards, refreshPendingRewards] = useQueryWithRefresh( + (builder) => + builder.callApis( + "NominationPoolsApi", + "pending_rewards", + accounts.map((account) => [account.address] as const), + ), + ); + + if (accounts.length === 0) { + return ( +
+

Pending rewards

+

Please connect accounts to see pending rewards

+
+ ); + } + + return ( +
+

Pending rewards

+ + +
+ ); +}; const Query = () => { const block = useBlock(); @@ -69,7 +109,7 @@ const Query = () => {

Total issuance

-

{totalIssuance.toString()} planck

+

{totalIssuance.toLocaleString()} planck

Bonding duration

@@ -77,11 +117,11 @@ const Query = () => {

Total value staked

-

{totalStaked?.toString()} planck

+

{totalStaked?.toLocaleString()} planck

Total value locked in nomination Pools

-

{totalValueLocked.toString()} planck

+

{totalValueLocked.toLocaleString()} planck

First 4 pools

@@ -89,6 +129,7 @@ const Query = () => {

{x.asText()}

))}
+ ); }; diff --git a/apps/example/src/index.css b/apps/example/src/index.css new file mode 100644 index 00000000..928218de --- /dev/null +++ b/apps/example/src/index.css @@ -0,0 +1,14 @@ +:root { + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Oxygen, + Ubuntu, + Cantarell, + "Open Sans", + "Helvetica Neue", + sans-serif; +} diff --git a/apps/example/src/index.tsx b/apps/example/src/index.tsx index e4ba30a4..f69e4358 100644 --- a/apps/example/src/index.tsx +++ b/apps/example/src/index.tsx @@ -1,4 +1,5 @@ import App from "./App.js"; +import "./index.css"; import React from "react"; import ReactDOM from "react-dom/client"; diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts index 2536e762..d3ee560e 100644 --- a/packages/react/src/hooks/useQuery.ts +++ b/packages/react/src/hooks/useQuery.ts @@ -1,5 +1,8 @@ import { ChainIdContext } from "../context.js"; -import { queryPayloadAtomFamily } from "../stores/query.js"; +import { + getQueryInstructionPayloadAtoms, + queryPayloadAtomFamily, +} from "../stores/query.js"; import type { Falsy, FalsyGuard, FlatHead } from "../types.js"; import { flatHead, stringify } from "../utils.js"; import type { ChainHookOptions } from "./types.js"; @@ -11,16 +14,17 @@ import { } from "@reactive-dot/core"; import type { ChainId, Chains, ReDotDescriptor } from "@reactive-dot/types"; import { atom, useAtomValue } from "jotai"; -import { useContext, useMemo } from "react"; +import { useAtomCallback } from "jotai/utils"; +import { useCallback, useContext, useMemo } from "react"; /** - * Hook for querying data from chain, and returning the response. + * Hook for querying data from chain, returning the response & a refresher function. * * @param builder - The function to create the query * @param options - Additional options - * @returns The data response + * @returns The data response & a function to refresh it */ -export const useQuery = < +export const useQueryWithRefresh = < TQuery extends | (( builder: Query<[], TDescriptor>, @@ -33,14 +37,17 @@ export const useQuery = < >( builder: TQuery, options?: ChainHookOptions, -): TQuery extends false - ? undefined - : FalsyGuard< - ReturnType>, - FlatHead< - InferQueryPayload>, Falsy>> - > - > => { +): [ + data: TQuery extends false + ? undefined + : FalsyGuard< + ReturnType>, + FlatHead< + InferQueryPayload>, Falsy>> + > + >, + refresh: () => void, +] => { const contextChainId = useContext(ChainIdContext); const chainId = options?.chainId ?? contextChainId; @@ -58,8 +65,7 @@ export const useQuery = < [query], ); - // @ts-expect-error complex type - return flatHead( + const data = flatHead( useAtomValue( useMemo( () => @@ -74,4 +80,62 @@ export const useQuery = < ), ), ); + + const refresh = useAtomCallback( + useCallback( + (_, set) => { + if (!query) { + return; + } + + const atoms = getQueryInstructionPayloadAtoms(chainId, query).flat(); + + for (const atom of atoms) { + if ("write" in atom) { + set(atom); + } + } + }, + [chainId, query], + ), + ); + + return [ + // @ts-expect-error complex type + data, + refresh, + ]; +}; + +/** + * Hook for querying data from chain, and returning the response. + * + * @param builder - The function to create the query + * @param options - Additional options + * @returns The data response + */ +export const useQuery = < + TQuery extends + | (( + builder: Query<[], TDescriptor>, + ) => Query[], TDescriptor> | Falsy) + | Falsy, + TDescriptor extends TChainId extends void + ? ReDotDescriptor + : Chains[Exclude], + TChainId extends ChainId | void = void, +>( + builder: TQuery, + options?: ChainHookOptions, +): TQuery extends false + ? undefined + : FalsyGuard< + ReturnType>, + FlatHead< + InferQueryPayload>, Falsy>> + > + > => { + const [data] = useQueryWithRefresh(builder, options); + + return data; }; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 36beb60b..791296fa 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -11,6 +11,6 @@ export { useAccounts } from "./hooks/useAccounts.js"; export { useBlock } from "./hooks/useBlock.js"; export { useClient } from "./hooks/useClient.js"; export { useMutation } from "./hooks/useMutation.js"; -export { useQuery } from "./hooks/useQuery.js"; +export { useQuery, useQueryWithRefresh } from "./hooks/useQuery.js"; export { useTypedApi } from "./hooks/useTypedApi.js"; export { useConnectedWallets, useWallets } from "./hooks/useWallets.js"; diff --git a/packages/react/src/stores/query.ts b/packages/react/src/stores/query.ts index 3b24f29e..1ebe7c84 100644 --- a/packages/react/src/stores/query.ts +++ b/packages/react/src/stores/query.ts @@ -1,16 +1,15 @@ import { stringify } from "../utils.js"; import { typedApiAtomFamily } from "./client.js"; import { - type MultiInstruction, - type Query, - QueryError, QueryInstruction, preflight, query, + type MultiInstruction, + type Query, } from "@reactive-dot/core"; import type { ChainId } from "@reactive-dot/types"; -import { atom } from "jotai"; -import { atomFamily, atomWithObservable } from "jotai/utils"; +import { type Atom, type WritableAtom, atom } from "jotai"; +import { atomFamily, atomWithObservable, atomWithRefresh } from "jotai/utils"; import { from, switchMap, type Observable } from "rxjs"; const instructionPayloadAtomFamily = atomFamily( @@ -22,13 +21,13 @@ const instructionPayloadAtomFamily = atomFamily( // eslint-disable-next-line @typescript-eslint/ban-types {}> >; - }) => { + }): Atom | WritableAtom, [], void> => { switch (preflight(param.instruction)) { case "promise": - return atom(async (get, { signal }) => { + return atomWithRefresh(async (get, { signal }) => { const api = await get(typedApiAtomFamily(param.chainId)); - return query(api, param.instruction, { signal }) as Promise; + return query(api, param.instruction, { signal }); }); case "observable": return atomWithObservable((get) => @@ -43,41 +42,61 @@ const instructionPayloadAtomFamily = atomFamily( (a, b) => stringify(a) === stringify(b), ); +export const getQueryInstructionPayloadAtoms = ( + chainId: ChainId, + query: Query, +) => + query.instructions.map((instruction) => { + if (!("multi" in instruction)) { + return instructionPayloadAtomFamily({ + chainId: chainId, + instruction, + }); + } + + return (instruction.args as unknown[]).map((args) => { + const { multi, ...rest } = instruction; + + return instructionPayloadAtomFamily({ + chainId: chainId, + instruction: { ...rest, args }, + }); + }); + }); + +atomFamily((param: { chainId: ChainId; query: Query }) => + atom((get) => { + const atoms = getQueryInstructionPayloadAtoms(param.chainId, param.query); + + return Promise.all( + atoms.map((atomOrAtoms) => { + if (Array.isArray(atomOrAtoms)) { + return Promise.all(atomOrAtoms.map(get)); + } + + return get(atomOrAtoms); + }), + ); + }), +); + // TODO: should be memoized within render function instead // https://github.com/pmndrs/jotai/discussions/1553 export const queryPayloadAtomFamily = atomFamily( (param: { chainId: ChainId; query: Query }) => - atom((get) => - Promise.all( - param.query.instructions.map((instruction) => { - if (param.chainId === undefined) { - throw new QueryError("No chain Id provided"); - } + atom((get) => { + const atoms = getQueryInstructionPayloadAtoms(param.chainId, param.query); - if (!("multi" in instruction)) { - return get( - instructionPayloadAtomFamily({ - chainId: param.chainId, - instruction, - }), - ); + return Promise.all( + atoms.map((atomOrAtoms) => { + if (Array.isArray(atomOrAtoms)) { + return Promise.all(atomOrAtoms.map(get)); } - const { multi: _, ...query } = instruction; - - return Promise.all( - instruction.args.map((args: unknown[]) => - get( - instructionPayloadAtomFamily({ - chainId: param.chainId, - instruction: { ...query, args }, - }), - ), - ), - ); + return get(atomOrAtoms); }), - ), - ), + ); + }), (a, b) => a.chainId === b.chainId && stringify(a.query.instructions) === stringify(b.query.instructions),