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

Configure Feed

Select the types of activity you want to include in your feed.

(preact) - Update @urql/preact implementation to match React bindings (#1008)

authored by

Phil Pluckthun and committed by
GitHub
372b0e3f a2c3a1f8

+266 -332
+5
.changeset/famous-penguins-tickle.md
··· 1 + --- 2 + '@urql/preact': minor 3 + --- 4 + 5 + Update `@urql/preact` implementation to match `urql` React implementation. Internally these changes should align behaviour and updates slightly, but outwardly no changes should be apparent apart from how some updates are scheduled.
+8
packages/preact-urql/src/hooks/constants.ts
··· 1 + export const initialState = { 2 + fetching: false, 3 + stale: false, 4 + error: undefined, 5 + data: undefined, 6 + extensions: undefined, 7 + operation: undefined, 8 + };
-50
packages/preact-urql/src/hooks/useImmediateEffect.test.ts
··· 1 - import { h } from 'preact'; 2 - import { render } from '@testing-library/preact'; 3 - import { useImmediateEffect } from './useImmediateEffect'; 4 - import { act } from 'preact/test-utils'; 5 - 6 - const Component = ({ assertion, effect }) => { 7 - useImmediateEffect(effect, [effect]); 8 - if (assertion) assertion(); 9 - return null; 10 - }; 11 - 12 - it('calls effects immediately on mount', () => { 13 - const effect = jest.fn(); 14 - 15 - act(() => { 16 - render( 17 - h(Component, { 18 - assertion: () => expect(effect).toHaveBeenCalledTimes(1), 19 - effect, 20 - }) 21 - ); 22 - }); 23 - 24 - expect(effect).toHaveBeenCalledTimes(1); 25 - }); 26 - 27 - it('behaves like useEffect otherwise', () => { 28 - const effect = jest.fn(); 29 - const effectRerender = jest.fn(); 30 - let rerender; 31 - 32 - act(() => { 33 - ({ rerender } = render( 34 - h(Component, { 35 - assertion: () => expect(effect).toHaveBeenCalledTimes(1), 36 - effect, 37 - }) 38 - )); 39 - }); 40 - 41 - act(() => { 42 - rerender( 43 - h(Component, { 44 - assertion: () => expect(effectRerender).toHaveBeenCalledTimes(0), 45 - effect: effectRerender, 46 - }) 47 - ); 48 - }); 49 - expect(effectRerender).toHaveBeenCalledTimes(1); 50 - });
-29
packages/preact-urql/src/hooks/useImmediateEffect.ts
··· 1 - /* eslint-disable react-hooks/exhaustive-deps */ 2 - 3 - import { useRef, useEffect, EffectCallback } from 'preact/hooks'; 4 - import { noop } from './useQuery'; 5 - 6 - export const useImmediateEffect = ( 7 - effect: EffectCallback, 8 - changes: ReadonlyArray<any> 9 - ) => { 10 - const teardown = useRef<() => void>(noop); 11 - const isMounted = useRef<boolean>(false); 12 - 13 - // On initial render we just execute the effect 14 - if (!isMounted.current) { 15 - // There's the slight possibility that we had an interrupt due to 16 - // conccurrent mode after running the effect. 17 - // This could result in memory leaks. 18 - teardown.current(); 19 - teardown.current = effect() || noop; 20 - } 21 - 22 - useEffect(() => { 23 - // Initially we skip executing the effect since we've already done so on 24 - // initial render, then we execute it as usual 25 - return isMounted.current 26 - ? effect() 27 - : ((isMounted.current = true), teardown.current); 28 - }, changes); 29 - };
-54
packages/preact-urql/src/hooks/useImmediateState.test.ts
··· 1 - import { h } from 'preact'; 2 - import { render } from '@testing-library/preact'; 3 - import { useImmediateState } from './useImmediateState'; 4 - 5 - const initialState = { someObject: 1234 }; 6 - const updateState = { someObject: 5678 }; 7 - let state; 8 - let setState; 9 - let returnVal; 10 - 11 - const Fixture = ({ update }: { update?: boolean }) => { 12 - const [a, set] = useImmediateState<object>(initialState); 13 - 14 - if (update) { 15 - returnVal = set(updateState); 16 - } 17 - 18 - state = a; 19 - setState = set; 20 - 21 - return null; 22 - }; 23 - 24 - beforeEach(jest.clearAllMocks); 25 - 26 - describe('on initial mount', () => { 27 - it('sets initial state', () => { 28 - render( 29 - // @ts-ignore 30 - h(Fixture, {}) 31 - ); 32 - expect(state).toEqual(initialState); 33 - }); 34 - 35 - it('only mutates on setState call', () => { 36 - render( 37 - // @ts-ignore 38 - h(Fixture, { update: true }) 39 - ); 40 - expect(state).toEqual(updateState); 41 - expect(returnVal).toBe(undefined); 42 - }); 43 - }); 44 - 45 - describe('on later mounts', () => { 46 - it('sets state via setState', () => { 47 - render( 48 - // @ts-ignore 49 - h(Fixture, {}) 50 - ); 51 - setState(updateState); 52 - expect(returnVal).toBe(undefined); 53 - }); 54 - });
-45
packages/preact-urql/src/hooks/useImmediateState.ts
··· 1 - /* eslint-disable react-hooks/exhaustive-deps */ 2 - 3 - import { 4 - useRef, 5 - useState, 6 - useCallback, 7 - useEffect, 8 - useLayoutEffect, 9 - } from 'preact/hooks'; 10 - 11 - type SetStateAction<S> = S | ((prevState: S) => S); 12 - type SetState<S> = (action: SetStateAction<S>) => void; 13 - 14 - const useIsomorphicLayoutEffect = 15 - typeof window !== 'undefined' ? useLayoutEffect : useEffect; 16 - 17 - export const useImmediateState = <S extends {}>(init: S): [S, SetState<S>] => { 18 - const isMounted = useRef(false); 19 - const [state, setState] = useState<S>(init); 20 - 21 - // This wraps setState and updates the state mutably on initial mount 22 - const updateState: SetState<S> = useCallback( 23 - (action: SetStateAction<S>): void => { 24 - if (!isMounted.current) { 25 - const newState = 26 - typeof action === 'function' 27 - ? (action as (arg: S) => S)(state) 28 - : action; 29 - Object.assign(state, newState); 30 - } else { 31 - setState(action); 32 - } 33 - }, 34 - [] 35 - ); 36 - 37 - useIsomorphicLayoutEffect(() => { 38 - isMounted.current = true; 39 - return () => { 40 - isMounted.current = false; 41 - }; 42 - }, []); 43 - 44 - return [state, updateState]; 45 - };
+28 -23
packages/preact-urql/src/hooks/useMutation.ts
··· 1 - import { useCallback } from 'preact/hooks'; 2 1 import { DocumentNode } from 'graphql'; 2 + import { useState, useCallback, useRef, useEffect } from 'preact/hooks'; 3 3 import { pipe, toPromise } from 'wonka'; 4 + 4 5 import { 5 - Operation, 6 + OperationResult, 6 7 OperationContext, 7 8 CombinedError, 8 9 createRequest, 9 - OperationResult, 10 + Operation, 10 11 } from '@urql/core'; 12 + 11 13 import { useClient } from '../context'; 12 - import { useImmediateState } from './useImmediateState'; 13 - import { initialState } from './useQuery'; 14 + import { initialState } from './constants'; 14 15 15 16 export interface UseMutationState<T> { 16 17 fetching: boolean; ··· 29 30 ) => Promise<OperationResult<T>> 30 31 ]; 31 32 32 - export const useMutation = <T = any, V = object>( 33 + export function useMutation<T = any, V = object>( 33 34 query: DocumentNode | string 34 - ): UseMutationResponse<T, V> => { 35 + ): UseMutationResponse<T, V> { 36 + const isMounted = useRef(true); 35 37 const client = useClient(); 36 38 37 - const [state, setState] = useImmediateState<UseMutationState<T>>({ 38 - ...initialState, 39 - }); 39 + const [state, setState] = useState<UseMutationState<T>>(initialState); 40 40 41 41 const executeMutation = useCallback( 42 42 (variables?: V, context?: Partial<OperationContext>) => { 43 - setState({ 44 - ...initialState, 45 - fetching: true, 46 - }); 43 + setState({ ...initialState, fetching: true }); 47 44 48 45 return pipe( 49 46 client.executeMutation( ··· 52 49 ), 53 50 toPromise 54 51 ).then(result => { 55 - setState({ 56 - fetching: false, 57 - stale: !!result.stale, 58 - data: result.data, 59 - error: result.error, 60 - extensions: result.extensions, 61 - operation: result.operation, 62 - }); 52 + if (isMounted.current) { 53 + setState({ 54 + fetching: false, 55 + stale: !!result.stale, 56 + data: result.data, 57 + error: result.error, 58 + extensions: result.extensions, 59 + operation: result.operation, 60 + }); 61 + } 63 62 return result; 64 63 }); 65 64 }, ··· 67 66 [client, query, setState] 68 67 ); 69 68 69 + useEffect(() => { 70 + return () => { 71 + isMounted.current = false; 72 + }; 73 + }, []); 74 + 70 75 return [state, executeMutation]; 71 - }; 76 + }
+10 -17
packages/preact-urql/src/hooks/useQuery.test.tsx
··· 262 262 unmount(); 263 263 }); 264 264 265 - expect(start).toBeCalledTimes(1); 266 - expect(unsubscribe).toBeCalledTimes(1); 265 + expect(start).toBeCalledTimes(2); 266 + expect(unsubscribe).toBeCalledTimes(2); 267 267 }); 268 268 }); 269 269 270 270 describe('active teardown', () => { 271 271 it('sets fetching to false when the source ends', () => { 272 272 client.executeQuery.mockReturnValueOnce(empty); 273 - render( 274 - h(Provider, { 275 - value: client as any, 276 - children: [h(QueryUser, { ...props })], 277 - }) 278 - ); 273 + act(() => { 274 + render( 275 + h(Provider, { 276 + value: client as any, 277 + children: [h(QueryUser, { ...props })], 278 + }) 279 + ); 280 + }); 279 281 expect(client.executeQuery).toHaveBeenCalled(); 280 282 expect(state).toMatchObject({ fetching: false }); 281 283 }); ··· 313 315 }) 314 316 ); 315 317 316 - /** 317 - * Call update twice for the change to be detected. 318 - */ 319 - rerender( 320 - h(Provider, { 321 - value: client as any, 322 - children: [h(QueryUser, { ...props, pause: true })], 323 - }) 324 - ); 325 318 rerender( 326 319 h(Provider, { 327 320 value: client as any,
+61 -64
packages/preact-urql/src/hooks/useQuery.ts
··· 1 1 import { DocumentNode } from 'graphql'; 2 - import { pipe, subscribe, onEnd } from 'wonka'; 3 - import { useRef, useCallback } from 'preact/hooks'; 2 + import { useCallback, useMemo } from 'preact/hooks'; 3 + import { pipe, concat, fromValue, switchMap, map, scan } from 'wonka'; 4 4 import { 5 + CombinedError, 5 6 OperationContext, 6 7 RequestPolicy, 7 - CombinedError, 8 8 Operation, 9 9 } from '@urql/core'; 10 10 11 11 import { useClient } from '../context'; 12 + import { useSource, useBehaviourSubject } from './useSource'; 12 13 import { useRequest } from './useRequest'; 13 - import { useImmediateState } from './useImmediateState'; 14 - import { useImmediateEffect } from './useImmediateEffect'; 15 - 16 - export const initialState: UseQueryState<any> = { 17 - fetching: false, 18 - stale: false, 19 - data: undefined, 20 - error: undefined, 21 - operation: undefined, 22 - extensions: undefined, 23 - }; 14 + import { initialState } from './constants'; 24 15 25 16 export interface UseQueryArgs<V> { 26 17 query: string | DocumentNode; ··· 45 36 (opts?: Partial<OperationContext>) => void 46 37 ]; 47 38 48 - // eslint-disable-next-line 49 - export const noop = () => {}; 50 - 51 - export const useQuery = <T = any, V = object>( 39 + export function useQuery<T = any, V = object>( 52 40 args: UseQueryArgs<V> 53 - ): UseQueryResponse<T> => { 54 - const unsubscribe = useRef<(_1?: any) => void>(noop); 41 + ): UseQueryResponse<T> { 55 42 const client = useClient(); 56 - const [state, setState] = useImmediateState<UseQueryState<T>>({ 57 - ...initialState, 58 - }); 59 43 60 44 // This creates a request which will keep a stable reference 61 45 // if request.key doesn't change 62 46 const request = useRequest(args.query, args.variables); 63 47 64 - const executeQuery = useCallback( 48 + // Create a new query-source from client.executeQuery 49 + const makeQuery$ = useCallback( 65 50 (opts?: Partial<OperationContext>) => { 66 - unsubscribe.current(); 51 + return client.executeQuery(request, { 52 + requestPolicy: args.requestPolicy, 53 + pollInterval: args.pollInterval, 54 + ...args.context, 55 + ...opts, 56 + }); 57 + }, 58 + [client, request, args.requestPolicy, args.pollInterval, args.context] 59 + ); 67 60 68 - setState(s => ({ ...s, fetching: true })); 61 + const [query$$, update] = useBehaviourSubject( 62 + useMemo(() => (args.pause ? null : makeQuery$()), [args.pause, makeQuery$]) 63 + ); 64 + 65 + const state = useSource( 66 + useMemo(() => { 67 + return pipe( 68 + query$$, 69 + switchMap(query$ => { 70 + if (!query$) return fromValue({ fetching: false, stale: false }); 69 71 70 - const result = pipe( 71 - client.executeQuery(request, { 72 - requestPolicy: args.requestPolicy, 73 - pollInterval: args.pollInterval, 74 - ...args.context, 75 - ...opts, 72 + return concat([ 73 + // Initially set fetching to true 74 + fromValue({ fetching: true, stale: false }), 75 + pipe( 76 + query$, 77 + map(({ stale, data, error, extensions, operation }) => ({ 78 + fetching: false, 79 + stale: !!stale, 80 + data, 81 + error, 82 + operation, 83 + extensions, 84 + })) 85 + ), 86 + // When the source proactively closes, fetching is set to false 87 + fromValue({ fetching: false, stale: false }), 88 + ]); 76 89 }), 77 - onEnd(() => setState(s => ({ ...s, fetching: false }))), 78 - subscribe(result => { 79 - setState({ 80 - fetching: false, 81 - data: result.data, 82 - error: result.error, 83 - extensions: result.extensions, 84 - stale: !!result.stale, 85 - operation: result.operation, 86 - }); 87 - }) 90 + // The individual partial results are merged into each previous result 91 + scan( 92 + (result, partial) => ({ 93 + ...result, 94 + ...partial, 95 + }), 96 + initialState 97 + ) 88 98 ); 89 - unsubscribe.current = result.unsubscribe; 90 - }, 91 - [ 92 - setState, 93 - client, 94 - request, 95 - args.requestPolicy, 96 - args.pollInterval, 97 - args.context, 98 - ] 99 + }, [query$$]), 100 + initialState 99 101 ); 100 102 101 - useImmediateEffect(() => { 102 - if (args.pause) { 103 - unsubscribe.current(); 104 - setState(s => ({ ...s, fetching: false })); 105 - return noop; 106 - } 107 - 108 - executeQuery(); 109 - return unsubscribe.current; // eslint-disable-line 110 - }, [executeQuery, args.pause, setState]); 103 + // This is the imperative execute function passed to the user 104 + const executeQuery = useCallback( 105 + (opts?: Partial<OperationContext>) => update(makeQuery$(opts)), 106 + [update, makeQuery$] 107 + ); 111 108 112 109 return [state, executeQuery]; 113 - }; 110 + }
+88
packages/preact-urql/src/hooks/useSource.ts
··· 1 + /* eslint-disable react-hooks/exhaustive-deps */ 2 + 3 + import { useMemo, useEffect, useState } from 'preact/hooks'; 4 + 5 + import { 6 + Source, 7 + fromValue, 8 + makeSubject, 9 + pipe, 10 + map, 11 + concat, 12 + onPush, 13 + publish, 14 + subscribe, 15 + } from 'wonka'; 16 + 17 + import { useClient } from '../context'; 18 + 19 + let currentInit = false; 20 + 21 + export function useSource<T>(source: Source<T>, init: T): T { 22 + const [state, setState] = useState(() => { 23 + currentInit = true; 24 + let initialValue = init; 25 + 26 + pipe( 27 + source, 28 + onPush(value => { 29 + initialValue = value; 30 + }), 31 + publish 32 + ).unsubscribe(); 33 + 34 + currentInit = false; 35 + return initialValue; 36 + }); 37 + 38 + useEffect(() => { 39 + return pipe( 40 + source, 41 + subscribe(value => { 42 + if (!currentInit) { 43 + setState(value); 44 + } 45 + }) 46 + ).unsubscribe as () => void; 47 + }, [source]); 48 + 49 + return state; 50 + } 51 + 52 + export function useBehaviourSubject<T>(value: T) { 53 + const client = useClient(); 54 + 55 + const state = useMemo((): [Source<T>, (value: T) => void] => { 56 + let prevValue = value; 57 + 58 + const subject = makeSubject<T>(); 59 + const prevValue$ = pipe( 60 + fromValue(value), 61 + map(() => prevValue) 62 + ); 63 + 64 + // This turns the subject into a behaviour subject that returns 65 + // the last known value (or the initial value) synchronously 66 + const source = concat([prevValue$, subject.source]); 67 + 68 + const next = (value: T) => { 69 + // We can use the latest known value to also deduplicate next calls. 70 + if (value !== prevValue) { 71 + subject.next((prevValue = value)); 72 + } 73 + }; 74 + 75 + return [source, next]; 76 + }, []); 77 + 78 + // NOTE: This is a special case for client-side suspense. 79 + // We can't trigger suspense inside an effect but only in the render function. 80 + // So we "deopt" to not using an effect if the client is in suspense-mode. 81 + useEffect(() => { 82 + if (!client.suspense) state[1](value); 83 + }, [state, value]); 84 + 85 + if (client.suspense) state[1](value); 86 + 87 + return state; 88 + }
+1 -1
packages/preact-urql/src/hooks/useSubscription.test.tsx
··· 112 112 children: [h(SubscriptionUser, { ...props })], 113 113 }) 114 114 ); 115 - expect(handler).toBeCalledTimes(1); 115 + expect(handler).toBeCalledTimes(2); 116 116 expect(handler).toBeCalledWith(undefined, 1234); 117 117 }); 118 118
+64 -48
packages/preact-urql/src/hooks/useSubscription.ts
··· 1 1 import { DocumentNode } from 'graphql'; 2 - import { useCallback, useRef } from 'preact/hooks'; 3 - import { pipe, onEnd, subscribe } from 'wonka'; 2 + import { useCallback, useRef, useMemo } from 'preact/hooks'; 3 + import { pipe, concat, fromValue, switchMap, map, scan } from 'wonka'; 4 4 import { CombinedError, OperationContext, Operation } from '@urql/core'; 5 + 5 6 import { useClient } from '../context'; 7 + import { useSource, useBehaviourSubject } from './useSource'; 6 8 import { useRequest } from './useRequest'; 7 - import { noop, initialState } from './useQuery'; 8 - import { useImmediateEffect } from './useImmediateEffect'; 9 - import { useImmediateState } from './useImmediateState'; 9 + import { initialState } from './constants'; 10 10 11 11 export interface UseSubscriptionArgs<V> { 12 12 query: DocumentNode | string; ··· 31 31 (opts?: Partial<OperationContext>) => void 32 32 ]; 33 33 34 - export const useSubscription = <T = any, R = T, V = object>( 34 + export function useSubscription<T = any, R = T, V = object>( 35 35 args: UseSubscriptionArgs<V>, 36 36 handler?: SubscriptionHandler<T, R> 37 - ): UseSubscriptionResponse<R> => { 38 - const unsubscribe = useRef<(_1?: any) => void>(noop); 39 - const handlerRef = useRef(handler); 37 + ): UseSubscriptionResponse<R> { 40 38 const client = useClient(); 41 39 42 - const [state, setState] = useImmediateState<UseSubscriptionState<R>>({ 43 - ...initialState, 44 - }); 45 - 46 40 // Update handler on constant ref, since handler changes shouldn't 47 41 // trigger a new subscription run 42 + const handlerRef = useRef(handler); 48 43 handlerRef.current = handler!; 49 44 50 45 // This creates a request which will keep a stable reference 51 46 // if request.key doesn't change 52 47 const request = useRequest(args.query, args.variables); 53 48 54 - const executeSubscription = useCallback( 49 + // Create a new subscription-source from client.executeSubscription 50 + const makeSubscription$ = useCallback( 55 51 (opts?: Partial<OperationContext>) => { 56 - unsubscribe.current(); 52 + return client.executeSubscription(request, { ...args.context, ...opts }); 53 + }, 54 + [client, request, args.context] 55 + ); 57 56 58 - setState(s => ({ ...s, fetching: true })); 57 + const [subscription$$, update] = useBehaviourSubject( 58 + useMemo(() => (args.pause ? null : makeSubscription$()), [ 59 + args.pause, 60 + makeSubscription$, 61 + ]) 62 + ); 59 63 60 - const result = pipe( 61 - client.executeSubscription(request, { 62 - ...args.context, 63 - ...opts, 64 + const state = useSource( 65 + useMemo(() => { 66 + return pipe( 67 + subscription$$, 68 + switchMap(subscription$ => { 69 + if (!subscription$) return fromValue({ fetching: false }); 70 + 71 + return concat([ 72 + // Initially set fetching to true 73 + fromValue({ fetching: true, stale: false }), 74 + pipe( 75 + subscription$, 76 + map(({ stale, data, error, extensions, operation }) => ({ 77 + fetching: true, 78 + stale: !!stale, 79 + data, 80 + error, 81 + extensions, 82 + operation, 83 + })) 84 + ), 85 + // When the source proactively closes, fetching is set to false 86 + fromValue({ fetching: false, stale: false }), 87 + ]); 64 88 }), 65 - onEnd(() => setState(s => ({ ...s, fetching: false }))), 66 - subscribe(result => { 67 - setState(s => ({ 68 - fetching: true, 69 - data: 70 - typeof handlerRef.current === 'function' 71 - ? handlerRef.current(s.data, result.data) 72 - : result.data, 73 - error: result.error, 74 - extensions: result.extensions, 75 - stale: !!result.stale, 76 - operation: result.operation, 77 - })); 78 - }) 89 + // The individual partial results are merged into each previous result 90 + scan((result, partial: any) => { 91 + const { current: handler } = handlerRef; 92 + // If a handler has been passed, it's used to merge new data in 93 + const data = 94 + partial.data !== undefined 95 + ? typeof handler === 'function' 96 + ? handler(result.data, partial.data) 97 + : partial.data 98 + : result.data; 99 + return { ...result, ...partial, data }; 100 + }, initialState) 79 101 ); 80 - unsubscribe.current = result.unsubscribe; 81 - }, 82 - [client, request, setState, args.context] 102 + }, [subscription$$]), 103 + initialState 83 104 ); 84 105 85 - useImmediateEffect(() => { 86 - if (args.pause) { 87 - unsubscribe.current(); 88 - setState(s => ({ ...s, fetching: false })); 89 - return noop; 90 - } 91 - 92 - executeSubscription(); 93 - return unsubscribe.current; // eslint-disable-line 94 - }, [executeSubscription, args.pause, setState]); 106 + // This is the imperative execute function passed to the user 107 + const executeSubscription = useCallback( 108 + (opts?: Partial<OperationContext>) => update(makeSubscription$(opts)), 109 + [update, makeSubscription$] 110 + ); 95 111 96 112 return [state, executeSubscription]; 97 - }; 113 + }
+1 -1
packages/react-urql/src/hooks/useSubscription.ts
··· 40 40 // Update handler on constant ref, since handler changes shouldn't 41 41 // trigger a new subscription run 42 42 const handlerRef = useRef(handler); 43 - handlerRef.current = handler; 43 + handlerRef.current = handler!; 44 44 45 45 // This creates a request which will keep a stable reference 46 46 // if request.key doesn't change