Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.

feat(vue): refactor composable functions (#3619)

+5
.changeset/chatty-mice-join.md
···
+
---
+
'@urql/vue': minor
+
---
+
+
Refactor composable functions with a focus on avoiding memory leaks and Vue best practices
+3 -4
packages/vue-urql/src/useClientHandle.ts
···
-
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';
···
* function or when chained in an `async setup()` function.
*/
useMutation<T = any, V extends AnyVariables = AnyVariables>(
-
query: TypedDocumentNode<T, V> | DocumentNode | string
+
query: DocumentInput<T, V>
): UseMutationResponse<T, V>;
}
···
},
useMutation<T = any, V extends AnyVariables = AnyVariables>(
-
query: TypedDocumentNode<T, V> | DocumentNode | string
+
query: DocumentInput<T, V>
): UseMutationResponse<T, V> {
return callUseMutation(query, client);
},
+16 -18
packages/vue-urql/src/useMutation.test.ts
···
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 () => {
···
() => subject.source as OperationResultSource<OperationResult>
);
-
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,
···
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);
});
});
+11 -13
packages/vue-urql/src/useMutation.ts
···
/* 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}.
*
···
* ```
*/
export function useMutation<T = any, V extends AnyVariables = AnyVariables>(
-
query: TypedDocumentNode<T, V> | DocumentNode | string
+
query: DocumentInput<T, V>
): UseMutationResponse<T, V> {
return callUseMutation(query);
}
export function callUseMutation<T = any, V extends AnyVariables = AnyVariables>(
-
query: MaybeRef<TypedDocumentNode<T, V> | DocumentNode | string>,
+
query: MaybeRef<DocumentInput<T, V>>,
client: Ref<Client> = useClient()
): UseMutationResponse<T, V> {
const data: Ref<T | undefined> = ref();
-
const stale: Ref<boolean> = ref(false);
-
const fetching: Ref<boolean> = ref(false);
-
const error: Ref<CombinedError | undefined> = shallowRef();
-
const operation: Ref<Operation<T, V> | undefined> = shallowRef();
-
const extensions: Ref<Record<string, any> | undefined> = shallowRef();
+
+
const { fetching, operation, extensions, stale, error } = useRequestState<
+
T,
+
V
+
>();
return {
data,
···
return pipe(
client.value.executeMutation<T, V>(
-
createRequest<T, V>(unref(query), unref(variables)),
+
createRequestWithArgs({ query, variables }),
context || {}
),
onPush(result => {
+322 -44
packages/vue-urql/src/useQuery.test.ts
···
-
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 () => ({
···
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<any>();
···
() => subject.source as OperationResultSource<OperationResult>
);
-
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,
···
}
);
-
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 () => {
···
});
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,
});
···
);
});
-
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 variablesSetUnwrapped = {
+
func: 'func',
+
ref: 'ref',
+
computed: 'computed',
+
...simpleVariables,
};
-
const query$ = useQuery({
-
query: '{ test }',
+
+
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);
-
expect(query$.operation.value).toHaveProperty('variables.test', 1);
+
prop.value++;
+
await query$;
+
expect(executeQuery).toHaveBeenCalledTimes(2);
+
expect(query$.operation.value).toHaveProperty('variables.prop', 2);
+
});
-
variables.test.value = 2;
+
it('reacts to deep nested ref variables changing', async () => {
+
const prop = ref(1);
+
+
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.test', 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<RequestPolicy>('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);
});
it('pauses query when asked to do so', async () => {
···
() => subject.source as OperationResultSource<OperationResult>
);
+
const query = useQuery({
+
query: `{ test }`,
+
pause: true,
+
});
+
+
expect(executeQuery).not.toHaveBeenCalled();
+
+
query.resume();
+
await nextTick();
+
expect(query.fetching.value).toBe(true);
+
+
subject.next({ data: { 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 _query = useQuery({
-
query: `{ test }`,
+
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,
});
-
const query = reactive(_query);
+
await query$;
expect(executeQuery).not.toHaveBeenCalled();
pause.value = false;
-
await nextTick();
-
expect(query.fetching).toBe(true);
+
await query$;
+
expect(executeQuery).toHaveBeenCalledTimes(1);
-
subject.next({ data: { test: true } });
-
-
expect(query.fetching).toBe(false);
-
expect(query.data).toEqual({ test: true });
+
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);
});
});
+76 -146
packages/vue-urql/src/useQuery.ts
···
/* 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.
*
···
V extends AnyVariables = AnyVariables,
> = UseQueryState<T, V> & PromiseLike<UseQueryState<T, V>>;
-
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.
···
}
export function callUseQuery<T = any, V extends AnyVariables = AnyVariables>(
-
_args: UseQueryArgs<T, V>,
+
args: UseQueryArgs<T, V>,
client: Ref<Client> = useClient(),
-
stops: WatchStopHandle[] = []
+
stops?: WatchStopHandle[]
): UseQueryResponse<T, V> {
-
const args = reactive(_args) as UseQueryArgs<T, V>;
-
const data: Ref<T | undefined> = ref();
-
const stale: Ref<boolean> = ref(false);
-
const fetching: Ref<boolean> = ref(false);
-
const error: Ref<CombinedError | undefined> = shallowRef();
-
const operation: Ref<Operation<T, V> | undefined> = shallowRef();
-
const extensions: Ref<Record<string, any> | undefined> = shallowRef();
-
const isPaused: Ref<boolean> = 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<T, V>(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<Source<OperationResult<T, V>> | undefined> = ref();
+
const teardownQuery = watchEffect(
+
onInvalidate => {
+
if (source.value) {
+
fetching.value = true;
+
stale.value = false;
-
stops.push(
-
watchEffect(() => {
-
updateShallowRef(input, {
-
request: createRequest<T, V>(
-
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<T, V>(input.value.request, {
-
requestPolicy: unref(args.requestPolicy),
-
...unref(args.context),
-
})
-
: undefined;
-
}, watchOptions)
-
);
+
stops && stops.push(teardown, teardownQuery);
+
+
const then: UseQueryResponse<T, V>['then'] = (onFulfilled, onRejected) => {
+
let sub: Subscription | void;
+
+
const promise = new Promise<UseQueryState<T, V>>(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<T, V> = {
data,
···
extensions,
fetching,
isPaused,
+
pause,
+
resume,
executeQuery(opts?: Partial<OperationContext>): UseQueryResponse<T, V> {
-
const s = (source.value = client.value.executeQuery<T, V>(
-
input.value.request,
-
{
-
requestPolicy: unref(args.requestPolicy),
-
...unref(args.context),
-
...opts,
-
}
-
));
-
-
return {
-
...response,
-
then(onFulfilled, onRejected) {
-
let sub: Subscription | void;
-
return new Promise<UseQueryState<T, V>>(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;
+
execute(opts);
+
return { ...state, then };
},
};
-
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<T, V> = {
-
...state,
-
then(onFulfilled, onRejected) {
-
let sub: Subscription | void;
-
const promise = new Promise<UseQueryState<T, V>>(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);
-
},
-
};
-
-
return response;
+
return { ...state, then };
}
+25 -28
packages/vue-urql/src/useSubscription.test.ts
···
// @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 () => ({
···
() => subject.source as OperationResultSource<OperationResult>
);
-
const sub = reactive(
-
useSubscription({
-
query: `{ test }`,
-
})
-
);
+
const sub = useSubscription({
+
query: `{ test }`,
+
});
-
expect(sub).toMatchObject({
+
expect(readonly(sub)).toMatchObject({
data: undefined,
stale: false,
fetching: true,
···
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 () => {
···
);
const variables = ref({});
-
const sub = reactive(
-
useSubscription({
-
query: `{ test }`,
-
variables,
-
})
-
);
+
const sub = useSubscription({
+
query: `{ test }`,
+
variables,
+
});
expect(executeSubscription).toHaveBeenCalledWith(
{
···
);
subject.next({ data: { test: true } });
-
expect(sub.data).toEqual({ test: true });
+
expect(sub.data.value).toHaveProperty('test', true);
variables.value = { test: true };
await nextTick();
···
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<any>();
const executeSubscription = vi
···
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(
···
);
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);
});
});
+51 -95
packages/vue-urql/src/useSubscription.ts
···
/* 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.
*
···
export type SubscriptionHandler<T, R> = (prev: R | undefined, data: T) => R;
/** A {@link SubscriptionHandler} or a reactive ref of one. */
-
export type SubscriptionHandlerArg<T, R> = MaybeRef<SubscriptionHandler<T, R>>;
+
export type SubscriptionHandlerArg<T, R> =
+
| Ref<SubscriptionHandler<T, R>>
+
| SubscriptionHandler<T, R>;
/** State of the current query, your {@link useSubscription} function is executing.
*
···
executeSubscription(opts?: Partial<OperationContext>): 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.
···
V extends AnyVariables = AnyVariables,
>(
args: UseSubscriptionArgs<T, V>,
-
handler?: MaybeRef<SubscriptionHandler<T, R>>
+
handler?: SubscriptionHandlerArg<T, R>
): UseSubscriptionResponse<T, R, V> {
return callUseSubscription(args, handler);
}
···
R = T,
V extends AnyVariables = AnyVariables,
>(
-
_args: UseSubscriptionArgs<T, V>,
-
handler?: MaybeRef<SubscriptionHandler<T, R>>,
+
args: UseSubscriptionArgs<T, V>,
+
handler?: SubscriptionHandlerArg<T, R>,
client: Ref<Client> = useClient(),
-
stops: WatchStopHandle[] = []
+
stops?: WatchStopHandle[]
): UseSubscriptionResponse<T, R, V> {
-
const args = reactive(_args) as UseSubscriptionArgs<T, V>;
-
const data: Ref<R | undefined> = ref();
-
const stale: Ref<boolean> = ref(false);
-
const fetching: Ref<boolean> = ref(false);
-
const error: Ref<CombinedError | undefined> = shallowRef();
-
const operation: Ref<Operation<T, V> | undefined> = shallowRef();
-
const extensions: Ref<Record<string, any> | undefined> = shallowRef();
-
const scanHandler = ref(handler);
-
const isPaused: Ref<boolean> = 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<T, V>(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<Source<OperationResult<T, V>> | undefined> = ref();
+
const teardownSubscription = watchEffect(onInvalidate => {
+
if (source.value) {
+
fetching.value = true;
-
stops.push(
-
watchEffect(() => {
-
updateShallowRef(input, {
-
request: createRequest<T, V>(
-
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<T, V>(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)
-
);
+
).unsubscribe
+
);
+
} else {
+
fetching.value = false;
+
}
+
});
-
stops.push(
-
watchEffect(onInvalidate => {
-
if (source.value) {
-
fetching.value = true;
-
-
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<T, R, V> = {
data,
···
extensions,
fetching,
isPaused,
+
pause,
+
resume,
executeSubscription(
opts?: Partial<OperationContext>
): UseSubscriptionResponse<T, R, V> {
-
source.value = client.value.executeSubscription<T, V>(
-
input.value.request,
-
{
-
...unref(args.context),
-
...opts,
-
}
-
);
-
+
execute(opts);
return state;
-
},
-
pause() {
-
isPaused.value = true;
-
},
-
resume() {
-
isPaused.value = false;
},
};
+154 -28
packages/vue-urql/src/utils.ts
···
-
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 | (() => T) | Ref<T>;
export type MaybeRefObj<T> = T extends {}
? { [K in keyof T]: MaybeRef<T[K]> }
: T;
-
export const unref = <T>(maybeRef: MaybeRef<T>): T =>
+
const unwrap = <T>(maybeRef: MaybeRef<T>): 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<Data, Variables>;
-
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;
+
+
const unwrapDeeply = <T>(input: T): T => {
+
input = isRef(input) ? (input.value as T) : input;
+
+
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;
-
export function createRequestState<
-
Data = any,
-
Variables extends AnyVariables = AnyVariables,
-
>(
-
request: GraphQLRequest<Data, Variables>,
-
isPaused: boolean
-
): RequestState<Data, Variables> {
-
return { request, isPaused };
-
}
+
for (; i < length; i++) {
+
key = keys[i];
+
out[key] = unwrapDeeply(input[key]);
+
}
-
export const updateShallowRef = <T extends Record<string, any>>(
-
ref: ShallowRef<T>,
-
next: T
-
) => {
-
for (const key in next) {
-
if (ref.value[key] !== next[key]) {
-
ref.value = next;
-
return;
+
return out;
}
}
+
+
return input;
};
+
+
export const createRequestWithArgs = <
+
T = any,
+
V extends AnyVariables = AnyVariables,
+
>(
+
args:
+
| UseQueryArgs<T, V>
+
| UseSubscriptionArgs<T, V>
+
| { query: MaybeRef<DocumentInput<T, V>>; variables: V }
+
) => {
+
return createRequest<T, V>(
+
unwrap(args.query),
+
unwrapDeeply(args.variables) as V
+
);
+
};
+
+
export const useRequestState = <
+
T = any,
+
V extends AnyVariables = AnyVariables,
+
>() => {
+
const stale: Ref<boolean> = ref(false);
+
const fetching: Ref<boolean> = ref(false);
+
const error: Ref<CombinedError | undefined> = shallowRef();
+
const operation: Ref<Operation<T, V> | undefined> = shallowRef();
+
const extensions: Ref<Record<string, any> | undefined> = shallowRef();
+
return {
+
stale,
+
fetching,
+
error,
+
operation,
+
extensions,
+
};
+
};
+
+
export function useClientState<T = any, V extends AnyVariables = AnyVariables>(
+
args: UseQueryArgs<T, V> | UseSubscriptionArgs<T, V>,
+
client: Ref<Client>,
+
method: keyof Pick<Client, 'executeSubscription' | 'executeQuery'>
+
) {
+
const source: Ref<OperationResultSource<OperationResult<T, V>> | undefined> =
+
shallowRef();
+
+
const isPaused: Ref<boolean> = 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<OperationContext>) => {
+
return client.value[method]<T, V>(request.value, {
+
...requestOptions.value,
+
...opts,
+
});
+
};
+
+
const execute = (opts?: Partial<OperationContext>) => {
+
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,
+
};
+
}