diff --git a/.changeset/chatty-mice-join.md b/.changeset/chatty-mice-join.md new file mode 100644 index 0000000000..167b763f2b --- /dev/null +++ b/.changeset/chatty-mice-join.md @@ -0,0 +1,5 @@ +--- +'@urql/vue': minor +--- + +Refactor composable functions with a focus on avoiding memory leaks and Vue best practices diff --git a/packages/vue-urql/src/useClientHandle.ts b/packages/vue-urql/src/useClientHandle.ts index 962f2bed40..60988d8260 100644 --- a/packages/vue-urql/src/useClientHandle.ts +++ b/packages/vue-urql/src/useClientHandle.ts @@ -1,5 +1,4 @@ -import type { DocumentNode } from 'graphql'; -import type { AnyVariables, Client, TypedDocumentNode } from '@urql/core'; +import type { AnyVariables, Client, DocumentInput } from '@urql/core'; import type { WatchStopHandle } from 'vue'; import { getCurrentInstance, onMounted, onBeforeUnmount } from 'vue'; @@ -75,7 +74,7 @@ export interface ClientHandle { * function or when chained in an `async setup()` function. */ useMutation( - query: TypedDocumentNode | DocumentNode | string + query: DocumentInput ): UseMutationResponse; } @@ -153,7 +152,7 @@ export function useClientHandle(): ClientHandle { }, useMutation( - query: TypedDocumentNode | DocumentNode | string + query: DocumentInput ): UseMutationResponse { return callUseMutation(query, client); }, diff --git a/packages/vue-urql/src/useMutation.test.ts b/packages/vue-urql/src/useMutation.test.ts index 9d4e8ea00b..50b7992261 100644 --- a/packages/vue-urql/src/useMutation.test.ts +++ b/packages/vue-urql/src/useMutation.test.ts @@ -1,5 +1,5 @@ import { OperationResult, OperationResultSource } from '@urql/core'; -import { reactive } from 'vue'; +import { readonly } from 'vue'; import { vi, expect, it, beforeEach, describe } from 'vitest'; vi.mock('./useClient.ts', async () => { @@ -30,15 +30,13 @@ describe('useMutation', () => { () => subject.source as OperationResultSource ); - const mutation = reactive( - useMutation(gql` - mutation { - test - } - `) - ); + const mutation = useMutation(gql` + mutation { + test + } + `); - expect(mutation).toMatchObject({ + expect(readonly(mutation)).toMatchObject({ data: undefined, stale: false, fetching: false, @@ -50,18 +48,18 @@ describe('useMutation', () => { const promise = mutation.executeMutation({ test: true }); - expect(mutation.fetching).toBe(true); - expect(mutation.stale).toBe(false); - expect(mutation.error).toBe(undefined); + expect(mutation.fetching.value).toBe(true); + expect(mutation.stale.value).toBe(false); + expect(mutation.error.value).toBe(undefined); expect(clientMutation).toHaveBeenCalledTimes(1); subject.next({ data: { test: true }, stale: false }); - await promise.then(function () { - expect(mutation.fetching).toBe(false); - expect(mutation.stale).toBe(false); - expect(mutation.error).toBe(undefined); - expect(mutation.data).toEqual({ test: true }); - }); + + await promise; + expect(mutation.fetching.value).toBe(false); + expect(mutation.stale.value).toBe(false); + expect(mutation.error.value).toBe(undefined); + expect(mutation.data.value).toHaveProperty('test', true); }); }); diff --git a/packages/vue-urql/src/useMutation.ts b/packages/vue-urql/src/useMutation.ts index e220ddc9f5..74fd65e9c4 100644 --- a/packages/vue-urql/src/useMutation.ts +++ b/packages/vue-urql/src/useMutation.ts @@ -1,24 +1,22 @@ /* eslint-disable react-hooks/rules-of-hooks */ import type { Ref } from 'vue'; -import { ref, shallowRef } from 'vue'; -import type { DocumentNode } from 'graphql'; +import { ref } from 'vue'; import { pipe, onPush, filter, toPromise, take } from 'wonka'; import type { Client, AnyVariables, - TypedDocumentNode, CombinedError, Operation, OperationContext, OperationResult, + DocumentInput, } from '@urql/core'; -import { createRequest } from '@urql/core'; import { useClient } from './useClient'; import type { MaybeRef } from './utils'; -import { unref } from './utils'; +import { createRequestWithArgs, useRequestState } from './utils'; /** State of the last mutation executed by {@link useMutation}. * @@ -126,21 +124,21 @@ export interface UseMutationResponse { * ``` */ export function useMutation( - query: TypedDocumentNode | DocumentNode | string + query: DocumentInput ): UseMutationResponse { return callUseMutation(query); } export function callUseMutation( - query: MaybeRef | DocumentNode | string>, + query: MaybeRef>, client: Ref = useClient() ): UseMutationResponse { const data: Ref = ref(); - const stale: Ref = ref(false); - const fetching: Ref = ref(false); - const error: Ref = shallowRef(); - const operation: Ref | undefined> = shallowRef(); - const extensions: Ref | undefined> = shallowRef(); + + const { fetching, operation, extensions, stale, error } = useRequestState< + T, + V + >(); return { data, @@ -157,7 +155,7 @@ export function callUseMutation( return pipe( client.value.executeMutation( - createRequest(unref(query), unref(variables)), + createRequestWithArgs({ query, variables }), context || {} ), onPush(result => { diff --git a/packages/vue-urql/src/useQuery.test.ts b/packages/vue-urql/src/useQuery.test.ts index 62abb2d90c..a28bf0d6c5 100644 --- a/packages/vue-urql/src/useQuery.test.ts +++ b/packages/vue-urql/src/useQuery.test.ts @@ -1,5 +1,9 @@ -import { OperationResult, OperationResultSource } from '@urql/core'; -import { nextTick, reactive, ref } from 'vue'; +import { + OperationResult, + OperationResultSource, + RequestPolicy, +} from '@urql/core'; +import { computed, nextTick, reactive, readonly, ref } from 'vue'; import { vi, expect, it, describe } from 'vitest'; vi.mock('./useClient.ts', async () => ({ @@ -10,10 +14,28 @@ vi.mock('./useClient.ts', async () => ({ import { pipe, makeSubject, fromValue, delay } from 'wonka'; import { createClient } from '@urql/core'; -import { useQuery } from './useQuery'; +import { useQuery, UseQueryArgs } from './useQuery'; const client = createClient({ url: '/graphql', exchanges: [] }); +const createQuery = (args: UseQueryArgs) => { + const executeQuery = vi + .spyOn(client, 'executeQuery') + .mockImplementation(request => { + return pipe( + fromValue({ operation: request, data: { test: true } }), + delay(1) + ) as any; + }); + + const query$ = useQuery(args); + + return { + query$, + executeQuery, + }; +}; + describe('useQuery', () => { it('runs a query and updates data', async () => { const subject = makeSubject(); @@ -23,12 +45,11 @@ describe('useQuery', () => { () => subject.source as OperationResultSource ); - const _query = useQuery({ + const query = useQuery({ query: `{ test }`, }); - const query = reactive(_query); - expect(query).toMatchObject({ + expect(readonly(query)).toMatchObject({ data: undefined, stale: false, fetching: true, @@ -54,12 +75,12 @@ describe('useQuery', () => { } ); - expect(query.fetching).toBe(true); + expect(query.fetching.value).toBe(true); subject.next({ data: { test: true } }); - expect(query.fetching).toBe(false); - expect(query.data).toEqual({ test: true }); + expect(query.fetching.value).toBe(false); + expect(query.data.value).toHaveProperty('test', true); }); it('runs queries as a promise-like that resolves when used', async () => { @@ -79,18 +100,9 @@ describe('useQuery', () => { }); it('runs queries as a promise-like that resolves even when the query changes', async () => { - const executeQuery = vi - .spyOn(client, 'executeQuery') - .mockImplementation(request => { - return pipe( - fromValue({ operation: request, data: { test: true } }), - delay(1) - ) as any; - }); - const doc = ref('{ test }'); - const query$ = useQuery({ + const { executeQuery, query$ } = createQuery({ query: doc, }); @@ -108,36 +120,239 @@ describe('useQuery', () => { ); }); - it('reacts to variables changing', async () => { - const executeQuery = vi - .spyOn(client, 'executeQuery') - .mockImplementation(request => { - return pipe( - fromValue({ operation: request, data: { test: true } }), - delay(1) - ) as any; - }); + it('runs a query with different variables', async () => { + const simpleVariables = { + null: null, + NaN: NaN, + empty: '', + bool: false, + int: 1, + float: 1.1, + string: 'string', + blob: new Blob(), + date: new Date(), + }; - const variables = { - test: ref(1), + const variablesSet = { + func: () => 'func', + ref: ref('ref'), + computed: computed(() => 'computed'), + ...simpleVariables, }; - const query$ = useQuery({ - query: '{ test }', + + const variablesSetUnwrapped = { + func: 'func', + ref: 'ref', + computed: 'computed', + ...simpleVariables, + }; + + const { query$ } = createQuery({ + query: ref('{ test }'), + variables: { + ...variablesSet, + nested: variablesSet, + array: [variablesSet], + }, + }); + + await query$; + + expect(query$.operation.value?.variables).toStrictEqual({ + ...variablesSetUnwrapped, + nested: variablesSetUnwrapped, + array: [variablesSetUnwrapped], + }); + }); + + it('reacts to ref variables changing', async () => { + const variables = ref({ prop: 1 }); + + const { executeQuery, query$ } = createQuery({ + query: ref('{ test }'), variables, }); await query$; + expect(executeQuery).toHaveBeenCalledTimes(1); + expect(query$.operation.value).toHaveProperty('variables.prop', 1); + + variables.value.prop++; + await query$; + expect(executeQuery).toHaveBeenCalledTimes(2); + expect(query$.operation.value).toHaveProperty('variables.prop', 2); + + variables.value = { prop: 3 }; + await query$; + expect(executeQuery).toHaveBeenCalledTimes(3); + expect(query$.operation.value).toHaveProperty('variables.prop', 3); + }); + it('reacts to nested ref variables changing', async () => { + const prop = ref(1); + + const { executeQuery, query$ } = createQuery({ + query: ref('{ test }'), + variables: { prop }, + }); + + await query$; expect(executeQuery).toHaveBeenCalledTimes(1); + expect(query$.operation.value).toHaveProperty('variables.prop', 1); + + prop.value++; + await query$; + expect(executeQuery).toHaveBeenCalledTimes(2); + expect(query$.operation.value).toHaveProperty('variables.prop', 2); + }); - expect(query$.operation.value).toHaveProperty('variables.test', 1); + it('reacts to deep nested ref variables changing', async () => { + const prop = ref(1); - variables.test.value = 2; + const { executeQuery, query$ } = createQuery({ + query: ref('{ test }'), + variables: { deep: { nested: { prop } } }, + }); await query$; + expect(executeQuery).toHaveBeenCalledTimes(1); + expect(query$.operation.value).toHaveProperty( + 'variables.deep.nested.prop', + 1 + ); + prop.value++; + await query$; + expect(executeQuery).toHaveBeenCalledTimes(2); + expect(query$.operation.value).toHaveProperty( + 'variables.deep.nested.prop', + 2 + ); + }); + + it('reacts to reactive variables changing', async () => { + const prop = ref(1); + const variables = reactive({ prop: 1, deep: { nested: { prop } } }); + + const { executeQuery, query$ } = createQuery({ + query: ref('{ test }'), + variables, + }); + + await query$; + expect(executeQuery).toHaveBeenCalledTimes(1); + expect(query$.operation.value).toHaveProperty('variables.prop', 1); + + variables.prop++; + await query$; + expect(executeQuery).toHaveBeenCalledTimes(2); + expect(query$.operation.value).toHaveProperty('variables.prop', 2); + + prop.value++; + await query$; + expect(executeQuery).toHaveBeenCalledTimes(3); + expect(query$.operation.value).toHaveProperty( + 'variables.deep.nested.prop', + 2 + ); + }); + + it('reacts to computed variables changing', async () => { + const prop = ref(1); + const prop2 = ref(1); + const variables = computed(() => ({ + prop: prop.value, + deep: { nested: { prop2 } }, + })); + + const { executeQuery, query$ } = createQuery({ + query: ref('{ test }'), + variables, + }); + + await query$; + expect(executeQuery).toHaveBeenCalledTimes(1); + expect(query$.operation.value).toHaveProperty('variables.prop', 1); + + prop.value++; + await query$; + expect(executeQuery).toHaveBeenCalledTimes(2); + expect(query$.operation.value).toHaveProperty('variables.prop', 2); + + prop2.value++; + await query$; + expect(executeQuery).toHaveBeenCalledTimes(3); + expect(query$.operation.value).toHaveProperty( + 'variables.deep.nested.prop2', + 2 + ); + }); + + it('reacts to callback variables changing', async () => { + const prop = ref(1); + const prop2 = ref(1); + const variables = () => ({ + prop: prop.value, + deep: { nested: { prop2 } }, + }); + + const { executeQuery, query$ } = createQuery({ + query: ref('{ test }'), + variables, + }); + + await query$; + expect(executeQuery).toHaveBeenCalledTimes(1); + expect(query$.operation.value).toHaveProperty('variables.prop', 1); + + prop.value++; + await query$; + expect(executeQuery).toHaveBeenCalledTimes(2); + expect(query$.operation.value).toHaveProperty('variables.prop', 2); + + prop2.value++; + await query$; + expect(executeQuery).toHaveBeenCalledTimes(3); + expect(query$.operation.value).toHaveProperty( + 'variables.deep.nested.prop2', + 2 + ); + }); + + it('reacts to reactive context argument', async () => { + const context = ref<{ requestPolicy: RequestPolicy }>({ + requestPolicy: 'cache-only', + }); + + const { executeQuery, query$ } = createQuery({ + query: ref('{ test }'), + context, + }); + + await query$; + expect(executeQuery).toHaveBeenCalledTimes(1); + + context.value.requestPolicy = 'network-only'; + await query$; + expect(executeQuery).toHaveBeenCalledTimes(2); + }); + + it('reacts to callback context argument', async () => { + const requestPolicy = ref('cache-only'); + + const { executeQuery, query$ } = createQuery({ + query: ref('{ test }'), + context: () => ({ + requestPolicy: requestPolicy.value, + }), + }); + + await query$; + expect(executeQuery).toHaveBeenCalledTimes(1); + + requestPolicy.value = 'network-only'; + await query$; expect(executeQuery).toHaveBeenCalledTimes(2); - expect(query$.operation.value).toHaveProperty('variables.test', 2); }); it('pauses query when asked to do so', async () => { @@ -148,23 +363,86 @@ describe('useQuery', () => { () => subject.source as OperationResultSource ); - const pause = ref(true); - - const _query = useQuery({ + const query = useQuery({ query: `{ test }`, - pause: () => pause.value, + pause: true, }); - const query = reactive(_query); expect(executeQuery).not.toHaveBeenCalled(); - pause.value = false; + query.resume(); await nextTick(); - expect(query.fetching).toBe(true); + expect(query.fetching.value).toBe(true); subject.next({ data: { test: true } }); - expect(query.fetching).toBe(false); - expect(query.data).toEqual({ test: true }); + expect(query.fetching.value).toBe(false); + expect(query.data.value).toHaveProperty('test', true); + }); + + it('pauses query with ref variable', async () => { + const pause = ref(true); + + const { executeQuery, query$ } = createQuery({ + query: ref('{ test }'), + pause, + }); + + await query$; + expect(executeQuery).not.toHaveBeenCalled(); + + pause.value = false; + await query$; + expect(executeQuery).toHaveBeenCalledTimes(1); + + query$.pause(); + query$.resume(); + await query$; + expect(executeQuery).toHaveBeenCalledTimes(2); + }); + + it('pauses query with computed variable', async () => { + const pause = ref(true); + + const { executeQuery, query$ } = createQuery({ + query: ref('{ test }'), + pause: computed(() => pause.value), + }); + + await query$; + expect(executeQuery).not.toHaveBeenCalled(); + + pause.value = false; + await query$; + expect(executeQuery).toHaveBeenCalledTimes(1); + + query$.pause(); + query$.resume(); + await query$; + // this shouldn't be called, as pause/resume functionality should works in sync with passed `pause` variable, e.g.: + // if we pass readonly computed variable, then we want to make sure that its value fully controls the state of the request. + expect(executeQuery).toHaveBeenCalledTimes(1); + }); + + it('pauses query with callback', async () => { + const pause = ref(true); + + const { executeQuery, query$ } = createQuery({ + query: ref('{ test }'), + pause: () => pause.value, + }); + + await query$; + expect(executeQuery).not.toHaveBeenCalled(); + + pause.value = false; + await query$; + expect(executeQuery).toHaveBeenCalledTimes(1); + + query$.pause(); + query$.resume(); + await query$; + // the same as computed variable example - user has full control over the request state if using callback + expect(executeQuery).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/vue-urql/src/useQuery.ts b/packages/vue-urql/src/useQuery.ts index 102edf4310..b06fde3edd 100644 --- a/packages/vue-urql/src/useQuery.ts +++ b/packages/vue-urql/src/useQuery.ts @@ -1,27 +1,25 @@ /* eslint-disable react-hooks/rules-of-hooks */ import type { Ref, WatchStopHandle } from 'vue'; -import { isRef, ref, shallowRef, watch, watchEffect, reactive } from 'vue'; +import { ref, watchEffect } from 'vue'; -import type { Subscription, Source } from 'wonka'; +import type { Subscription } from 'wonka'; import { pipe, subscribe, onEnd } from 'wonka'; import type { Client, AnyVariables, - OperationResult, GraphQLRequestParams, CombinedError, OperationContext, RequestPolicy, Operation, } from '@urql/core'; -import { createRequest } from '@urql/core'; import { useClient } from './useClient'; import type { MaybeRef, MaybeRefObj } from './utils'; -import { unref, updateShallowRef } from './utils'; +import { useRequestState, useClientState } from './utils'; /** Input arguments for the {@link useQuery} function. * @@ -195,10 +193,6 @@ export type UseQueryResponse< V extends AnyVariables = AnyVariables, > = UseQueryState & PromiseLike>; -const watchOptions = { - flush: 'pre' as const, -}; - /** Function to run a GraphQL query and get reactive GraphQL results. * * @param args - a {@link UseQueryArgs} object, to pass a `query`, `variables`, and options. @@ -241,55 +235,83 @@ export function useQuery( } export function callUseQuery( - _args: UseQueryArgs, + args: UseQueryArgs, client: Ref = useClient(), - stops: WatchStopHandle[] = [] + stops?: WatchStopHandle[] ): UseQueryResponse { - const args = reactive(_args) as UseQueryArgs; - const data: Ref = ref(); - const stale: Ref = ref(false); - const fetching: Ref = ref(false); - const error: Ref = shallowRef(); - const operation: Ref | undefined> = shallowRef(); - const extensions: Ref | undefined> = shallowRef(); - const isPaused: Ref = ref(!!unref(args.pause)); - if (isRef(args.pause) || typeof args.pause === 'function') { - stops.push(watch(args.pause, value => (isPaused.value = value))); - } + const { fetching, operation, extensions, stale, error } = useRequestState< + T, + V + >(); - const input = shallowRef({ - request: createRequest(unref(args.query), unref(args.variables) as V), - requestPolicy: unref(args.requestPolicy), - isPaused: isPaused.value, - }); + const { isPaused, source, pause, resume, execute, teardown } = useClientState( + args, + client, + 'executeQuery' + ); - const source: Ref> | undefined> = ref(); + const teardownQuery = watchEffect( + onInvalidate => { + if (source.value) { + fetching.value = true; + stale.value = false; - stops.push( - watchEffect(() => { - updateShallowRef(input, { - request: createRequest( - unref(args.query), - unref(args.variables) as V - ), - requestPolicy: unref(args.requestPolicy), - isPaused: isPaused.value, - }); - }, watchOptions) + onInvalidate( + pipe( + source.value, + onEnd(() => { + fetching.value = false; + stale.value = false; + }), + subscribe(res => { + data.value = res.data; + stale.value = !!res.stale; + fetching.value = false; + error.value = res.error; + operation.value = res.operation; + extensions.value = res.extensions; + }) + ).unsubscribe + ); + } else { + fetching.value = false; + stale.value = false; + } + }, + { + // NOTE: This part of the query pipeline is only initialised once and will need + // to do so synchronously + flush: 'sync', + } ); - stops.push( - watchEffect(() => { - source.value = !input.value.isPaused - ? client.value.executeQuery(input.value.request, { - requestPolicy: unref(args.requestPolicy), - ...unref(args.context), - }) - : undefined; - }, watchOptions) - ); + stops && stops.push(teardown, teardownQuery); + + const then: UseQueryResponse['then'] = (onFulfilled, onRejected) => { + let sub: Subscription | void; + + const promise = new Promise>(resolve => { + if (!source.value) { + return resolve(state); + } + let hasResult = false; + sub = pipe( + source.value, + subscribe(() => { + if (!state.fetching.value && !state.stale.value) { + if (sub) sub.unsubscribe(); + hasResult = true; + resolve(state); + } + }) + ); + if (hasResult) sub.unsubscribe(); + }); + + return promise.then(onFulfilled, onRejected); + }; const state: UseQueryState = { data, @@ -299,105 +321,13 @@ export function callUseQuery( extensions, fetching, isPaused, + pause, + resume, executeQuery(opts?: Partial): UseQueryResponse { - const s = (source.value = client.value.executeQuery( - input.value.request, - { - requestPolicy: unref(args.requestPolicy), - ...unref(args.context), - ...opts, - } - )); - - return { - ...response, - then(onFulfilled, onRejected) { - let sub: Subscription | void; - return new Promise>(resolve => { - let hasResult = false; - sub = pipe( - s, - subscribe(() => { - if (!state.fetching.value && !state.stale.value) { - if (sub) sub.unsubscribe(); - hasResult = true; - resolve(state); - } - }) - ); - if (hasResult) sub.unsubscribe(); - }).then(onFulfilled, onRejected); - }, - }; - }, - pause() { - isPaused.value = true; - }, - resume() { - isPaused.value = false; - }, - }; - - stops.push( - watchEffect( - onInvalidate => { - if (source.value) { - fetching.value = true; - stale.value = false; - - onInvalidate( - pipe( - source.value, - onEnd(() => { - fetching.value = false; - stale.value = false; - }), - subscribe(res => { - data.value = res.data; - stale.value = !!res.stale; - fetching.value = false; - error.value = res.error; - operation.value = res.operation; - extensions.value = res.extensions; - }) - ).unsubscribe - ); - } else { - fetching.value = false; - stale.value = false; - } - }, - { - // NOTE: This part of the query pipeline is only initialised once and will need - // to do so synchronously - flush: 'sync', - } - ) - ); - - const response: UseQueryResponse = { - ...state, - then(onFulfilled, onRejected) { - let sub: Subscription | void; - const promise = new Promise>(resolve => { - if (!source.value) return resolve(state); - let hasResult = false; - sub = pipe( - source.value, - subscribe(() => { - if (!state.fetching.value && !state.stale.value) { - if (sub) sub.unsubscribe(); - hasResult = true; - resolve(state); - } - }) - ); - if (hasResult) sub.unsubscribe(); - }); - - return promise.then(onFulfilled, onRejected); + execute(opts); + return { ...state, then }; }, }; - return response; + return { ...state, then }; } diff --git a/packages/vue-urql/src/useSubscription.test.ts b/packages/vue-urql/src/useSubscription.test.ts index 67375b388f..232e0ce34b 100644 --- a/packages/vue-urql/src/useSubscription.test.ts +++ b/packages/vue-urql/src/useSubscription.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { OperationResult, OperationResultSource } from '@urql/core'; -import { nextTick, reactive, ref } from 'vue'; +import { nextTick, readonly, ref } from 'vue'; import { vi, expect, it, describe } from 'vitest'; vi.mock('./useClient.ts', async () => ({ @@ -25,13 +25,11 @@ describe('useSubscription', () => { () => subject.source as OperationResultSource ); - const sub = reactive( - useSubscription({ - query: `{ test }`, - }) - ); + const sub = useSubscription({ + query: `{ test }`, + }); - expect(sub).toMatchObject({ + expect(readonly(sub)).toMatchObject({ data: undefined, stale: false, fetching: true, @@ -53,12 +51,13 @@ describe('useSubscription', () => { expect.any(Object) ); - expect(sub.fetching).toBe(true); + expect(sub.fetching.value).toBe(true); subject.next({ data: { test: true } }); - expect(sub.data).toEqual({ test: true }); + expect(sub.data.value).toHaveProperty('test', true); + subject.complete(); - expect(sub.fetching).toBe(false); + expect(sub.fetching.value).toBe(false); }); it('updates the executed subscription when inputs change', async () => { @@ -70,12 +69,10 @@ describe('useSubscription', () => { ); const variables = ref({}); - const sub = reactive( - useSubscription({ - query: `{ test }`, - variables, - }) - ); + const sub = useSubscription({ + query: `{ test }`, + variables, + }); expect(executeSubscription).toHaveBeenCalledWith( { @@ -87,7 +84,7 @@ describe('useSubscription', () => { ); subject.next({ data: { test: true } }); - expect(sub.data).toEqual({ test: true }); + expect(sub.data.value).toHaveProperty('test', true); variables.value = { test: true }; await nextTick(); @@ -101,9 +98,10 @@ describe('useSubscription', () => { expect.any(Object) ); - expect(sub.fetching).toBe(true); - expect(sub.data).toEqual({ test: true }); + expect(sub.fetching.value).toBe(true); + expect(sub.data.value).toHaveProperty('test', true); }); + it('supports a custom scanning handler', async () => { const subject = makeSubject(); const executeSubscription = vi @@ -115,13 +113,12 @@ describe('useSubscription', () => { const scanHandler = (currentState: any, nextState: any) => ({ counter: (currentState ? currentState.counter : 0) + nextState.counter, }); - const sub = reactive( - useSubscription( - { - query: `subscription { counter }`, - }, - scanHandler - ) + + const sub = useSubscription( + { + query: `subscription { counter }`, + }, + scanHandler ); expect(executeSubscription).toHaveBeenCalledWith( @@ -134,9 +131,9 @@ describe('useSubscription', () => { ); subject.next({ data: { counter: 1 } }); - expect(sub.data).toEqual({ counter: 1 }); + expect(sub.data.value).toHaveProperty('counter', 1); subject.next({ data: { counter: 2 } }); - expect(sub.data).toEqual({ counter: 3 }); + expect(sub.data.value).toHaveProperty('counter', 3); }); }); diff --git a/packages/vue-urql/src/useSubscription.ts b/packages/vue-urql/src/useSubscription.ts index c6d0545cfb..084d7586e9 100644 --- a/packages/vue-urql/src/useSubscription.ts +++ b/packages/vue-urql/src/useSubscription.ts @@ -1,26 +1,23 @@ /* eslint-disable react-hooks/rules-of-hooks */ -import type { Source } from 'wonka'; import { pipe, subscribe, onEnd } from 'wonka'; import type { Ref, WatchStopHandle } from 'vue'; -import { isRef, ref, shallowRef, watch, watchEffect, reactive } from 'vue'; +import { isRef, ref, watchEffect } from 'vue'; import type { Client, GraphQLRequestParams, AnyVariables, - OperationResult, CombinedError, OperationContext, Operation, } from '@urql/core'; -import { createRequest } from '@urql/core'; import { useClient } from './useClient'; import type { MaybeRef, MaybeRefObj } from './utils'; -import { unref, updateShallowRef } from './utils'; +import { useRequestState, useClientState } from './utils'; /** Input arguments for the {@link useSubscription} function. * @@ -89,7 +86,9 @@ export type UseSubscriptionArgs< export type SubscriptionHandler = (prev: R | undefined, data: T) => R; /** A {@link SubscriptionHandler} or a reactive ref of one. */ -export type SubscriptionHandlerArg = MaybeRef>; +export type SubscriptionHandlerArg = + | Ref> + | SubscriptionHandler; /** State of the current query, your {@link useSubscription} function is executing. * @@ -182,10 +181,6 @@ export interface UseSubscriptionResponse< executeSubscription(opts?: Partial): void; } -const watchOptions = { - flush: 'pre' as const, -}; - /** Function to run a GraphQL subscription and get reactive GraphQL results. * * @param args - a {@link UseSubscriptionArgs} object, to pass a `query`, `variables`, and options. @@ -229,7 +224,7 @@ export function useSubscription< V extends AnyVariables = AnyVariables, >( args: UseSubscriptionArgs, - handler?: MaybeRef> + handler?: SubscriptionHandlerArg ): UseSubscriptionResponse { return callUseSubscription(args, handler); } @@ -239,86 +234,58 @@ export function callUseSubscription< R = T, V extends AnyVariables = AnyVariables, >( - _args: UseSubscriptionArgs, - handler?: MaybeRef>, + args: UseSubscriptionArgs, + handler?: SubscriptionHandlerArg, client: Ref = useClient(), - stops: WatchStopHandle[] = [] + stops?: WatchStopHandle[] ): UseSubscriptionResponse { - const args = reactive(_args) as UseSubscriptionArgs; - const data: Ref = ref(); - const stale: Ref = ref(false); - const fetching: Ref = ref(false); - const error: Ref = shallowRef(); - const operation: Ref | undefined> = shallowRef(); - const extensions: Ref | undefined> = shallowRef(); - const scanHandler = ref(handler); - const isPaused: Ref = ref(!!unref(args.pause)); - if (isRef(args.pause) || typeof args.pause === 'function') { - stops.push(watch(args.pause, value => (isPaused.value = value))); - } + const { fetching, operation, extensions, stale, error } = useRequestState< + T, + V + >(); - const input = shallowRef({ - request: createRequest(unref(args.query), unref(args.variables) as V), - isPaused: isPaused.value, - }); + const { isPaused, source, pause, resume, execute, teardown } = useClientState( + args, + client, + 'executeSubscription' + ); - const source: Ref> | undefined> = ref(); + const teardownSubscription = watchEffect(onInvalidate => { + if (source.value) { + fetching.value = true; - stops.push( - watchEffect(() => { - updateShallowRef(input, { - request: createRequest( - unref(args.query), - unref(args.variables) as V - ), - isPaused: isPaused.value, - }); - }, watchOptions) - ); + onInvalidate( + pipe( + source.value, + onEnd(() => { + fetching.value = false; + }), + subscribe(result => { + fetching.value = true; + error.value = result.error; + extensions.value = result.extensions; + stale.value = !!result.stale; + operation.value = result.operation; - stops.push( - watchEffect(() => { - source.value = !isPaused.value - ? client.value.executeSubscription(input.value.request, { - ...unref(args.context), + if (result.data != null && handler) { + const cb = isRef(handler) ? handler.value : handler; + if (typeof cb === 'function') { + data.value = cb(data.value, result.data); + return; + } + } + data.value = result.data as R; }) - : undefined; - }, watchOptions) - ); - - stops.push( - watchEffect(onInvalidate => { - if (source.value) { - fetching.value = true; + ).unsubscribe + ); + } else { + fetching.value = false; + } + }); - onInvalidate( - pipe( - source.value, - onEnd(() => { - fetching.value = false; - }), - subscribe(result => { - fetching.value = true; - data.value = - result.data != null - ? typeof scanHandler.value === 'function' - ? scanHandler.value(data.value as any, result.data) - : result.data - : (result.data as any); - error.value = result.error; - extensions.value = result.extensions; - stale.value = !!result.stale; - operation.value = result.operation; - }) - ).unsubscribe - ); - } else { - fetching.value = false; - } - }, watchOptions) - ); + stops && stops.push(teardown, teardownSubscription); const state: UseSubscriptionResponse = { data, @@ -328,25 +295,14 @@ export function callUseSubscription< extensions, fetching, isPaused, + pause, + resume, executeSubscription( opts?: Partial ): UseSubscriptionResponse { - source.value = client.value.executeSubscription( - input.value.request, - { - ...unref(args.context), - ...opts, - } - ); - + execute(opts); return state; }, - pause() { - isPaused.value = true; - }, - resume() { - isPaused.value = false; - }, }; return state; diff --git a/packages/vue-urql/src/utils.ts b/packages/vue-urql/src/utils.ts index a9a83cdac9..a7806bddd2 100644 --- a/packages/vue-urql/src/utils.ts +++ b/packages/vue-urql/src/utils.ts @@ -1,45 +1,171 @@ -import type { GraphQLRequest, AnyVariables } from '@urql/core'; -import type { Ref, ShallowRef } from 'vue'; -import { isRef } from 'vue'; +import type { + AnyVariables, + Client, + CombinedError, + DocumentInput, + Operation, + OperationContext, + OperationResult, + OperationResultSource, +} from '@urql/core'; +import { createRequest } from '@urql/core'; +import type { Ref } from 'vue'; +import { watchEffect, isReadonly, computed, ref, shallowRef, isRef } from 'vue'; +import type { UseSubscriptionArgs } from './useSubscription'; +import type { UseQueryArgs } from './useQuery'; export type MaybeRef = T | (() => T) | Ref; export type MaybeRefObj = T extends {} ? { [K in keyof T]: MaybeRef } : T; -export const unref = (maybeRef: MaybeRef): T => +const unwrap = (maybeRef: MaybeRef): T => typeof maybeRef === 'function' ? (maybeRef as () => T)() : maybeRef != null && isRef(maybeRef) ? maybeRef.value : maybeRef; -export interface RequestState< - Data = any, - Variables extends AnyVariables = AnyVariables, -> { - request: GraphQLRequest; - isPaused: boolean; -} +const isPlainObject = (value: any): boolean => { + if (typeof value !== 'object' || value === null) return false; + return ( + value.constructor && + Object.getPrototypeOf(value).constructor === Object.prototype.constructor + ); +}; +export const isArray = Array.isArray; -export function createRequestState< - Data = any, - Variables extends AnyVariables = AnyVariables, ->( - request: GraphQLRequest, - isPaused: boolean -): RequestState { - return { request, isPaused }; -} +const unwrapDeeply = (input: T): T => { + input = isRef(input) ? (input.value as T) : input; -export const updateShallowRef = >( - ref: ShallowRef, - next: T -) => { - for (const key in next) { - if (ref.value[key] !== next[key]) { - ref.value = next; - return; + if (typeof input === 'function') { + return unwrapDeeply(input()) as T; + } + + if (input && typeof input === 'object') { + if (isArray(input)) { + const length = input.length; + const out = new Array(length) as T; + let i = 0; + for (; i < length; i++) { + out[i] = unwrapDeeply(input[i]); + } + + return out; + } else if (isPlainObject(input)) { + const keys = Object.keys(input); + const length = keys.length; + let i = 0; + let key: string; + const out = {} as T; + + for (; i < length; i++) { + key = keys[i]; + out[key] = unwrapDeeply(input[key]); + } + + return out; } } + + return input; +}; + +export const createRequestWithArgs = < + T = any, + V extends AnyVariables = AnyVariables, +>( + args: + | UseQueryArgs + | UseSubscriptionArgs + | { query: MaybeRef>; variables: V } +) => { + return createRequest( + unwrap(args.query), + unwrapDeeply(args.variables) as V + ); }; + +export const useRequestState = < + T = any, + V extends AnyVariables = AnyVariables, +>() => { + const stale: Ref = ref(false); + const fetching: Ref = ref(false); + const error: Ref = shallowRef(); + const operation: Ref | undefined> = shallowRef(); + const extensions: Ref | undefined> = shallowRef(); + return { + stale, + fetching, + error, + operation, + extensions, + }; +}; + +export function useClientState( + args: UseQueryArgs | UseSubscriptionArgs, + client: Ref, + method: keyof Pick +) { + const source: Ref> | undefined> = + shallowRef(); + + const isPaused: Ref = isRef(args.pause) + ? args.pause + : typeof args.pause === 'function' + ? computed(args.pause) + : ref(!!args.pause); + + const request = computed(() => createRequestWithArgs(args)); + + const requestOptions = computed(() => { + return 'requestPolicy' in args + ? { + requestPolicy: unwrap(args.requestPolicy), + ...unwrap(args.context), + } + : { + ...unwrap(args.context), + }; + }); + + const pause = () => { + if (!isReadonly(isPaused)) { + isPaused.value = true; + } + }; + + const resume = () => { + if (!isReadonly(isPaused)) { + isPaused.value = false; + } + }; + + const executeRaw = (opts?: Partial) => { + return client.value[method](request.value, { + ...requestOptions.value, + ...opts, + }); + }; + + const execute = (opts?: Partial) => { + source.value = executeRaw(opts); + }; + + // it's important to use `watchEffect()` here instead of `watch()` + // because it listening for reactive variables inside `executeRaw()` function + const teardown = watchEffect(() => { + source.value = !isPaused.value ? executeRaw() : undefined; + }); + + return { + source, + isPaused, + pause, + resume, + execute, + teardown, + }; +}