From d51bb8624e39a9d5a89f7d31b9398d4b79f26c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n=20Kh=E1=BA=AFc?= Date: Fri, 28 Feb 2025 15:38:47 +1300 Subject: [PATCH] feat(react): pauseable subscription (#525) --- .changeset/five-files-knock.md | 5 + .gitignore | 1 + apps/docs/react/guides/performance.md | 36 ++++++ .../guides/{usinng-papi.md => using-papi.md} | 2 +- .../guides/{usinng-papi.md => using-papi.md} | 0 packages/react/package.json | 5 +- packages/react/src/contexts/query-options.tsx | 19 +++ packages/react/src/hooks/use-accounts.ts | 22 ++-- packages/react/src/hooks/use-balance.test.ts | 95 +++++++------- packages/react/src/hooks/use-block.ts | 26 ++-- .../hooks/use-pausable-atom-value.test.tsx | 77 ++++++++++++ .../src/hooks/use-pausable-atom-value.ts | 16 +++ packages/react/src/hooks/use-query-loader.ts | 2 +- .../src/hooks/use-query-options.test.tsx | 20 +-- .../react/src/hooks/use-query-refresher.ts | 6 +- packages/react/src/hooks/use-query.ts | 77 ++++++++---- packages/react/src/hooks/use-wallets.ts | 10 +- packages/react/src/index.test.ts | 1 + packages/react/src/index.ts | 1 + .../react/src/utils/find-all-indexes.test.ts | 2 +- packages/react/src/utils/interlace.test.ts | 2 +- .../jotai/atom-family-with-error-catcher.ts | 32 ++--- packages/react/src/utils/jotai/atom-family.ts | 11 +- .../atom-with-observable-and-promise.test.ts | 117 ++++++++++++++++++ .../jotai/atom-with-observable-and-promise.ts | 44 +++++++ packages/react/tsconfig.base.json | 12 ++ packages/react/tsconfig.json | 22 +--- packages/react/tsconfig.lib.json | 13 ++ packages/react/tsconfig.test.json | 9 ++ yarn.lock | 10 ++ 30 files changed, 544 insertions(+), 151 deletions(-) create mode 100644 .changeset/five-files-knock.md create mode 100644 apps/docs/react/guides/performance.md rename apps/docs/react/guides/{usinng-papi.md => using-papi.md} (98%) rename apps/docs/vue/guides/{usinng-papi.md => using-papi.md} (100%) create mode 100644 packages/react/src/contexts/query-options.tsx create mode 100644 packages/react/src/hooks/use-pausable-atom-value.test.tsx create mode 100644 packages/react/src/hooks/use-pausable-atom-value.ts create mode 100644 packages/react/src/utils/jotai/atom-with-observable-and-promise.test.ts create mode 100644 packages/react/src/utils/jotai/atom-with-observable-and-promise.ts create mode 100644 packages/react/tsconfig.base.json create mode 100644 packages/react/tsconfig.lib.json create mode 100644 packages/react/tsconfig.test.json diff --git a/.changeset/five-files-knock.md b/.changeset/five-files-knock.md new file mode 100644 index 00000000..27b26eff --- /dev/null +++ b/.changeset/five-files-knock.md @@ -0,0 +1,5 @@ +--- +"@reactive-dot/react": minor +--- + +Added the ability to pause subscriptions via `` context. diff --git a/.gitignore b/.gitignore index f41617a4..c25c75c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules coverage +*.tsbuildinfo .yarn/* !.yarn/patches .nx/cache diff --git a/apps/docs/react/guides/performance.md b/apps/docs/react/guides/performance.md new file mode 100644 index 00000000..94eb6763 --- /dev/null +++ b/apps/docs/react/guides/performance.md @@ -0,0 +1,36 @@ +--- +sidebar_position: 3 +--- + +# Query performance + +All query hooks in ReactiveDOT create subscriptions to the underlying data sources. These subscriptions ensure that your components always reflect the latest data changes. However, it's important to manage these subscriptions effectively to avoid performance issues. + +## Subscription lifecycle + +A new subscription is created the first time a query hook is mounted. When the last component that uses the query unmounts, the subscription is automatically cleaned up. Therefore, it's crucial to ensure components unmount when they are no longer needed. + +## Unmounting components + +Always remember to unmount components when they are no longer visible or required. This is the most straightforward way to manage subscriptions and prevent unnecessary data fetching and updates. + +## Controlling subscriptions + +In some scenarios, you might not want to unmount components (e.g., in an infinite scroll implementation where you only want active subscriptions on visible elements). In such cases, you can use the [`QueryOptionsProvider`](/api/react/function/QueryOptionsProvider) component to control subscription behavior. + +The `QueryOptionsProvider` allows you to disable subscriptions for specific parts of your application. This can be useful for optimizing performance when dealing with a large number of components. + +```tsx +import { QueryOptionsProvider } from "@reactive-dot/react"; + +export default function ParentComponent() { + // Hook implementation not included for brevity + const isVisible = useIsVisible(); + + return ( + + + + ); +} +``` diff --git a/apps/docs/react/guides/usinng-papi.md b/apps/docs/react/guides/using-papi.md similarity index 98% rename from apps/docs/react/guides/usinng-papi.md rename to apps/docs/react/guides/using-papi.md index 7592cdf5..7287f8ad 100644 --- a/apps/docs/react/guides/usinng-papi.md +++ b/apps/docs/react/guides/using-papi.md @@ -1,5 +1,5 @@ --- -sidebar_position: 3 +sidebar_position: 4 --- # Using Polkadot-API (PAPI) diff --git a/apps/docs/vue/guides/usinng-papi.md b/apps/docs/vue/guides/using-papi.md similarity index 100% rename from apps/docs/vue/guides/usinng-papi.md rename to apps/docs/vue/guides/using-papi.md diff --git a/packages/react/package.json b/packages/react/package.json index ffce2a25..734a7f96 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -27,13 +27,14 @@ "exports": "./build/index.js", "scripts": { "dev": "tsc --build --watch", - "build": "rm -rf build && tsc --build", + "build": "tsc --build --clean && tsc --build", "lint": "eslint src", "test": "vitest" }, "dependencies": { "@reactive-dot/core": "workspace:^", - "jotai": "^2.12.1" + "jotai": "^2.12.1", + "jotai-effect": "^2.0.1" }, "devDependencies": { "@reactive-dot/eslint-config": "workspace:^", diff --git a/packages/react/src/contexts/query-options.tsx b/packages/react/src/contexts/query-options.tsx new file mode 100644 index 00000000..dae7ae96 --- /dev/null +++ b/packages/react/src/contexts/query-options.tsx @@ -0,0 +1,19 @@ +import { createContext, type PropsWithChildren } from "react"; + +export const SubscriptionContext = createContext({ active: true }); + +type SubscriptionProps = PropsWithChildren<{ + active: boolean; +}>; + +/** + * React context to control subscription options. + * + * @param props - Component props + * @returns React element + */ +export function QueryOptionsProvider({ active, children }: SubscriptionProps) { + return ( + {children} + ); +} diff --git a/packages/react/src/hooks/use-accounts.ts b/packages/react/src/hooks/use-accounts.ts index 4f1a93c3..f787b7be 100644 --- a/packages/react/src/hooks/use-accounts.ts +++ b/packages/react/src/hooks/use-accounts.ts @@ -1,13 +1,13 @@ import { atomFamilyWithErrorCatcher } from "../utils/jotai/atom-family-with-error-catcher.js"; +import { atomWithObservableAndPromise } from "../utils/jotai/atom-with-observable-and-promise.js"; import type { ChainHookOptions } from "./types.js"; import { internal_useChainId } from "./use-chain-id.js"; import { chainSpecDataAtom } from "./use-chain-spec-data.js"; import { useConfig } from "./use-config.js"; +import { usePausableAtomValue } from "./use-pausable-atom-value.js"; import { connectedWalletsAtom } from "./use-wallets.js"; import { type ChainId, type Config } from "@reactive-dot/core"; import { getAccounts } from "@reactive-dot/core/internal/actions.js"; -import { useAtomValue } from "jotai"; -import { atomWithObservable } from "jotai/utils"; /** * Hook for getting currently connected accounts. @@ -16,7 +16,7 @@ import { atomWithObservable } from "jotai/utils"; * @returns The currently connected accounts */ export function useAccounts(options?: ChainHookOptions) { - return useAtomValue( + return usePausableAtomValue( accountsAtom( useConfig(), internal_useChainId({ ...options, optionalChainId: true }), @@ -29,12 +29,14 @@ export function useAccounts(options?: ChainHookOptions) { */ export const accountsAtom = atomFamilyWithErrorCatcher( (withErrorCatcher, config: Config, chainId: ChainId | undefined) => - withErrorCatcher(atomWithObservable)((get) => - getAccounts( - get(connectedWalletsAtom(config)), - chainId === undefined - ? undefined - : get(chainSpecDataAtom(config, chainId)), - ), + atomWithObservableAndPromise( + (get) => + getAccounts( + get(connectedWalletsAtom(config).observableAtom), + chainId === undefined + ? undefined + : get(chainSpecDataAtom(config, chainId)), + ), + withErrorCatcher, ), ); diff --git a/packages/react/src/hooks/use-balance.test.ts b/packages/react/src/hooks/use-balance.test.ts index ab29560a..40d6e8d3 100644 --- a/packages/react/src/hooks/use-balance.test.ts +++ b/packages/react/src/hooks/use-balance.test.ts @@ -1,9 +1,10 @@ -import { DenominatedNumber } from "../../../utils/build/denominated-number"; -import { useSpendableBalance } from "./use-balance"; -import { useNativeTokenAmountFromPlanck } from "./use-native-token-amount"; -import { useLazyLoadQuery } from "./use-query"; +import { DenominatedNumber } from "../../../utils/build/denominated-number.js"; +import { useSpendableBalance } from "./use-balance.js"; +import { useNativeTokenAmountFromPlanck } from "./use-native-token-amount.js"; +import { useLazyLoadQuery } from "./use-query.js"; import { idle, Query } from "@reactive-dot/core"; import { renderHook } from "@testing-library/react"; +import type { ChainDefinition } from "polkadot-api"; import { expect, it, vi } from "vitest"; vi.mock("./use-query"); @@ -11,48 +12,56 @@ vi.mock("./use-native-token-amount"); const free = 1000n; -vi.mocked(useLazyLoadQuery).mockImplementation((builder) => { - if (!builder) { - return idle; - } - - const query = builder(new Query()); +vi.mocked(useLazyLoadQuery).mockImplementation( + // @ts-expect-error TODO: fix type + ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + builder: >( + query: Query<[], ChainDefinition>, + ) => T, + ) => { + if (!builder) { + return idle; + } - if (!query) { - return idle; - } + const query = builder(new Query()); - return query.instructions.map((instruction) => { - if ( - instruction.instruction === "get-constant" && - instruction.pallet === "Balances" && - instruction.constant === "ExistentialDeposit" - ) { - return 100n; - } else if ( - instruction.instruction === "read-storage" && - instruction.pallet === "System" && - instruction.storage === "Account" && - "multi" in instruction && - instruction.multi - ) { - return Array.from({ length: instruction.args.length }).fill({ - nonce: 0, - consumers: 0, - providers: 0, - sufficients: 0, - data: { - free, - reserved: 1000n, - frozen: 50n, - flags: 0n, - }, - }); - } else { - throw new Error("Invalid instruction"); + if (!query) { + return idle; } - }); -}); + + return query.instructions.map((instruction) => { + if ( + instruction.instruction === "get-constant" && + instruction.pallet === "Balances" && + instruction.constant === "ExistentialDeposit" + ) { + return 100n; + } else if ( + instruction.instruction === "read-storage" && + instruction.pallet === "System" && + instruction.storage === "Account" && + "multi" in instruction && + instruction.multi + ) { + return Array.from({ length: instruction.args.length }).fill({ + nonce: 0, + consumers: 0, + providers: 0, + sufficients: 0, + data: { + free, + reserved: 1000n, + frozen: 50n, + flags: 0n, + }, + }); + } else { + throw new Error("Invalid instruction"); + } + }); + }, +); vi.mocked(useNativeTokenAmountFromPlanck).mockReturnValue( (planck) => new DenominatedNumber(planck, 0), diff --git a/packages/react/src/hooks/use-block.ts b/packages/react/src/hooks/use-block.ts index 857d7b2f..826128d8 100644 --- a/packages/react/src/hooks/use-block.ts +++ b/packages/react/src/hooks/use-block.ts @@ -1,12 +1,12 @@ import { atomFamilyWithErrorCatcher } from "../utils/jotai/atom-family-with-error-catcher.js"; +import { atomWithObservableAndPromise } from "../utils/jotai/atom-with-observable-and-promise.js"; import type { ChainHookOptions } from "./types.js"; import { internal_useChainId } from "./use-chain-id.js"; import { clientAtom } from "./use-client.js"; import { useConfig } from "./use-config.js"; +import { usePausableAtomValue } from "./use-pausable-atom-value.js"; import { type ChainId, type Config } from "@reactive-dot/core"; import { getBlock } from "@reactive-dot/core/internal/actions.js"; -import { useAtomValue } from "jotai"; -import { atomWithObservable } from "jotai/utils"; import { from } from "rxjs"; import { switchMap } from "rxjs/operators"; @@ -24,7 +24,7 @@ export function useBlock( const config = useConfig(); const chainId = internal_useChainId(options); - return useAtomValue( + return usePausableAtomValue( tag === "finalized" ? finalizedBlockAtom(config, chainId) : bestBlockAtom(config, chainId), @@ -36,10 +36,12 @@ export function useBlock( */ export const finalizedBlockAtom = atomFamilyWithErrorCatcher( (withErrorCatcher, config: Config, chainId: ChainId) => - withErrorCatcher(atomWithObservable)((get) => - from(get(clientAtom(config, chainId))).pipe( - switchMap((client) => getBlock(client, { tag: "finalized" })), - ), + atomWithObservableAndPromise( + (get) => + from(get(clientAtom(config, chainId))).pipe( + switchMap((client) => getBlock(client, { tag: "finalized" })), + ), + withErrorCatcher, ), ); @@ -48,9 +50,11 @@ export const finalizedBlockAtom = atomFamilyWithErrorCatcher( */ export const bestBlockAtom = atomFamilyWithErrorCatcher( (withErrorCatcher, config: Config, chainId: ChainId) => - withErrorCatcher(atomWithObservable)((get) => - from(get(clientAtom(config, chainId))).pipe( - switchMap((client) => getBlock(client, { tag: "best" })), - ), + atomWithObservableAndPromise( + (get) => + from(get(clientAtom(config, chainId))).pipe( + switchMap((client) => getBlock(client, { tag: "best" })), + ), + withErrorCatcher, ), ); diff --git a/packages/react/src/hooks/use-pausable-atom-value.test.tsx b/packages/react/src/hooks/use-pausable-atom-value.test.tsx new file mode 100644 index 00000000..bb4115b1 --- /dev/null +++ b/packages/react/src/hooks/use-pausable-atom-value.test.tsx @@ -0,0 +1,77 @@ +import { QueryOptionsProvider } from "../contexts/query-options.js"; +import { atomWithObservableAndPromise } from "../utils/jotai/atom-with-observable-and-promise.js"; +import { usePausableAtomValue } from "./use-pausable-atom-value.js"; +import { renderHook } from "@testing-library/react"; +import { useAtomValue } from "jotai"; +import { atomWithObservable } from "jotai/utils"; +import { act, type PropsWithChildren } from "react"; +import { BehaviorSubject } from "rxjs"; +import { expect, it } from "vitest"; + +it("should return observable atom when subscription is set to active", async () => { + const subject = new BehaviorSubject("initial"); + const valueAtom = atomWithObservableAndPromise(() => subject); + + const { result } = await act(() => + renderHook(() => usePausableAtomValue(valueAtom), { + wrapper: ({ children }) => ( + {children} + ), + }), + ); + + expect(result.current).toBe("initial"); + + act(() => subject.next("updated")); + + expect(result.current).toBe("updated"); +}); + +it("should return promise atom when subscription is set to inactive", async () => { + const subject = new BehaviorSubject("initial"); + const valueAtom = atomWithObservableAndPromise(() => subject); + + const { result } = await act(() => + renderHook(() => usePausableAtomValue(valueAtom), { + wrapper: ({ children }) => ( + {children} + ), + }), + ); + + expect(result.current).toBe("initial"); + + act(() => subject.next("updated")); + + expect(result.current).toBe("initial"); +}); + +it("should change from inactive to active when subscription status change", async () => { + const activeSubject = new BehaviorSubject(false); + const activeAtom = atomWithObservable(() => activeSubject); + + const subject = new BehaviorSubject("initial"); + const valueAtom = atomWithObservableAndPromise(() => subject); + + const Wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ); + + const { result } = await act(() => + renderHook(() => usePausableAtomValue(valueAtom), { + wrapper: Wrapper, + }), + ); + + expect(result.current).toBe("initial"); + + act(() => subject.next("updated")); + + expect(result.current).toBe("initial"); + + act(() => activeSubject.next(true)); + + expect(result.current).toBe("updated"); +}); diff --git a/packages/react/src/hooks/use-pausable-atom-value.ts b/packages/react/src/hooks/use-pausable-atom-value.ts new file mode 100644 index 00000000..125310d1 --- /dev/null +++ b/packages/react/src/hooks/use-pausable-atom-value.ts @@ -0,0 +1,16 @@ +import { SubscriptionContext } from "../contexts/query-options.js"; +import type { atomWithObservableAndPromise } from "../utils/jotai/atom-with-observable-and-promise.js"; +import { useAtomValue } from "jotai"; +import { use } from "react"; + +export function usePausableAtomValue( + pausableAtom: ReturnType>, + options?: Parameters[1], +) { + return useAtomValue( + use(SubscriptionContext).active + ? pausableAtom.observableAtom + : pausableAtom.promiseAtom, + options, + ); +} diff --git a/packages/react/src/hooks/use-query-loader.ts b/packages/react/src/hooks/use-query-loader.ts index e2d7ed1e..28dce1fc 100644 --- a/packages/react/src/hooks/use-query-loader.ts +++ b/packages/react/src/hooks/use-query-loader.ts @@ -42,7 +42,7 @@ export function useQueryLoader() { query, chainId: options?.chainId ?? chainId, }, - ]), + ]).observableAtom, ); }, [chainId, config], diff --git a/packages/react/src/hooks/use-query-options.test.tsx b/packages/react/src/hooks/use-query-options.test.tsx index 7de06d0c..04f0e68a 100644 --- a/packages/react/src/hooks/use-query-options.test.tsx +++ b/packages/react/src/hooks/use-query-options.test.tsx @@ -23,8 +23,8 @@ it("handles single query with context chainId", () => { }); expect(result.current).toHaveLength(1); - expect(result.current[0].chainId).toBe(chainId); - expect(result.current[0].query).toBeInstanceOf(Query); + expect(result.current[0]?.chainId).toBe(chainId); + expect(result.current[0]?.query).toBeInstanceOf(Query); }); it("handles single query with explicit chainId", () => { @@ -34,8 +34,8 @@ it("handles single query with explicit chainId", () => { ); expect(result.current).toHaveLength(1); - expect(result.current[0].chainId).toBe(chainId); - expect(result.current[0].query).toBeInstanceOf(Query); + expect(result.current[0]?.chainId).toBe(chainId); + expect(result.current[0]?.query).toBeInstanceOf(Query); }); it("handles multiple queries with different chainIds", () => { @@ -47,10 +47,10 @@ it("handles multiple queries with different chainIds", () => { 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); + 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", () => { @@ -60,6 +60,6 @@ it("handles Query instance directly", () => { 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); + expect(result.current[0]?.chainId).toBe(chainId); + expect(result.current[0]?.query).toBe(query); }); diff --git a/packages/react/src/hooks/use-query-refresher.ts b/packages/react/src/hooks/use-query-refresher.ts index ceda0bb3..2d230b50 100644 --- a/packages/react/src/hooks/use-query-refresher.ts +++ b/packages/react/src/hooks/use-query-refresher.ts @@ -72,8 +72,10 @@ export function useQueryRefresher( ).flat(); for (const atom of atoms) { - if ("write" in atom) { - set(atom as WritableAtom); + if ("write" in atom.promiseAtom) { + set( + atom.promiseAtom as WritableAtom, + ); } } } diff --git a/packages/react/src/hooks/use-query.ts b/packages/react/src/hooks/use-query.ts index 2a43db59..72980075 100644 --- a/packages/react/src/hooks/use-query.ts +++ b/packages/react/src/hooks/use-query.ts @@ -1,6 +1,7 @@ import { findAllIndexes } from "../utils/find-all-indexes.js"; import { interlace } from "../utils/interlace.js"; import { atomFamilyWithErrorCatcher } from "../utils/jotai/atom-family-with-error-catcher.js"; +import { atomWithObservableAndPromise } from "../utils/jotai/atom-with-observable-and-promise.js"; import { objectId } from "../utils/object-id.js"; import type { ChainHookOptions, @@ -9,6 +10,7 @@ import type { QueryOptions, } from "./types.js"; import { useConfig } from "./use-config.js"; +import { usePausableAtomValue } from "./use-pausable-atom-value.js"; import { useQueryOptions } from "./use-query-options.js"; import { useQueryRefresher } from "./use-query-refresher.js"; import { typedApiAtom } from "./use-typed-api.js"; @@ -25,8 +27,8 @@ import { stringify, } from "@reactive-dot/core/internal.js"; import { preflight, query } from "@reactive-dot/core/internal/actions.js"; -import { atom, useAtomValue } from "jotai"; -import { atomWithObservable, atomWithRefresh } from "jotai/utils"; +import { atom } from "jotai"; +import { atomWithRefresh } from "jotai/utils"; import { useMemo } from "react"; import { from, type Observable } from "rxjs"; import { switchMap } from "rxjs/operators"; @@ -80,7 +82,7 @@ export function useLazyLoadQuery( mayBeOptions, ); - const partialData = useAtomValue( + const partialData = usePausableAtomValue( queryPayloadAtom( useConfig(), useMemo( @@ -183,17 +185,30 @@ const instructionPayloadAtom = atomFamilyWithErrorCatcher( >, ) => { switch (preflight(instruction)) { - case "promise": - return withErrorCatcher(atomWithRefresh)(async (get, { signal }) => { + case "promise": { + const atom = withErrorCatcher(atomWithRefresh)(async ( + get, + { signal }, + ) => { const api = await get(typedApiAtom(config, chainId)); return query(api, instruction, { signal }); }); + + return { + observableAtom: atom, + promiseAtom: atom, + }; + } case "observable": - return withErrorCatcher(atomWithObservable)((get) => - from(get(typedApiAtom(config, chainId))).pipe( - switchMap((api) => query(api, instruction) as Observable), - ), + return atomWithObservableAndPromise( + (get) => + from(get(typedApiAtom(config, chainId))).pipe( + switchMap( + (api) => query(api, instruction) as Observable, + ), + ), + withErrorCatcher, ); } }, @@ -237,23 +252,35 @@ export const queryPayloadAtom = atomFamilyWithErrorCatcher( getQueryInstructionPayloadAtoms(config, param.chainId, param.query), ); - return withErrorCatcher(atom)((get) => { - return Promise.all( - atoms.map((atomOrAtoms) => - !Array.isArray(atomOrAtoms) - ? atomOrAtoms - : Promise.all( - atomOrAtoms.map((atomOrAtoms) => { - if (Array.isArray(atomOrAtoms)) { - return Promise.all(atomOrAtoms.map(get)); - } + const unwrap = ( + atoms: ReturnType, + asObservable: boolean, + ) => (asObservable ? atoms.observableAtom : atoms.promiseAtom); - return get(atomOrAtoms); - }), - ).then(flatHead), - ), - ); - }); + const createAtom = (asObservable: boolean) => + withErrorCatcher(atom)((get) => { + return Promise.all( + atoms.map((atomOrAtoms) => + !Array.isArray(atomOrAtoms) + ? atomOrAtoms + : Promise.all( + atomOrAtoms.map((atomOrAtoms) => { + if (Array.isArray(atomOrAtoms)) { + return Promise.all( + atomOrAtoms.map((atom) => + get(unwrap(atom, asObservable)), + ), + ); + } + + return get(unwrap(atomOrAtoms, asObservable)); + }), + ).then(flatHead), + ), + ); + }); + + return { promiseAtom: createAtom(false), observableAtom: createAtom(true) }; }, (config, params) => [ diff --git a/packages/react/src/hooks/use-wallets.ts b/packages/react/src/hooks/use-wallets.ts index d522ca44..f81eeeb2 100644 --- a/packages/react/src/hooks/use-wallets.ts +++ b/packages/react/src/hooks/use-wallets.ts @@ -1,12 +1,13 @@ import { atomFamilyWithErrorCatcher } from "../utils/jotai/atom-family-with-error-catcher.js"; +import { atomWithObservableAndPromise } from "../utils/jotai/atom-with-observable-and-promise.js"; import { useConfig } from "./use-config.js"; +import { usePausableAtomValue } from "./use-pausable-atom-value.js"; import type { Config } from "@reactive-dot/core"; import { aggregateWallets, getConnectedWallets, } from "@reactive-dot/core/internal/actions.js"; import { atom, useAtomValue } from "jotai"; -import { atomWithObservable } from "jotai/utils"; /** * Hook for getting all available wallets. @@ -23,7 +24,7 @@ export function useWallets() { * @returns Connected wallets */ export function useConnectedWallets() { - return useAtomValue(connectedWalletsAtom(useConfig())); + return usePausableAtomValue(connectedWalletsAtom(useConfig())); } /** @@ -39,7 +40,8 @@ export const walletsAtom = atomFamilyWithErrorCatcher( */ export const connectedWalletsAtom = atomFamilyWithErrorCatcher( (withErrorCatcher, config: Config) => - withErrorCatcher(atomWithObservable)((get) => - getConnectedWallets(get(walletsAtom(config))), + atomWithObservableAndPromise( + (get) => getConnectedWallets(get(walletsAtom(config))), + withErrorCatcher, ), ); diff --git a/packages/react/src/index.test.ts b/packages/react/src/index.test.ts index 7fe3e537..4f77f95d 100644 --- a/packages/react/src/index.test.ts +++ b/packages/react/src/index.test.ts @@ -8,6 +8,7 @@ it("should match inline snapshot", () => "ChainProvider", "ReactiveDotProvider", "SignerProvider", + "QueryOptionsProvider", "useAccounts", "useSpendableBalance", "useBlock", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 25d20bad..d1044c26 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -2,6 +2,7 @@ export { QueryRenderer } from "./components/query-renderer.js"; export { ChainProvider } from "./contexts/chain.js"; export { ReactiveDotProvider } from "./contexts/provider.js"; export { SignerProvider } from "./contexts/signer.js"; +export { QueryOptionsProvider } from "./contexts/query-options.js"; export { useAccounts } from "./hooks/use-accounts.js"; export { useSpendableBalance } from "./hooks/use-balance.js"; export { useBlock } from "./hooks/use-block.js"; diff --git a/packages/react/src/utils/find-all-indexes.test.ts b/packages/react/src/utils/find-all-indexes.test.ts index 93143f37..6510ef3d 100644 --- a/packages/react/src/utils/find-all-indexes.test.ts +++ b/packages/react/src/utils/find-all-indexes.test.ts @@ -1,4 +1,4 @@ -import { findAllIndexes } from "./find-all-indexes"; +import { findAllIndexes } from "./find-all-indexes.js"; import { expect, it } from "vitest"; it("should return empty array when no matches found", () => { diff --git a/packages/react/src/utils/interlace.test.ts b/packages/react/src/utils/interlace.test.ts index 931f48ce..468cae6e 100644 --- a/packages/react/src/utils/interlace.test.ts +++ b/packages/react/src/utils/interlace.test.ts @@ -1,4 +1,4 @@ -import { interlace } from "./interlace"; +import { interlace } from "./interlace.js"; import { expect, it } from "vitest"; it.each([ diff --git a/packages/react/src/utils/jotai/atom-family-with-error-catcher.ts b/packages/react/src/utils/jotai/atom-family-with-error-catcher.ts index f7f0f305..4f968df3 100644 --- a/packages/react/src/utils/jotai/atom-family-with-error-catcher.ts +++ b/packages/react/src/utils/jotai/atom-family-with-error-catcher.ts @@ -1,5 +1,5 @@ import { type AtomFamily, atomFamily } from "./atom-family.js"; -import { atom, type Atom, type Getter } from "jotai"; +import { atom, type Getter } from "jotai"; import { Observable } from "rxjs"; import { catchError } from "rxjs/operators"; @@ -9,7 +9,7 @@ export const atomFamilyErrorsAtom = atom( atomFamily: AtomFamily< // eslint-disable-next-line @typescript-eslint/no-explicit-any any[], - Atom + unknown >; args: unknown; }>(), @@ -18,25 +18,23 @@ export const atomFamilyErrorsAtom = atom( export function atomFamilyWithErrorCatcher< // eslint-disable-next-line @typescript-eslint/no-explicit-any TArguments extends any[], - TAtomType extends Atom, - TWithErrorCatcher extends < - // eslint-disable-next-line @typescript-eslint/no-explicit-any - TRead extends (get: Getter, ...args: unknown[]) => any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - TAtomCreator extends (read: TRead, ...args: any[]) => Atom, - >( - atomCreator: TAtomCreator, - ) => TAtomCreator, + TAtomType, >( initializeAtom: ( - withErrorCatcher: TWithErrorCatcher, + withErrorCatcher: (atomCreator: TAtomCreator) => TAtomCreator, ...args: TArguments ) => TAtomType, getKey?: (...args: TArguments) => unknown, ): AtomFamily { const baseAtomFamily = atomFamily((...args: TArguments) => { - // @ts-expect-error complex sub-type - const withErrorCatcher: TWithErrorCatcher = (atomCreator) => { + const withErrorCatcher: < + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TRead extends (get: Getter, ...args: unknown[]) => any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TAtomCreator extends (read: TRead, ...args: any[]) => unknown, + >( + atomCreator: TAtomCreator, + ) => TAtomCreator = (atomCreator) => { // @ts-expect-error complex sub-type const atomCatching: TAtomCreator = (read, ...args) => { // @ts-expect-error complex sub-type @@ -79,7 +77,11 @@ export function atomFamilyWithErrorCatcher< return atomCatching; }; - return initializeAtom(withErrorCatcher, ...args); + return initializeAtom( + // @ts-expect-error complex type + withErrorCatcher, + ...args, + ); }, getKey); return baseAtomFamily; diff --git a/packages/react/src/utils/jotai/atom-family.ts b/packages/react/src/utils/jotai/atom-family.ts index 8d7484ca..289e6f79 100644 --- a/packages/react/src/utils/jotai/atom-family.ts +++ b/packages/react/src/utils/jotai/atom-family.ts @@ -1,18 +1,11 @@ import { objectId } from "../object-id.js"; -import type { Atom } from "jotai"; -export type AtomFamily< - TArguments extends unknown[], - TAtomType extends Atom, -> = { +export type AtomFamily = { (...args: TArguments): TAtomType; delete: (...args: TArguments) => boolean; }; -export function atomFamily< - TArguments extends unknown[], - TAtomType extends Atom, ->( +export function atomFamily( initializeAtom: (...args: TArguments) => TAtomType, getKey?: (...args: TArguments) => unknown, ): AtomFamily { diff --git a/packages/react/src/utils/jotai/atom-with-observable-and-promise.test.ts b/packages/react/src/utils/jotai/atom-with-observable-and-promise.test.ts new file mode 100644 index 00000000..dc13a6f0 --- /dev/null +++ b/packages/react/src/utils/jotai/atom-with-observable-and-promise.test.ts @@ -0,0 +1,117 @@ +import { atomWithObservableAndPromise } from "./atom-with-observable-and-promise.js"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useAtomValue } from "jotai/react"; +import { BehaviorSubject, firstValueFrom, of, switchMap } from "rxjs"; +import { expect, it } from "vitest"; + +it("should return an atom with the initial value from the observable", async () => { + const observable$ = of("initial"); + const { observableAtom } = atomWithObservableAndPromise(() => observable$); + const render = renderHook(() => useAtomValue(observableAtom)); + + expect(render.result.current).toBe("initial"); +}); + +it("should update the atom when the observable emits a new value", async () => { + const subject$ = new BehaviorSubject("initial"); + const { observableAtom } = atomWithObservableAndPromise(() => subject$); + const render = renderHook(() => useAtomValue(observableAtom)); + + expect(render.result.current).toBe("initial"); + + act(() => { + subject$.next("updated"); + }); + + expect(render.result.current).toBe("updated"); +}); + +it("should return a promise atom that resolves with the first value from the observable", async () => { + const observable$ = of("initial"); + const { promiseAtom } = atomWithObservableAndPromise(() => observable$); + const render = await act(() => renderHook(() => useAtomValue(promiseAtom))); + + await waitFor(() => firstValueFrom(observable$)); + + expect(render.result.current).toBe("initial"); +}); + +it("should return a promise atom that resolves with the first value from the observable, even when the observable emits multiple values", async () => { + const subject$ = new BehaviorSubject("initial"); + const { promiseAtom } = atomWithObservableAndPromise(() => subject$); + const render = await act(() => renderHook(() => useAtomValue(promiseAtom))); + + expect(render.result.current).toBe("initial"); + + act(() => { + subject$.next("updated"); + }); + + expect(render.result.current).toBe("initial"); +}); + +// TODO: resolve uncaught exception +it.skip("should handle errors in the observable", async () => { + const observable$ = new BehaviorSubject("initial"); + const { observableAtom } = atomWithObservableAndPromise(() => observable$); + + const render = renderHook(() => useAtomValue(observableAtom)); + + expect(render.result.current).toBe("initial"); + + const error = new Error("Test"); + + try { + act(() => observable$.error(error)); + } catch { + /* empty */ + } + + expect(() => render.rerender()).toThrow(error); +}); + +it("should allow enhancing the atom", async () => { + const observable$ = of("initial"); + + const enhancedAtoms = new Set(); + + const { observableAtom } = atomWithObservableAndPromise( + () => observable$, + (atomCreator) => { + enhancedAtoms.add(atomCreator); + return atomCreator; + }, + ); + + const render = renderHook(() => useAtomValue(observableAtom)); + + expect(render.result.current).toBe("initial"); + expect(enhancedAtoms).toHaveLength(2); +}); + +it("should work with a delayed observable", async () => { + const delay = Promise.withResolvers(); + + const observable$ = of("initial").pipe(switchMap(() => delay.promise)); + const { observableAtom } = atomWithObservableAndPromise(() => observable$); + + const render = await act(() => + renderHook(() => useAtomValue(observableAtom)), + ); + + expect(render.result.current).toBe(null); + + delay.resolve(true); + + await act(async () => render.rerender()); + + expect(render.result.current).toBeTruthy(); +}); + +it("should handle undefined values", async () => { + const observable$ = of(undefined); + const { observableAtom } = atomWithObservableAndPromise(() => observable$); + const render = renderHook(() => useAtomValue(observableAtom)); + + expect(render.result.current).toBe(undefined); +}); diff --git a/packages/react/src/utils/jotai/atom-with-observable-and-promise.ts b/packages/react/src/utils/jotai/atom-with-observable-and-promise.ts new file mode 100644 index 00000000..2ab799f0 --- /dev/null +++ b/packages/react/src/utils/jotai/atom-with-observable-and-promise.ts @@ -0,0 +1,44 @@ +import { atom, type Atom, type Getter } from "jotai"; +import { withAtomEffect } from "jotai-effect"; +import { atomWithObservable } from "jotai/utils"; +import { firstValueFrom, type Observable } from "rxjs"; + +export function atomWithObservableAndPromise< + TValue, + TAtomEnhancer extends < + TAtomCreator extends (...args: never[]) => Atom, + >( + atomCreator: TAtomCreator, + ) => TAtomCreator, +>( + getObservable: (get: Getter) => Observable, + enhanceAtom: TAtomEnhancer = ((atomCreator) => atomCreator) as TAtomEnhancer, +): { + observableAtom: Atom>; + promiseAtom: Atom>; +} { + const rawObservableAtom = atom(getObservable); + + const initialPromise = new Promise(() => {}); + + const promiseStateAtom = atom>(initialPromise); + + const promiseAtom = enhanceAtom(atom)((get) => { + const promiseState = get(promiseStateAtom); + + if (promiseState !== initialPromise) { + return promiseState; + } + + return firstValueFrom(get(rawObservableAtom)); + }); + + const observableAtom = withAtomEffect( + enhanceAtom(atomWithObservable)((get) => get(rawObservableAtom)), + (get, set) => { + set(promiseStateAtom, get(observableAtom)); + }, + ); + + return { promiseAtom, observableAtom }; +} diff --git a/packages/react/tsconfig.base.json b/packages/react/tsconfig.base.json new file mode 100644 index 00000000..b1fc9e1c --- /dev/null +++ b/packages/react/tsconfig.base.json @@ -0,0 +1,12 @@ +{ + "extends": [ + "@tsconfig/recommended/tsconfig.json", + "@tsconfig/strictest/tsconfig.json" + ], + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx" + } +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 577fd074..25174ed5 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -1,18 +1,8 @@ { - "extends": [ - "@tsconfig/recommended/tsconfig.json", - "@tsconfig/strictest/tsconfig.json" - ], - "compilerOptions": { - "target": "ESNext", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "jsx": "react-jsx", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "build" - }, - "include": ["src"], - "exclude": ["**/*.test.*"] + "extends": "./tsconfig.base.json", + "files": [], + "references": [ + { "path": "./tsconfig.lib.json" }, + { "path": "./tsconfig.test.json" } + ] } diff --git a/packages/react/tsconfig.lib.json b/packages/react/tsconfig.lib.json new file mode 100644 index 00000000..2f02c088 --- /dev/null +++ b/packages/react/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "rootDir": "./src", + "outDir": "build" + }, + "include": ["src"], + "exclude": ["**/*.test.*"] +} diff --git a/packages/react/tsconfig.test.json b/packages/react/tsconfig.test.json new file mode 100644 index 00000000..3ed241e2 --- /dev/null +++ b/packages/react/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "composite": true, + "noEmit": true + }, + "include": ["src/**/*.test.*"], + "references": [{ "path": "./tsconfig.lib.json" }] +} diff --git a/yarn.lock b/yarn.lock index 4bf6b1f3..ad565982 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5113,6 +5113,7 @@ __metadata: "@types/react": "npm:^19.0.10" eslint: "npm:^9.21.0" jotai: "npm:^2.12.1" + jotai-effect: "npm:^2.0.1" jsdom: "npm:^26.0.0" react: "npm:^19.0.0" typescript: "npm:^5.7.3" @@ -13281,6 +13282,15 @@ __metadata: languageName: node linkType: hard +"jotai-effect@npm:^2.0.1": + version: 2.0.1 + resolution: "jotai-effect@npm:2.0.1" + peerDependencies: + jotai: ">=2.12.1" + checksum: 10c0/79df42f5d0ae2cf5e0e02152faaa123202e6dc0c00dc0826d936027d7eea3883bfc337033e024ac175170e3fe7afef141b0bd18b347d34779e19bc483fdbd093 + languageName: node + linkType: hard + "jotai@npm:^2.12.1": version: 2.12.1 resolution: "jotai@npm:2.12.1"