Skip to content

Commit

Permalink
feat(react): pauseable subscription (#525)
Browse files Browse the repository at this point in the history
  • Loading branch information
tien authored Feb 28, 2025
1 parent df0b0e5 commit d51bb86
Show file tree
Hide file tree
Showing 30 changed files with 544 additions and 151 deletions.
5 changes: 5 additions & 0 deletions .changeset/five-files-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@reactive-dot/react": minor
---

Added the ability to pause subscriptions via `<QueryOptionsProvider active={false} />` context.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules
coverage
*.tsbuildinfo
.yarn/*
!.yarn/patches
.nx/cache
Expand Down
36 changes: 36 additions & 0 deletions apps/docs/react/guides/performance.md
Original file line number Diff line number Diff line change
@@ -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 (
<QueryOptionsProvider active={isVisible}>
<YourComponent />
</QueryOptionsProvider>
);
}
```
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 3
sidebar_position: 4
---

# Using Polkadot-API (PAPI)
Expand Down
File renamed without changes.
5 changes: 3 additions & 2 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
19 changes: 19 additions & 0 deletions packages/react/src/contexts/query-options.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SubscriptionContext value={{ active }}>{children}</SubscriptionContext>
);
}
22 changes: 12 additions & 10 deletions packages/react/src/hooks/use-accounts.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 }),
Expand All @@ -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,
),
);
95 changes: 52 additions & 43 deletions packages/react/src/hooks/use-balance.test.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,67 @@
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");
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: <T extends Query<any[], ChainDefinition>>(
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),
Expand Down
26 changes: 15 additions & 11 deletions packages/react/src/hooks/use-block.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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),
Expand All @@ -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,
),
);

Expand All @@ -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,
),
);
77 changes: 77 additions & 0 deletions packages/react/src/hooks/use-pausable-atom-value.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryOptionsProvider active>{children}</QueryOptionsProvider>
),
}),
);

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 }) => (
<QueryOptionsProvider active={false}>{children}</QueryOptionsProvider>
),
}),
);

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) => (
<QueryOptionsProvider active={useAtomValue(activeAtom)}>
{children}
</QueryOptionsProvider>
);

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");
});
Loading

0 comments on commit d51bb86

Please sign in to comment.