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.

Fix concurrent-mode edge cases using useSubscription (adds useSo… (#514)

* Replace react-wonka with use-subscription

* Add initial useSource implementation based on useSubscription

* Add useBehaviourSubject to useSource.ts

* Replace useOperator with useSource + useBehaviourSubject

* Fix return value in useBehaviourSubject's effect

* Simplify state in useQuery and useSubscription

* Update useSource to use useMemo

* Take all values in useSource for toArray until synchronous values are exhausted

* Take last synchronous value instead of first

* Remove shared subscription

* Move useSource from src/utils/ to src/hooks/

* Simplify BehaviourSubject logic

* Fix tests due to useSubscription changes

* Defer fetch request by Promise.resolve() tick

This is to prevent unnecessary calls to fetch
when the teardown operation comes in immediately

* Fix completion on subscription exchange

* Address review comments

Co-authored-by: Jovi De Croock <decroockjovi@gmail.com>

* Fix test and implementation for immediate fetch abort

Co-authored-by: Jovi De Croock <decroockjovi@gmail.com>

authored by

Phil Plückthun
Jovi De Croock
and committed by
GitHub
b88b4e78 410cc39a

+160 -68
+2 -3
package.json
··· 95 95 "@types/jest": "^24.0.25", 96 96 "@types/react": "^16.9.17", 97 97 "@types/react-test-renderer": "^16.9.1", 98 - "@types/scheduler": "^0.16.1", 98 + "@types/use-subscription": "^1.0.0", 99 99 "@typescript-eslint/eslint-plugin": "^2.15.0", 100 100 "@typescript-eslint/parser": "^2.15.0", 101 101 "babel-plugin-closure-elimination": "^1.3.0", ··· 136 136 "react": ">= 16.8.0" 137 137 }, 138 138 "dependencies": { 139 - "react-wonka": "^2.0.1", 140 - "scheduler": ">= 0.16.0", 139 + "use-subscription": "^1.3.0", 141 140 "wonka": "^4.0.7" 142 141 } 143 142 }
+19 -13
src/exchanges/fetch.test.ts
··· 17 17 }; 18 18 }); 19 19 20 - beforeEach(() => { 20 + afterEach(() => { 21 21 fetch.mockClear(); 22 22 abort.mockClear(); 23 23 }); ··· 42 42 43 43 describe('on success', () => { 44 44 beforeEach(() => { 45 - jest 46 - .spyOn(Date, 'now') 47 - .mockImplementationOnce(() => 100) 48 - .mockImplementationOnce(() => 200); 49 - 50 45 fetch.mockResolvedValue({ 51 46 status: 200, 52 47 json: jest.fn().mockResolvedValue(response), ··· 76 71 77 72 describe('on error', () => { 78 73 beforeEach(() => { 79 - jest 80 - .spyOn(Date, 'now') 81 - .mockImplementationOnce(() => 100) 82 - .mockImplementationOnce(() => 200); 83 - 84 74 fetch.mockResolvedValue({ 85 75 status: 400, 86 76 json: jest.fn().mockResolvedValue(response), ··· 117 107 }); 118 108 119 109 describe('on teardown', () => { 120 - it('aborts the outgoing request', () => { 121 - fetch.mockReturnValue(Promise.reject(abortError)); 110 + it('does not start the outgoing request on immediate teardowns', () => { 111 + fetch.mockRejectedValueOnce(abortError); 122 112 123 113 const { unsubscribe } = pipe( 124 114 fromValue(queryOperation), 125 115 fetchExchange(exchangeArgs), 126 116 subscribe(fail) 127 117 ); 118 + 119 + unsubscribe(); 120 + expect(fetch).toHaveBeenCalledTimes(0); 121 + expect(abort).toHaveBeenCalledTimes(1); 122 + }); 123 + 124 + it('aborts the outgoing request', async () => { 125 + fetch.mockRejectedValueOnce(abortError); 126 + 127 + const { unsubscribe } = pipe( 128 + fromValue(queryOperation), 129 + fetchExchange(exchangeArgs), 130 + subscribe(fail) 131 + ); 132 + 133 + await Promise.resolve(); 128 134 129 135 unsubscribe(); 130 136 expect(fetch).toHaveBeenCalledTimes(1);
+15 -8
src/exchanges/fetch.ts
··· 99 99 abortController !== undefined ? abortController.signal : undefined, 100 100 }; 101 101 102 - executeFetch(operation, fetchOptions).then(result => { 103 - if (result !== undefined) { 104 - next(result); 105 - } 102 + let ended = false; 106 103 107 - complete(); 108 - }); 104 + Promise.resolve() 105 + .then(() => (ended ? undefined : executeFetch(operation, fetchOptions))) 106 + .then((result: OperationResult | undefined) => { 107 + if (!ended) { 108 + ended = true; 109 + if (result) next(result); 110 + complete(); 111 + } 112 + }); 109 113 110 114 return () => { 115 + ended = true; 111 116 if (abortController !== undefined) { 112 117 abortController.abort(); 113 118 } ··· 115 120 }); 116 121 }; 117 122 118 - const executeFetch = (operation: Operation, opts: RequestInit) => { 123 + const executeFetch = ( 124 + operation: Operation, 125 + opts: RequestInit 126 + ): Promise<OperationResult> => { 119 127 const { url, fetch: fetcher } = operation.context; 120 - 121 128 let response: Response | undefined; 122 129 123 130 return (fetcher || fetch)(url, opts)
+3 -2
src/exchanges/subscription.ts
··· 79 79 error: err => next(makeErrorResult(operation, err)), 80 80 complete: () => { 81 81 if (!isComplete) { 82 + isComplete = true; 82 83 client.reexecuteOperation({ 83 84 ...operation, 84 85 operationName: 'teardown', 85 86 }); 86 - } 87 87 88 - complete(); 88 + complete(); 89 + } 89 90 }, 90 91 }); 91 92
+3 -7
src/hooks/useQuery.test.tsx
··· 184 184 185 185 beforeEach(() => { 186 186 client.executeQuery.mockReturnValue( 187 - pipe( 188 - never, 189 - onStart(start), 190 - onEnd(unsubscribe) 191 - ) 187 + pipe(never, onStart(start), onEnd(unsubscribe)) 192 188 ); 193 189 }); 194 190 195 191 it('unsubscribe is called', () => { 196 192 const wrapper = renderer.create(<QueryUser {...props} />); 197 193 act(() => wrapper.unmount()); 198 - expect(start).toBeCalledTimes(1); 199 - expect(unsubscribe).toBeCalledTimes(1); 194 + expect(start).toHaveBeenCalled(); 195 + expect(unsubscribe).toHaveBeenCalled(); 200 196 }); 201 197 }); 202 198
+14 -11
src/hooks/useQuery.ts
··· 1 1 import { DocumentNode } from 'graphql'; 2 2 import { useCallback, useMemo } from 'react'; 3 3 import { pipe, concat, fromValue, switchMap, map, scan } from 'wonka'; 4 - import { useOperator } from 'react-wonka'; 5 4 6 5 import { useClient } from '../context'; 7 6 import { OperationContext, RequestPolicy } from '../types'; 8 7 import { CombinedError } from '../utils'; 8 + import { useSource, useBehaviourSubject } from './useSource'; 9 9 import { useRequest } from './useRequest'; 10 10 import { initialState } from './constants'; 11 11 ··· 53 53 [client, request, args.requestPolicy, args.pollInterval, args.context] 54 54 ); 55 55 56 - const [state, update] = useOperator( 57 - query$$ => 58 - pipe( 56 + const [query$$, update] = useBehaviourSubject( 57 + useMemo(() => (args.pause ? null : makeQuery$()), [args.pause, makeQuery$]) 58 + ); 59 + 60 + const state = useSource( 61 + useMemo(() => { 62 + return pipe( 59 63 query$$, 60 64 switchMap(query$ => { 61 - if (!query$) return fromValue({ fetching: false }); 65 + if (!query$) return fromValue({ fetching: false, stale: false }); 62 66 63 67 return concat([ 64 68 // Initially set fetching to true 65 - fromValue({ fetching: true }), 69 + fromValue({ fetching: true, stale: false }), 66 70 pipe( 67 71 query$, 68 72 map(({ stale, data, error, extensions }) => ({ ··· 74 78 })) 75 79 ), 76 80 // When the source proactively closes, fetching is set to false 77 - fromValue({ fetching: false }), 81 + fromValue({ fetching: false, stale: false }), 78 82 ]); 79 83 }), 80 84 // The individual partial results are merged into each previous result 81 85 scan( 82 - (result, partial: { fetching: boolean }) => ({ 86 + (result, partial) => ({ 83 87 ...result, 84 - stale: false, 85 88 ...partial, 86 89 }), 87 90 initialState 88 91 ) 89 - ), 90 - useMemo(() => (args.pause ? null : makeQuery$()), [args.pause, makeQuery$]), 92 + ); 93 + }, [query$$]), 91 94 initialState 92 95 ); 93 96
+75
src/hooks/useSource.ts
··· 1 + /* eslint-disable @typescript-eslint/no-use-before-define */ 2 + /* eslint-disable react-hooks/exhaustive-deps */ 3 + 4 + import { useMemo, useEffect } from 'react'; 5 + import { Subscription, Unsubscribe, useSubscription } from 'use-subscription'; 6 + 7 + import { 8 + Source, 9 + fromValue, 10 + makeSubject, 11 + pipe, 12 + map, 13 + concat, 14 + onPush, 15 + publish, 16 + subscribe, 17 + } from 'wonka'; 18 + 19 + export const useSource = <T>(source: Source<T>, init: T): T => 20 + useSubscription( 21 + useMemo((): Subscription<T> => { 22 + let hasUpdate = false; 23 + let currentValue: T = init; 24 + 25 + const updateValue = pipe( 26 + source, 27 + onPush(value => { 28 + currentValue = value; 29 + }) 30 + ); 31 + 32 + return { 33 + getCurrentValue(): T { 34 + if (!hasUpdate) publish(updateValue).unsubscribe(); 35 + return currentValue; 36 + }, 37 + subscribe(onValue: () => void): Unsubscribe { 38 + return pipe( 39 + updateValue, 40 + subscribe(() => { 41 + hasUpdate = true; 42 + onValue(); 43 + hasUpdate = false; 44 + }) 45 + ).unsubscribe as Unsubscribe; 46 + }, 47 + }; 48 + }, [source]) 49 + ); 50 + 51 + export const useBehaviourSubject = <T>(value: T) => { 52 + const state = useMemo((): [Source<T>, (value: T) => void] => { 53 + let prevValue = value; 54 + 55 + const subject = makeSubject<T>(); 56 + const prevValue$ = pipe( 57 + fromValue(value), 58 + map(() => prevValue) 59 + ); 60 + 61 + const source = concat([prevValue$, subject.source]); 62 + 63 + const next = (value: T) => { 64 + subject.next((prevValue = value)); 65 + }; 66 + 67 + return [source, next]; 68 + }, []); 69 + 70 + useEffect(() => { 71 + state[1](value); 72 + }, [state, value]); 73 + 74 + return state; 75 + };
-1
src/hooks/useSubscription.test.tsx
··· 95 95 <SubscriptionUser q={query} handler={handler} /> 96 96 ); 97 97 wrapper.update(<SubscriptionUser q={query} handler={handler} />); 98 - expect(handler).toBeCalledTimes(1); 99 98 expect(handler).toBeCalledWith(undefined, 1234); 100 99 }); 101 100
+16 -12
src/hooks/useSubscription.ts
··· 1 1 import { DocumentNode } from 'graphql'; 2 2 import { useCallback, useRef, useMemo } from 'react'; 3 3 import { pipe, concat, fromValue, switchMap, map, scan } from 'wonka'; 4 - import { useOperator } from 'react-wonka'; 5 4 6 5 import { useClient } from '../context'; 7 6 import { CombinedError } from '../utils'; 7 + import { useSource, useBehaviourSubject } from './useSource'; 8 8 import { OperationContext } from '../types'; 9 9 import { useRequest } from './useRequest'; 10 10 import { initialState } from './constants'; ··· 54 54 [client, request, args.context] 55 55 ); 56 56 57 - const [state, update] = useOperator( 58 - subscription$$ => 59 - pipe( 57 + const [subscription$$, update] = useBehaviourSubject( 58 + useMemo(() => (args.pause ? null : makeSubscription$()), [ 59 + args.pause, 60 + makeSubscription$, 61 + ]) 62 + ); 63 + 64 + const state = useSource( 65 + useMemo(() => { 66 + return pipe( 60 67 subscription$$, 61 68 switchMap(subscription$ => { 62 69 if (!subscription$) return fromValue({ fetching: false }); 63 70 64 71 return concat([ 65 72 // Initially set fetching to true 66 - fromValue({ fetching: true }), 73 + fromValue({ fetching: true, stale: false }), 67 74 pipe( 68 75 subscription$, 69 76 map(({ stale, data, error, extensions }) => ({ ··· 75 82 })) 76 83 ), 77 84 // When the source proactively closes, fetching is set to false 78 - fromValue({ fetching: false }), 85 + fromValue({ fetching: false, stale: false }), 79 86 ]); 80 87 }), 81 88 // The individual partial results are merged into each previous result ··· 88 95 ? handler(result.data, partial.data) 89 96 : partial.data 90 97 : result.data; 91 - return { ...result, stale: false, ...partial, data }; 98 + return { ...result, ...partial, data }; 92 99 }, initialState) 93 - ), 94 - useMemo(() => (args.pause ? null : makeSubscription$()), [ 95 - args.pause, 96 - makeSubscription$, 97 - ]), 100 + ); 101 + }, [subscription$$]), 98 102 initialState 99 103 ); 100 104
+13 -11
yarn.lock
··· 599 599 dependencies: 600 600 "@types/node" "*" 601 601 602 - "@types/scheduler@^0.16.1": 603 - version "0.16.1" 604 - resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.1.tgz#18845205e86ff0038517aab7a18a62a6b9f71275" 605 - integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA== 606 - 607 602 "@types/stack-utils@^1.0.1": 608 603 version "1.0.1" 609 604 resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" ··· 631 626 dependencies: 632 627 "@types/react-dom" "*" 633 628 "@types/testing-library__dom" "*" 629 + 630 + "@types/use-subscription@^1.0.0": 631 + version "1.0.0" 632 + resolved "https://registry.yarnpkg.com/@types/use-subscription/-/use-subscription-1.0.0.tgz#d146f8d834f70f50d48bd8246a481d096f11db19" 633 + integrity sha512-0WWZ5GUDKMXUY/1zy4Ur5/zsC0s/B+JjXfHdkvx6JgDNZzZV5eW+KKhDqsTGyqX56uh99gwGwbsKbVwkcVIKQA== 634 634 635 635 "@types/yargs-parser@*": 636 636 version "13.0.0" ··· 4718 4718 react-is "^16.8.6" 4719 4719 scheduler "^0.18.0" 4720 4720 4721 - react-wonka@^2.0.1: 4722 - version "2.0.1" 4723 - resolved "https://registry.yarnpkg.com/react-wonka/-/react-wonka-2.0.1.tgz#75bdf03dbad8ceb8c1066216f635f05ce2b642a5" 4724 - integrity sha512-mM2UH2gnK5LLzaqVWd6JCLrB1vO3I4PN/sQZbjvzsjms4vSv+nKwelNUftM0KeC+LtTPC4GGsuxyu2XJnsCUTw== 4725 - 4726 4721 react@^16.12.0: 4727 4722 version "16.12.0" 4728 4723 resolved "https://registry.yarnpkg.com/react/-/react-16.12.0.tgz#0c0a9c6a142429e3614834d5a778e18aa78a0b83" ··· 5137 5132 resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" 5138 5133 integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== 5139 5134 5140 - "scheduler@>= 0.16.0", scheduler@^0.18.0: 5135 + scheduler@^0.18.0: 5141 5136 version "0.18.0" 5142 5137 resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.18.0.tgz#5901ad6659bc1d8f3fdaf36eb7a67b0d6746b1c4" 5143 5138 integrity sha512-agTSHR1Nbfi6ulI0kYNK0203joW2Y5W4po4l+v03tOoiJKpTBbxpNhWDvqc/4IcOw+KLmSiQLTasZ4cab2/UWQ== ··· 5857 5852 version "0.4.4" 5858 5853 resolved "https://registry.yarnpkg.com/urlgrey/-/urlgrey-0.4.4.tgz#892fe95960805e85519f1cd4389f2cb4cbb7652f" 5859 5854 integrity sha1-iS/pWWCAXoVRnxzUOJ8stMu3ZS8= 5855 + 5856 + use-subscription@^1.3.0: 5857 + version "1.3.0" 5858 + resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.3.0.tgz#3df13a798e826c8d462899423293289a3362e4e6" 5859 + integrity sha512-buZV7FUtnbOr+65dN7PHK7chHhQGfk/yjgqfpRLoWuHIAc4klAD/rdot2FsPNtFthN1ZydvA8tR/mWBMQ+/fDQ== 5860 + dependencies: 5861 + object-assign "^4.1.1" 5860 5862 5861 5863 use@^3.1.0: 5862 5864 version "3.1.1"