diff --git a/.changeset/wicked-bikes-peel.md b/.changeset/wicked-bikes-peel.md new file mode 100644 index 000000000..f599ac493 --- /dev/null +++ b/.changeset/wicked-bikes-peel.md @@ -0,0 +1,7 @@ +--- +"@onflow/fcl-core": minor +"@onflow/fcl": minor +"@onflow/kit": minor +--- + +Added useFlowQueryRaw hook to execute a query and get non-decoded data as result. diff --git a/packages/fcl-core/src/exec/query-raw.ts b/packages/fcl-core/src/exec/query-raw.ts new file mode 100644 index 000000000..c4c1c2c0e --- /dev/null +++ b/packages/fcl-core/src/exec/query-raw.ts @@ -0,0 +1,55 @@ +import * as sdk from "@onflow/sdk" +import type {ArgsFn} from "./args" +import {normalizeArgs} from "./utils/normalize-args" +import {preQuery} from "./utils/pre" +import {prepTemplateOpts} from "./utils/prep-template-opts" + +export interface QueryOptions { + cadence?: string + args?: ArgsFn + template?: any + isSealed?: boolean + limit?: number +} + +/** + * @description Allows you to submit scripts to query the blockchain and get raw response data. + * + * @param opts Query Options and configuration + * @param opts.cadence Cadence Script used to query Flow + * @param opts.args Arguments passed to cadence script + * @param opts.template Interaction Template for a script + * @param opts.isSealed Block Finality + * @param opts.limit Compute Limit for Query + * @returns A promise that resolves to the raw query result + * + * @example + * const cadence = ` + * cadence: ` + * access(all) fun main(a: Int, b: Int, c: Address): Int { + * log(c) + * return a + b + * } + * `.trim() + * + * const args = (arg, t) => [ + * arg(5, t.Int), + * arg(7, t.Int), + * arg("0xb2db43ad6bc345fec9", t.Address), + * ] + * + * await queryRaw({ cadence, args }) + */ +export async function queryRaw(opts: QueryOptions = {}): Promise { + await preQuery(opts) + opts = await prepTemplateOpts(opts) + + return sdk.send([ + sdk.script(opts.cadence!), + sdk.args(normalizeArgs(opts.args || [])), + sdk.atLatestBlock(opts.isSealed ?? false), + opts.limit && + typeof opts.limit === "number" && + (sdk.limit(opts.limit!) as any), + ]) +} diff --git a/packages/fcl-core/src/exec/query.js b/packages/fcl-core/src/exec/query.js deleted file mode 100644 index bee2ebcba..000000000 --- a/packages/fcl-core/src/exec/query.js +++ /dev/null @@ -1,48 +0,0 @@ -import * as sdk from "@onflow/sdk" -import * as config from "@onflow/config" -import {normalizeArgs} from "./utils/normalize-args" -import {prepTemplateOpts} from "./utils/prep-template-opts.js" -import {preQuery} from "./utils/pre.js" - -/** - * @description - * Allows you to submit scripts to query the blockchain. - * - * @param {object} opts - Query Options and configuration - * @param {string} opts.cadence - Cadence Script used to query Flow - * @param {import("./args").ArgsFn} [opts.args] - Arguments passed to cadence script - * @param {object | string} [opts.template] - Interaction Template for a script - * @param {boolean} [opts.isSealed] - Block Finality - * @param {number} [opts.limit] - Compute Limit for Query - * @returns {Promise} - * - * @example - * const cadence = ` - * cadence: ` - * access(all) fun main(a: Int, b: Int, c: Address): Int { - * log(c) - * return a + b - * } - * `.trim() - * - * const args = (arg, t) => [ - * arg(5, t.Int), - * arg(7, t.Int), - * arg("0xb2db43ad6bc345fec9", t.Address), - * ] - * - * await query({ cadence, args }) - */ -export async function query(opts = {}) { - await preQuery(opts) - opts = await prepTemplateOpts(opts) - - return sdk - .send([ - sdk.script(opts.cadence), - sdk.args(normalizeArgs(opts.args || [])), - sdk.atLatestBlock(opts.isSealed ?? false), - opts.limit && typeof opts.limit === "number" && sdk.limit(opts.limit), - ]) - .then(sdk.decode) -} diff --git a/packages/fcl-core/src/exec/query.ts b/packages/fcl-core/src/exec/query.ts new file mode 100644 index 000000000..1af3f31b8 --- /dev/null +++ b/packages/fcl-core/src/exec/query.ts @@ -0,0 +1,34 @@ +import * as sdk from "@onflow/sdk" +import {QueryOptions, queryRaw} from "./query-raw" + +/** + * @description Allows you to submit scripts to query the blockchain. + * + * @param opts Query Options and configuration + * @param opts.cadence Cadence Script used to query Flow + * @param opts.args Arguments passed to cadence script + * @param opts.template Interaction Template for a script + * @param opts.isSealed Block Finality + * @param opts.limit Compute Limit for Query + * @returns A promise that resolves to the query result + * + * @example + * const cadence = ` + * cadence: ` + * access(all) fun main(a: Int, b: Int, c: Address): Int { + * log(c) + * return a + b + * } + * `.trim() + * + * const args = (arg, t) => [ + * arg(5, t.Int), + * arg(7, t.Int), + * arg("0xb2db43ad6bc345fec9", t.Address), + * ] + * + * await query({ cadence, args }) + */ +export async function query(opts: QueryOptions = {}): Promise { + return queryRaw(opts).then(sdk.decode) +} diff --git a/packages/fcl-core/src/fcl-core.ts b/packages/fcl-core/src/fcl-core.ts index 1a764cb6f..6ea39eb32 100644 --- a/packages/fcl-core/src/fcl-core.ts +++ b/packages/fcl-core/src/fcl-core.ts @@ -1,5 +1,6 @@ export {VERSION} from "./VERSION" export {query} from "./exec/query" +export {queryRaw} from "./exec/query-raw" export {verifyUserSignatures} from "./exec/verify" export {serialize} from "./serialize" export {transaction as tx, TransactionError} from "./transaction" diff --git a/packages/fcl-core/src/interaction-template-utils/get-interaction-template-audits.js b/packages/fcl-core/src/interaction-template-utils/get-interaction-template-audits.js index 8c59d595a..633f8e3d1 100644 --- a/packages/fcl-core/src/interaction-template-utils/get-interaction-template-audits.js +++ b/packages/fcl-core/src/interaction-template-utils/get-interaction-template-audits.js @@ -1,6 +1,6 @@ import {config, invariant} from "@onflow/sdk" import {log, LEVELS} from "@onflow/util-logger" -import {query} from "../exec/query.js" +import {query} from "../exec/query" import {generateTemplateId} from "./generate-template-id/generate-template-id.js" import {getChainId} from "../utils" diff --git a/packages/fcl/src/fcl.ts b/packages/fcl/src/fcl.ts index 78508a077..fdefe588a 100644 --- a/packages/fcl/src/fcl.ts +++ b/packages/fcl/src/fcl.ts @@ -1,6 +1,7 @@ export { VERSION, query, + queryRaw, verifyUserSignatures, serialize, tx, diff --git a/packages/kit/src/__mocks__/fcl.ts b/packages/kit/src/__mocks__/fcl.ts index a2c5e317a..a4546d92a 100644 --- a/packages/kit/src/__mocks__/fcl.ts +++ b/packages/kit/src/__mocks__/fcl.ts @@ -17,6 +17,7 @@ export default { events: jest.fn(), mutate: jest.fn(), query: jest.fn(), + queryRaw: jest.fn(), tx: jest.fn(), config: () => ({ subscribe: sharedSubscribe, diff --git a/packages/kit/src/hooks/index.ts b/packages/kit/src/hooks/index.ts index e7514cf4b..5c30464d5 100644 --- a/packages/kit/src/hooks/index.ts +++ b/packages/kit/src/hooks/index.ts @@ -5,6 +5,7 @@ export {useFlowConfig} from "./useFlowConfig" export {useFlowEvents} from "./useFlowEvents" export {useFlowMutate} from "./useFlowMutate" export {useFlowQuery} from "./useFlowQuery" +export {useFlowQueryRaw} from "./useFlowQueryRaw" export {useFlowRevertibleRandom} from "./useFlowRevertibleRandom" export {useCrossVmBatchTransaction} from "./useCrossVmBatchTransaction" export {useCrossVmTokenBalance} from "./useCrossVmTokenBalance" diff --git a/packages/kit/src/hooks/useFlowQuery.test.ts b/packages/kit/src/hooks/useFlowQuery.test.ts index c4cbe9b03..a4fcfb493 100644 --- a/packages/kit/src/hooks/useFlowQuery.test.ts +++ b/packages/kit/src/hooks/useFlowQuery.test.ts @@ -1,7 +1,7 @@ import {renderHook, act, waitFor} from "@testing-library/react" import * as fcl from "@onflow/fcl" import {FlowProvider} from "../provider" -import {useFlowQuery} from "./useFlowQuery" +import {useFlowQuery, encodeQueryArgs} from "./useFlowQuery" jest.mock("@onflow/fcl", () => require("../__mocks__/fcl").default) @@ -183,4 +183,55 @@ describe("useFlowQuery", () => { await waitFor(() => expect(hookResult.current.data).toEqual(updatedResult)) }) + + describe("encodeQueryArgs", () => { + beforeEach(() => { + // Clear mocks + jest.clearAllMocks() + }) + + test("returns undefined when args is undefined", () => { + const result = encodeQueryArgs(undefined) + expect(result).toBeUndefined() + }) + + test("returns undefined when args is null", () => { + const result = encodeQueryArgs(null as any) + expect(result).toBeUndefined() + }) + + test("encodes single argument correctly", () => { + const argsFunction = (arg: typeof fcl.arg, t: typeof fcl.t) => [ + arg("42", t.Int), + ] + + const result = encodeQueryArgs(argsFunction) + + expect(result).toEqual([{type: "Int", value: "42"}]) + }) + + test("encodes multiple arguments correctly", () => { + const argsFunction = (arg: typeof fcl.arg, t: typeof fcl.t) => [ + arg("42", t.Int), + arg("hello", t.String), + arg("0x1234567890abcdef", t.Address), + ] + + const result = encodeQueryArgs(argsFunction) + + expect(result).toEqual([ + {type: "Int", value: "42"}, + {type: "String", value: "hello"}, + {type: "Address", value: "0x1234567890abcdef"}, + ]) + }) + + test("handles empty args array", () => { + const argsFunction = (arg: typeof fcl.arg, t: typeof fcl.t) => [] + + const result = encodeQueryArgs(argsFunction) + + expect(result).toEqual([]) + }) + }) }) diff --git a/packages/kit/src/hooks/useFlowQuery.ts b/packages/kit/src/hooks/useFlowQuery.ts index 42f7b22ca..9617bf28a 100644 --- a/packages/kit/src/hooks/useFlowQuery.ts +++ b/packages/kit/src/hooks/useFlowQuery.ts @@ -3,6 +3,14 @@ import {useQuery, UseQueryResult, UseQueryOptions} from "@tanstack/react-query" import {useCallback} from "react" import {useFlowQueryClient} from "../provider/FlowQueryClient" +export function encodeQueryArgs( + args?: (arg: typeof fcl.arg, t: typeof fcl.t) => unknown[] +): any[] | undefined { + // Encode the arguments to a JSON-CDC object so they can be deterministically + // serialized and used as the query key. + return args?.(fcl.arg, fcl.t)?.map((x: any) => x.xform.asArgument(x.value)) +} + export interface UseFlowQueryArgs { cadence: string args?: (arg: typeof fcl.arg, t: typeof fcl.t) => unknown[] @@ -32,12 +40,7 @@ export function useFlowQuery({ return fcl.query({cadence, args}) }, [cadence, args]) - // Encode the arguments to a JSON-CDC object so they can be deterministically - // serialized and used as the query key. - const encodedArgs = args?.(fcl.arg, fcl.t)?.map((x: any) => - x.xform.asArgument(x.value) - ) - + const encodedArgs = encodeQueryArgs(args) return useQuery( { queryKey: ["flowQuery", cadence, encodedArgs], diff --git a/packages/kit/src/hooks/useFlowQueryRaw.test.ts b/packages/kit/src/hooks/useFlowQueryRaw.test.ts new file mode 100644 index 000000000..b7ae4138c --- /dev/null +++ b/packages/kit/src/hooks/useFlowQueryRaw.test.ts @@ -0,0 +1,186 @@ +import {renderHook, act, waitFor} from "@testing-library/react" +import * as fcl from "@onflow/fcl" +import {FlowProvider} from "../provider" +import {useFlowQueryRaw} from "./useFlowQueryRaw" + +jest.mock("@onflow/fcl", () => require("../__mocks__/fcl").default) + +describe("useFlowQueryRaw", () => { + afterEach(() => { + jest.clearAllMocks() + }) + + test("returns undefined when no cadence is provided", async () => { + const {result} = renderHook(() => useFlowQueryRaw({cadence: ""}), { + wrapper: FlowProvider, + }) + + expect(result.current.data).toBeUndefined() + await waitFor(() => expect(result.current.isLoading).toBe(false)) + }) + + test("fetches data successfully", async () => { + const cadenceScript = "access(all) fun main(): Int { return 42 }" + const expectedResult = 42 + const queryMock = jest.mocked(fcl.queryRaw) + queryMock.mockResolvedValueOnce(expectedResult) + + let hookResult: any + + await act(async () => { + const {result} = renderHook( + () => useFlowQueryRaw({cadence: cadenceScript}), + { + wrapper: FlowProvider, + } + ) + hookResult = result + }) + + expect(hookResult.current.data).toBeUndefined() + + await waitFor(() => expect(hookResult.current.isLoading).toBe(false)) + expect(hookResult.current.data).toEqual(expectedResult) + expect(queryMock).toHaveBeenCalledWith({ + cadence: cadenceScript, + args: undefined, + }) + }) + + test("does not fetch data when enabled is false", async () => { + const cadenceScript = "access(all) fun main(): Int { return 42 }" + const queryMock = jest.mocked(fcl.queryRaw) + + renderHook( + () => useFlowQueryRaw({cadence: cadenceScript, query: {enabled: false}}), + { + wrapper: FlowProvider, + } + ) + + // wait a little to ensure fcl.queryRaw isn't called + await waitFor(() => { + expect(queryMock).not.toHaveBeenCalled() + }) + }) + + test("handles error from fcl.queryRaw", async () => { + const cadenceScript = "access(all) fun main(): Int { return 42 }" + const testError = new Error("Query failed") + const queryMock = jest.mocked(fcl.queryRaw) + queryMock.mockRejectedValueOnce(testError) + + let hookResult: any + + await act(async () => { + const {result} = renderHook( + () => useFlowQueryRaw({cadence: cadenceScript}), + { + wrapper: FlowProvider, + } + ) + hookResult = result + }) + + await waitFor(() => expect(hookResult.current.isLoading).toBe(false)) + expect(hookResult.current.data).toBeUndefined() + expect(hookResult.current.error).toEqual(testError) + }) + + test("refetch function works correctly", async () => { + const cadenceScript = "access(all) fun main(): Int { return 42 }" + const initialResult = 42 + const updatedResult = 100 + const queryMock = jest.mocked(fcl.queryRaw) + queryMock.mockResolvedValueOnce(initialResult) + + let hookResult: any + + await act(async () => { + const {result} = renderHook( + () => useFlowQueryRaw({cadence: cadenceScript}), + { + wrapper: FlowProvider, + } + ) + hookResult = result + }) + + await waitFor(() => expect(hookResult.current.data).toEqual(initialResult)) + + queryMock.mockResolvedValueOnce(updatedResult) + act(() => { + hookResult.current.refetch() + }) + + await waitFor(() => expect(hookResult.current.data).toEqual(updatedResult)) + expect(queryMock).toHaveBeenCalledTimes(2) + }) + + test("supports args function parameter", async () => { + const cadenceScript = "access(all) fun main(a: Int): Int { return a }" + const expectedResult = 7 + const queryMock = jest.mocked(fcl.queryRaw) + queryMock.mockResolvedValueOnce(expectedResult) + + const argsFunction = (arg: typeof fcl.arg, t: typeof fcl.t) => [ + arg(7, t.Int), + ] + + let hookResult: any + + await act(async () => { + const {result} = renderHook( + () => useFlowQueryRaw({cadence: cadenceScript, args: argsFunction}), + { + wrapper: FlowProvider, + } + ) + hookResult = result + }) + + await waitFor(() => expect(hookResult.current.isLoading).toBe(false)) + expect(hookResult.current.data).toEqual(expectedResult) + expect(queryMock).toHaveBeenCalledWith({ + cadence: cadenceScript, + args: argsFunction, + }) + }) + + test("detects args changes", async () => { + const cadenceScript = "access(all) fun main(a: Int): Int { return a }" + const initialResult = 7 + const updatedResult = 42 + const queryMock = jest.mocked(fcl.queryRaw) + queryMock.mockResolvedValueOnce(initialResult) + + const argsFunction = (arg: typeof fcl.arg, t: typeof fcl.t) => [ + arg(7, t.Int), + ] + + let hookResult: any + let hookRerender: any + + await act(async () => { + const {result, rerender} = renderHook(useFlowQueryRaw, { + wrapper: FlowProvider, + initialProps: {cadence: cadenceScript, args: argsFunction}, + }) + hookResult = result + hookRerender = rerender + }) + + await waitFor(() => expect(hookResult.current.isLoading).toBe(false)) + expect(hookResult.current.data).toEqual(initialResult) + + queryMock.mockResolvedValueOnce(updatedResult) + await act(() => { + hookRerender({ + cadence: cadenceScript, + args: (arg: typeof fcl.arg, t: typeof fcl.t) => [arg(42, t.Int)], + }) + }) + + await waitFor(() => expect(hookResult.current.data).toEqual(updatedResult)) + }) +}) diff --git a/packages/kit/src/hooks/useFlowQueryRaw.ts b/packages/kit/src/hooks/useFlowQueryRaw.ts new file mode 100644 index 000000000..4914e4fea --- /dev/null +++ b/packages/kit/src/hooks/useFlowQueryRaw.ts @@ -0,0 +1,46 @@ +import * as fcl from "@onflow/fcl" +import {useQuery, UseQueryResult, UseQueryOptions} from "@tanstack/react-query" +import {useCallback} from "react" +import {useFlowQueryClient} from "../provider/FlowQueryClient" +import {encodeQueryArgs} from "./useFlowQuery" + +export interface UseFlowQueryRawArgs { + cadence: string + args?: (arg: typeof fcl.arg, t: typeof fcl.t) => unknown[] + query?: Omit, "queryKey" | "queryFn"> +} + +/** + * useFlowQueryRaw + * + * Executes a Cadence script and returns the raw query result. + * + * @param params + * - cadence: The Cadence script to run + * - args: (optional) A function returning script arguments + * - query: (optional) React Query settings (staleTime, retry, enabled, select, etc.) + * @returns {UseQueryResult} + */ +export function useFlowQueryRaw({ + cadence, + args, + query: queryOptions = {}, +}: UseFlowQueryRawArgs): UseQueryResult { + const queryClient = useFlowQueryClient() + + const fetchQueryRaw = useCallback(async () => { + if (!cadence) return null + return fcl.queryRaw({cadence, args}) + }, [cadence, args]) + + const encodedArgs = encodeQueryArgs(args) + return useQuery( + { + queryKey: ["flowQueryRaw", cadence, encodedArgs], + queryFn: fetchQueryRaw, + enabled: queryOptions.enabled ?? true, + ...queryOptions, + }, + queryClient + ) +}