diff --git a/.changeset/itchy-carrots-think.md b/.changeset/itchy-carrots-think.md new file mode 100644 index 00000000..0009d38f --- /dev/null +++ b/.changeset/itchy-carrots-think.md @@ -0,0 +1,5 @@ +--- +"@reactive-dot/react": minor +--- + +Added concurrent multi-chain query capability hooks. diff --git a/apps/docs/react/guides/multichain.md b/apps/docs/react/guides/multichain.md index 39cea36f..f6ecbd74 100644 --- a/apps/docs/react/guides/multichain.md +++ b/apps/docs/react/guides/multichain.md @@ -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: diff --git a/examples/react/.papi/descriptors/package.json b/examples/react/.papi/descriptors/package.json index 89dd17f8..dbd5cea4 100644 --- a/examples/react/.papi/descriptors/package.json +++ b/examples/react/.papi/descriptors/package.json @@ -1,5 +1,5 @@ { - "version": "0.1.0-autogenerated.14047238154392085370", + "version": "0.1.0-autogenerated.5420105234347264657", "name": "@polkadot-api/descriptors", "files": [ "dist" diff --git a/examples/react/.papi/metadata/kusama_asset_hub.scale b/examples/react/.papi/metadata/kusama_asset_hub.scale new file mode 100644 index 00000000..2fb1621b Binary files /dev/null and b/examples/react/.papi/metadata/kusama_asset_hub.scale differ diff --git a/examples/react/.papi/metadata/westend_asset_hub.scale b/examples/react/.papi/metadata/westend_asset_hub.scale new file mode 100644 index 00000000..b1daa6ee Binary files /dev/null and b/examples/react/.papi/metadata/westend_asset_hub.scale differ diff --git a/examples/react/.papi/polkadot-api.json b/examples/react/.papi/polkadot-api.json index e514aaae..6b3f02f2 100644 --- a/examples/react/.papi/polkadot-api.json +++ b/examples/react/.papi/polkadot-api.json @@ -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" } } } diff --git a/examples/react/src/app.tsx b/examples/react/src/app.tsx index 59721474..32388830 100644 --- a/examples/react/src/app.tsx +++ b/examples/react/src/app.tsx @@ -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"; @@ -73,6 +74,7 @@ function Example({ chainName }: ExampleProps) { Loading {chainName}...}>

{chainName}

+
diff --git a/examples/react/src/config.ts b/examples/react/src/config.ts index 7d498e0c..9e1e3db3 100644 --- a/examples/react/src/config.ts +++ b/examples/react/src/config.ts @@ -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"; @@ -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: { @@ -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"], diff --git a/examples/react/src/multichain-query.tsx b/examples/react/src/multichain-query.tsx new file mode 100644 index 00000000..b0524f2c --- /dev/null +++ b/examples/react/src/multichain-query.tsx @@ -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 ( +
+
Parachain IDs
+
{parachains.join()}
+
Asset Hub ID
+
{assetHubParaId.toString()}
+
+ ); +} diff --git a/packages/react/src/hooks/types.ts b/packages/react/src/hooks/types.ts index 6a8a9add..5e4a235f 100644 --- a/packages/react/src/hooks/types.ts +++ b/packages/react/src/hooks/types.ts @@ -8,15 +8,20 @@ import type { InferQueryPayload, } from "@reactive-dot/core/internal.js"; -export type ChainHookOptions< - TChainId extends ChainId | undefined = ChainId | undefined, -> = { +type ChainOptions = { /** * Override default chain ID */ - chainId?: TChainId | undefined; + chainId: TChainId | undefined; }; +export type ChainHookOptions< + TChainId extends ChainId | undefined = ChainId | undefined, +> = Partial>; + +export type QueryOptions = + ChainOptions & { query: QueryArgument }; + export type QueryArgument = | Query< QueryInstruction>[], diff --git a/packages/react/src/hooks/use-query-loader.ts b/packages/react/src/hooks/use-query-loader.ts index 2e8e850d..e2d7ed1e 100644 --- a/packages/react/src/hooks/use-query-loader.ts +++ b/packages/react/src/hooks/use-query-loader.ts @@ -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], ); diff --git a/packages/react/src/hooks/use-query-options.test.tsx b/packages/react/src/hooks/use-query-options.test.tsx new file mode 100644 index 00000000..7de06d0c --- /dev/null +++ b/packages/react/src/hooks/use-query-options.test.tsx @@ -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 }) => ( + {children} + ); + + 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); +}); diff --git a/packages/react/src/hooks/use-query-options.ts b/packages/react/src/hooks/use-query-options.ts new file mode 100644 index 00000000..8ec05b71 --- /dev/null +++ b/packages/react/src/hooks/use-query-options.ts @@ -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, +>( + query: TQuery, + options?: ChainHookOptions, +): Array<{ + chainId: ChainId; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + query: Query | undefined; +}>; +/** + * @internal + */ +export function useQueryOptions< + TChainIds extends Array, + const TOptions extends { + [P in keyof TChainIds]: QueryOptions; + }, +>( + options: TOptions & { + [P in keyof TChainIds]: QueryOptions; + }, +): Array<{ + chainId: ChainId; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + query: Query | undefined; +}>; +/** + * @internal + */ +export function useQueryOptions( + queryOrOptions: // eslint-disable-next-line @typescript-eslint/no-explicit-any + | QueryArgument + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | Array & { query: QueryArgument }>, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mayBeOptions?: ChainHookOptions, +) { + 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], + ); +} diff --git a/packages/react/src/hooks/use-query-refresher.ts b/packages/react/src/hooks/use-query-refresher.ts index 4036f9a0..ceda0bb3 100644 --- a/packages/react/src/hooks/use-query-refresher.ts +++ b/packages/react/src/hooks/use-query-refresher.ts @@ -1,8 +1,9 @@ -import type { ChainHookOptions, QueryArgument } from "./types.js"; -import { internal_useChainId } from "./use-chain-id.js"; +import type { ChainHookOptions, QueryArgument, QueryOptions } from "./types.js"; import { useConfig } from "./use-config.js"; +import { useQueryOptions } from "./use-query-options.js"; import { getQueryInstructionPayloadAtoms } from "./use-query.js"; -import { Query, type ChainId } from "@reactive-dot/core"; +import { type ChainId } from "@reactive-dot/core"; +import type { WritableAtom } from "jotai"; import { useAtomCallback } from "jotai/utils"; import { useCallback } from "react"; @@ -16,36 +17,68 @@ import { useCallback } from "react"; export function useQueryRefresher< TChainId extends ChainId | undefined, TQuery extends QueryArgument, ->(query: TQuery, options?: ChainHookOptions) { +>(query: TQuery, options?: ChainHookOptions): () => void; +/** + * Hook for refreshing cached query. + * + * @param options - The query options + * @returns The function to refresh the query + */ +export function useQueryRefresher< + TChainIds extends Array, + const TOptions extends { + [P in keyof TChainIds]: QueryOptions; + }, +>( + options: TOptions & { + [P in keyof TChainIds]: QueryOptions; + }, +): () => void; +/** + * Hook for refreshing cached query. + * + * @param query - The function to create the query + * @param options - Additional options + * @returns The function to refresh the query + */ +export function useQueryRefresher( + queryOrOptions: // eslint-disable-next-line @typescript-eslint/no-explicit-any + | QueryArgument + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | Array & { query: QueryArgument }>, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mayBeOptions?: ChainHookOptions, +) { + const options = useQueryOptions( + // @ts-expect-error complex overload + queryOrOptions, + mayBeOptions, + ); + const config = useConfig(); - const chainId = internal_useChainId(options); const refresh = useAtomCallback( useCallback( (_, set) => { - if (!query) { - return; - } - - const queryValue = query instanceof Query ? query : query(new Query()); - - if (!queryValue) { - return; - } + for (const { chainId, query } of options) { + if (query === undefined) { + return; + } - const atoms = getQueryInstructionPayloadAtoms( - config, - chainId, - queryValue, - ).flat(); + const atoms = getQueryInstructionPayloadAtoms( + config, + chainId, + query, + ).flat(); - for (const atom of atoms) { - if ("write" in atom) { - set(atom); + for (const atom of atoms) { + if ("write" in atom) { + set(atom as WritableAtom); + } } } }, - [query, chainId, config], + [config, options], ), ); diff --git a/packages/react/src/hooks/use-query.ts b/packages/react/src/hooks/use-query.ts index dc8388a1..2a43db59 100644 --- a/packages/react/src/hooks/use-query.ts +++ b/packages/react/src/hooks/use-query.ts @@ -1,15 +1,23 @@ +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 { objectId } from "../utils/object-id.js"; import type { ChainHookOptions, InferQueryArgumentResult, QueryArgument, + QueryOptions, } from "./types.js"; -import { internal_useChainId } from "./use-chain-id.js"; import { useConfig } from "./use-config.js"; +import { useQueryOptions } from "./use-query-options.js"; import { useQueryRefresher } from "./use-query-refresher.js"; import { typedApiAtom } from "./use-typed-api.js"; -import { type ChainId, type Config, idle, Query } from "@reactive-dot/core"; +import { + type ChainId, + type Config, + idle, + type Query, +} from "@reactive-dot/core"; import { flatHead, type MultiInstruction, @@ -17,7 +25,7 @@ import { stringify, } from "@reactive-dot/core/internal.js"; import { preflight, query } from "@reactive-dot/core/internal/actions.js"; -import { type Atom, atom, useAtomValue, type WritableAtom } from "jotai"; +import { atom, useAtomValue } from "jotai"; import { atomWithObservable, atomWithRefresh } from "jotai/utils"; import { useMemo } from "react"; import { from, type Observable } from "rxjs"; @@ -33,41 +41,76 @@ import { switchMap } from "rxjs/operators"; export function useLazyLoadQuery< TChainId extends ChainId | undefined, TQuery extends QueryArgument, ->(query: TQuery, options?: ChainHookOptions) { - const config = useConfig(); - const chainId = internal_useChainId(options); - - const queryValue = useMemo( - () => - !query ? undefined : query instanceof Query ? query : query(new Query()), - [query], - ); - - const hashKey = useMemo( - () => (!queryValue ? queryValue : stringify(queryValue.instructions)), - [queryValue], +>( + query: TQuery, + options?: ChainHookOptions, +): InferQueryArgumentResult; +/** + * Hook for querying data from chain, and returning the response. + * + * @param options - The query options + * @returns The data response + */ +export function useLazyLoadQuery< + TChainIds extends Array, + const TOptions extends { + [P in keyof TChainIds]: QueryOptions; + }, +>( + options: TOptions & { + [P in keyof TChainIds]: QueryOptions; + }, +): { + [P in keyof TOptions]: InferQueryArgumentResult< + TOptions[P]["chainId"], + TOptions[P]["query"] + >; +}; +export function useLazyLoadQuery( + queryOrOptions: // eslint-disable-next-line @typescript-eslint/no-explicit-any + | QueryArgument + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | Array & { query: QueryArgument }>, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mayBeOptions?: ChainHookOptions, +) { + const options = useQueryOptions( + // @ts-expect-error complex overload + queryOrOptions, + mayBeOptions, ); - const rawData = useAtomValue( - useMemo( - () => - !queryValue - ? atom(idle) - : queryPayloadAtom(config, chainId, queryValue), - // eslint-disable-next-line react-hooks/exhaustive-deps - [hashKey], + const partialData = useAtomValue( + queryPayloadAtom( + useConfig(), + useMemo( + () => + options.filter( + ( + options, + ): options is Omit & { + query: NonNullable<(typeof options)["query"]>; + } => options.query !== undefined, + ), + [options], + ), ), // TODO: remove once https://github.com/pmndrs/jotai/issues/2847 is fixed { delay: 0 }, ); - return useMemo( - () => - queryValue && queryValue.instructions.length === 1 - ? flatHead(rawData) - : rawData, - [queryValue, rawData], - ) as InferQueryArgumentResult; + return useMemo(() => { + const unflattenedData = interlace( + partialData, + findAllIndexes(options, (options) => options.query === undefined).map( + (index) => [idle as unknown, index] as const, + ), + ); + + return !Array.isArray(queryOrOptions) + ? flatHead(unflattenedData) + : unflattenedData; + }, [options, partialData, queryOrOptions]); } /** @@ -80,11 +123,51 @@ export function useLazyLoadQuery< export function useLazyLoadQueryWithRefresh< TChainId extends ChainId | undefined, TQuery extends QueryArgument, ->(query: TQuery, options?: ChainHookOptions) { - const data = useLazyLoadQuery(query, options); - const refresh = useQueryRefresher(query, options); +>( + query: TQuery, + options?: ChainHookOptions, +): [data: InferQueryArgumentResult, refresh: () => void]; +/** + * Hook for querying data from chain, returning the response & a refresher function. + * + * @param query - The function to create the query + * @param options - Additional options + * @returns The data response & a function to refresh it + */ +export function useLazyLoadQueryWithRefresh< + TChainIds extends Array, + const TOptions extends { + [P in keyof TChainIds]: QueryOptions; + }, +>( + options: TOptions & { + [P in keyof TChainIds]: QueryOptions; + }, +): [ + data: { + [P in keyof TOptions]: InferQueryArgumentResult< + TOptions[P]["chainId"], + TOptions[P]["query"] + >; + }, + refresh: () => void, +]; +/** + * Hook for querying data from chain, returning the response & a refresher function. + * + * @param query - The function to create the query + * @param options - Additional options + * @returns The data response & a function to refresh it + */ +export function useLazyLoadQueryWithRefresh( + ...args: unknown[] +): [unknown, unknown] { + // @ts-expect-error need to spread args + const data = useLazyLoadQuery(...args); + // @ts-expect-error need to spread args + const refresh = useQueryRefresher(...args); - return [data, refresh] as [data: typeof data, refresh: typeof refresh]; + return [data, refresh]; } const instructionPayloadAtom = atomFamilyWithErrorCatcher( @@ -98,7 +181,7 @@ const instructionPayloadAtom = atomFamilyWithErrorCatcher( // eslint-disable-next-line @typescript-eslint/no-empty-object-type {}> >, - ): Atom | WritableAtom, [], void> => { + ) => { switch (preflight(instruction)) { case "promise": return withErrorCatcher(atomWithRefresh)(async (get, { signal }) => { @@ -148,23 +231,36 @@ export const queryPayloadAtom = atomFamilyWithErrorCatcher( ( withErrorCatcher, config: Config, - chainId: ChainId, - query: Query, - ): Atom => { - const atoms = getQueryInstructionPayloadAtoms(config, chainId, query); + params: Array<{ chainId: ChainId; query: Query }>, + ) => { + const atoms = params.map((param) => + getQueryInstructionPayloadAtoms(config, param.chainId, param.query), + ); return withErrorCatcher(atom)((get) => { return Promise.all( - atoms.map((atomOrAtoms) => { - if (Array.isArray(atomOrAtoms)) { - return Promise.all(atomOrAtoms.map(get)); - } + atoms.map((atomOrAtoms) => + !Array.isArray(atomOrAtoms) + ? atomOrAtoms + : Promise.all( + atomOrAtoms.map((atomOrAtoms) => { + if (Array.isArray(atomOrAtoms)) { + return Promise.all(atomOrAtoms.map(get)); + } - return get(atomOrAtoms); - }), + return get(atomOrAtoms); + }), + ).then(flatHead), + ), ); }); }, - (config, chainId, query) => - [objectId(config), chainId, stringify(query.instructions)].join(), + (config, params) => + [ + objectId(config), + ...params.map((param) => [ + param.chainId, + stringify(param.query.instructions), + ]), + ].join(), ); diff --git a/packages/react/src/utils/find-all-indexes.test.ts b/packages/react/src/utils/find-all-indexes.test.ts new file mode 100644 index 00000000..93143f37 --- /dev/null +++ b/packages/react/src/utils/find-all-indexes.test.ts @@ -0,0 +1,23 @@ +import { findAllIndexes } from "./find-all-indexes"; +import { expect, it } from "vitest"; + +it("should return empty array when no matches found", () => { + const array = [1, 2, 3]; + const result = findAllIndexes(array, (x) => x > 5); + + expect(result).toEqual([]); +}); + +it("should find all matching indexes", () => { + const array = [1, 2, 1, 3, 1]; + const result = findAllIndexes(array, (x) => x === 1); + + expect(result).toEqual([0, 2, 4]); +}); + +it("should work with empty array", () => { + const array: number[] = []; + const result = findAllIndexes(array, (x) => x === 1); + + expect(result).toEqual([]); +}); diff --git a/packages/react/src/utils/find-all-indexes.ts b/packages/react/src/utils/find-all-indexes.ts new file mode 100644 index 00000000..e02db807 --- /dev/null +++ b/packages/react/src/utils/find-all-indexes.ts @@ -0,0 +1,12 @@ +export function findAllIndexes( + array: T[], + predicate: (item: T) => boolean, +): number[] { + return array.reduce((indexes, item, index) => { + if (predicate(item)) { + indexes.push(index); + } + + return indexes; + }, [] as number[]); +} diff --git a/packages/react/src/utils/interlace.test.ts b/packages/react/src/utils/interlace.test.ts new file mode 100644 index 00000000..931f48ce --- /dev/null +++ b/packages/react/src/utils/interlace.test.ts @@ -0,0 +1,51 @@ +import { interlace } from "./interlace"; +import { expect, it } from "vitest"; + +it.each([ + { + array: [1, 2, 3], + itemsWithIndexes: [ + [4, 1], + [5, 3], + ], + expected: [1, 4, 2, 5, 3], + }, + { + array: [], + itemsWithIndexes: [ + [1, 0], + [2, 1], + ], + expected: [1, 2], + }, + { + array: [1, 2, 3], + itemsWithIndexes: [], + expected: [1, 2, 3], + }, + { + array: [1, 2, 3], + itemsWithIndexes: [ + [4, 1], + [5, 1], + ], + expected: [1, 5, 4, 2, 3], + }, + { + array: ["a", "b", "c"], + itemsWithIndexes: [ + ["x", 0], + ["y", 2], + ["z", 4], + ["@", 6], + ], + expected: ["x", "a", "y", "b", "z", "c", "@"], + }, +] as const)( + "should produce the following: $itemsWithIndexes -> $array = $expected", + ({ array, itemsWithIndexes, expected }) => { + expect(interlace(array, itemsWithIndexes)).toEqual( + expected, + ); + }, +); diff --git a/packages/react/src/utils/interlace.ts b/packages/react/src/utils/interlace.ts new file mode 100644 index 00000000..c9dbb044 --- /dev/null +++ b/packages/react/src/utils/interlace.ts @@ -0,0 +1,19 @@ +/** + * Given an array & a list of items and their indexes, interlace the items into the array at the specified indexes. + * + * @param array - The array to interlace the items into. + * @param itemsWithIndexes - The items to interlace into the array, along with their indexes. + * @returns The array with the items interlaced into it. + */ +export function interlace( + array: readonly T[], + itemsWithIndexes: ReadonlyArray, +): T[] { + const result = array.slice(); + + for (const [item, index] of itemsWithIndexes) { + result.splice(index, 0, item); + } + + return result; +}