diff --git a/.changeset/strange-beans-sort.md b/.changeset/strange-beans-sort.md new file mode 100644 index 0000000000..c605c9bf09 --- /dev/null +++ b/.changeset/strange-beans-sort.md @@ -0,0 +1,7 @@ +--- +'@urql/core': minor +'@urql/preact': minor +'urql': minor +--- + +Improve the Suspense implementation, which fixes edge-cases when Suspense is used with subscriptions, partially disabled, or _used on the client-side_. It has now been ensured that client-side suspense functions without the deprecated `suspenseExchange` and uncached results are loaded consistently. As part of this work, the `Client` itself does now never throw Suspense promises anymore, which is functionality that either way has no place outside of the React/Preact bindings. diff --git a/exchanges/suspense/README.md b/exchanges/suspense/README.md index 95a35e450a..f0a59933f7 100644 --- a/exchanges/suspense/README.md +++ b/exchanges/suspense/README.md @@ -13,13 +13,11 @@ suspense on the server. But since `` is mainly intended for client-side use it made sense to build and publish this exchange, which allows you to try out `urql` and suspense in your React app! -> ⚠️ **\*Deprecated**: -> This package is deprecated! Usage of client-side suspense with `urql` isn't recommended anymore -> and this packages has been marked as _deprecated_ after being _experimental_, since all it allows -> for is to use Suspense as a fancier loading boundary, which isn't its intended use. -> This exchange may still be useful when used with care, but it's worth keeping in mind that the -> suspense patterns in `urql` for the client-side may change. -> Suspense-mode usage for SSR remains unchanged and undeprecated however. +## ⚠️ Deprecated + +Starting from `urql@1.11.0` / `@urql/preact@1.4.0` this exchange isn't required anymore to enable +client-side suspense support. Instead the `useQuery` hook internally handles React Suspense and +caches a result between Suspense and the subsequent re-mount of your component automatically. ## Quick Start Guide diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts index 71128ef25c..125c3b944c 100755 --- a/packages/core/src/client.test.ts +++ b/packages/core/src/client.test.ts @@ -122,6 +122,7 @@ describe('promisified methods', () => { requestPolicy: 'cache-and-network', fetchOptions: undefined, fetch: undefined, + suspense: false, preferGetMethod: false, }); expect(mutationResult).toHaveProperty('then'); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 42fe9df2d9..e66000a7b7 100755 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -41,7 +41,6 @@ import { import { createRequest, - toSuspenseSource, withPromise, maskTypename, noop, @@ -173,14 +172,19 @@ export class Client { createOperationContext = ( opts?: Partial - ): OperationContext => ({ - url: this.url, - fetchOptions: this.fetchOptions, - fetch: this.fetch, - preferGetMethod: this.preferGetMethod, - ...opts, - requestPolicy: (opts || {}).requestPolicy || this.requestPolicy, - }); + ): OperationContext => { + if (!opts) opts = {}; + + return { + url: this.url, + fetchOptions: this.fetchOptions, + fetch: this.fetch, + preferGetMethod: this.preferGetMethod, + ...opts, + suspense: opts.suspense || (opts.suspense !== false && this.suspense), + requestPolicy: opts.requestPolicy || this.requestPolicy, + }; + }; createRequestOperation = ( kind: OperationType, @@ -222,10 +226,9 @@ export class Client { executeRequestOperation( operation: Operation ): Source> { - const { key, kind } = operation; let operationResults$ = pipe( this.results$, - filter((res: OperationResult) => res.operation.key === key) + filter((res: OperationResult) => res.operation.key === operation.key) ) as Source>; if (this.maskTypename) { @@ -238,7 +241,7 @@ export class Client { ); } - if (kind === 'mutation') { + if (operation.kind === 'mutation') { // A mutation is always limited to just a single result and is never shared return pipe( operationResults$, @@ -249,7 +252,9 @@ export class Client { const teardown$ = pipe( this.operations$, - filter((op: Operation) => op.kind === 'teardown' && op.key === key) + filter( + (op: Operation) => op.kind === 'teardown' && op.key === operation.key + ) ); const result$ = pipe( @@ -263,11 +268,14 @@ export class Client { }) ); - return operation.context.suspense !== false && - this.suspense && - kind === 'query' - ? toSuspenseSource(result$ as Source) - : (result$ as Source); + if (operation.kind === 'query' && operation.context.pollInterval) { + return pipe( + merge([fromValue(0), interval(operation.context.pollInterval)]), + switchMap(() => result$) + ); + } + + return result$; } query( @@ -309,17 +317,7 @@ export class Client { opts?: Partial ): Source> => { const operation = this.createRequestOperation('query', query, opts); - const response$ = this.executeRequestOperation(operation); - const { pollInterval } = operation.context; - - if (pollInterval) { - return pipe( - merge([fromValue(0), interval(pollInterval)]), - switchMap(() => response$) - ); - } - - return response$; + return this.executeRequestOperation(operation); }; subscription( diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 3ab0237f5c..ee2437c463 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -2,7 +2,6 @@ export * from './error'; export * from './request'; export * from './result'; export * from './typenames'; -export * from './toSuspenseSource'; export * from './stringifyVariables'; export * from './maskTypename'; export * from './withPromise'; diff --git a/packages/core/src/utils/toSuspenseSource.test.ts b/packages/core/src/utils/toSuspenseSource.test.ts deleted file mode 100644 index b1c52ea93d..0000000000 --- a/packages/core/src/utils/toSuspenseSource.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { - pipe, - onStart, - onPush, - onEnd, - fromValue, - fromArray, - makeSubject, - never, - publish, - subscribe, -} from 'wonka'; - -import { toSuspenseSource } from './toSuspenseSource'; - -it('does nothing when not subscribed to', () => { - const start = jest.fn(); - - pipe(fromValue('test'), onStart(start), toSuspenseSource); - - expect(start).not.toHaveBeenCalled(); -}); - -it('resolves synchronously when the source resolves synchronously', () => { - const start = jest.fn(); - const push = jest.fn(); - let result; - - pipe( - fromValue('test'), - onStart(start), - onPush(push), - toSuspenseSource, - subscribe(value => { - result = value; - }) - ); - - expect(result).toBe('test'); - expect(start).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledTimes(1); -}); - -it('throws a promise when the source is not resolving immediately', () => { - expect(() => { - pipe(never, toSuspenseSource as any, publish); - }).toThrow(expect.any(Promise)); -}); - -it('throws a promise that resolves when the source emits a value', () => { - const { source, next } = makeSubject(); - const end = jest.fn(); - - let promise; - let result; - - try { - pipe( - source, - toSuspenseSource, - onEnd(end), - subscribe(value => { - expect(value).toBe('test'); - result = value; - }) - ); - } catch (error) { - promise = error; - } - - // Expect it to have thrown - expect(promise).toBeInstanceOf(Promise); - - next('test'); - - // The result came in asynchronously and the original source has ended - expect(result).toBe(undefined); - - return promise.then(resolved => { - expect(resolved).toBe('test'); - expect(end).toHaveBeenCalled(); - }); -}); - -it('behaves like a normal source when the first result was synchronous', async () => { - const push = jest.fn(); - await new Promise(resolve => { - pipe(fromArray([1, 2]), toSuspenseSource, onEnd(resolve), subscribe(push)); - }); - - expect(push).toHaveBeenCalledTimes(2); -}); - -it('still supports cancellation', async () => { - let unsubscribe; - const end = jest.fn(); - - try { - ({ unsubscribe } = pipe( - fromArray([1, 2]), - toSuspenseSource, - onEnd(end), - publish - )); - } catch (promise) { - expect(promise).toBe(expect.any(Promise)); - } - - unsubscribe(); - expect(end).toHaveBeenCalledTimes(1); -}); diff --git a/packages/core/src/utils/toSuspenseSource.ts b/packages/core/src/utils/toSuspenseSource.ts deleted file mode 100644 index b7c6265acb..0000000000 --- a/packages/core/src/utils/toSuspenseSource.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { pipe, share, onPush, toPromise, takeWhile, take, Source } from 'wonka'; - -/** This converts a Source to a suspense Source; It will forward the first result synchronously or throw a promise that resolves when the result becomes available */ -export const toSuspenseSource = (source: Source): Source => sink => { - const shared = share(source); - let hasResult = false; - let hasSuspended = false; - - pipe( - shared, - takeWhile(() => !hasSuspended), - onPush(() => (hasResult = true)) - )(sink); - - if (!hasResult) { - hasSuspended = true; - sink(0); /* End */ - throw pipe(shared, take(1), toPromise); - } -}; diff --git a/packages/preact-urql/src/hooks/useQuery.ts b/packages/preact-urql/src/hooks/useQuery.ts index fabc7e3c1e..538c2bb63c 100644 --- a/packages/preact-urql/src/hooks/useQuery.ts +++ b/packages/preact-urql/src/hooks/useQuery.ts @@ -1,12 +1,25 @@ import { DocumentNode } from 'graphql'; -import { useCallback, useMemo } from 'preact/hooks'; -import { pipe, concat, fromValue, switchMap, map, scan } from 'wonka'; +import { useEffect, useCallback, useMemo } from 'preact/hooks'; import { + Source, + pipe, + share, + takeWhile, + concat, + fromValue, + switchMap, + map, + scan, +} from 'wonka'; + +import { + Client, TypedDocumentNode, CombinedError, OperationContext, RequestPolicy, + OperationResult, Operation, } from '@urql/core'; @@ -38,11 +51,51 @@ export type UseQueryResponse = [ (opts?: Partial) => void ]; +/** Convert the Source to a React Suspense source on demand */ +function toSuspenseSource(source: Source): Source { + const shared = share(source); + let cache: T | void; + let resolve: (value: T) => void; + + return sink => { + let hasSuspended = false; + + pipe( + shared, + takeWhile(result => { + // The first result that is received will resolve the suspense + // promise after waiting for a microtick + if (cache === undefined) Promise.resolve(result).then(resolve); + cache = result; + return !hasSuspended; + }) + )(sink); + + // If we haven't got a previous result then start suspending + // otherwise issue the last known result immediately + if (cache !== undefined) { + const signal = [cache] as [T] & { tag: 1 }; + signal.tag = 1; + sink(signal); + } else { + hasSuspended = true; + sink(0 /* End */); + throw new Promise(_resolve => { + resolve = _resolve; + }); + } + }; +} + +const isSuspense = (client: Client, context?: Partial) => + client.suspense && (!context || context.suspense !== false); + +const sources = new Map>(); + export function useQuery( args: UseQueryArgs ): UseQueryResponse { const client = useClient(); - // This creates a request which will keep a stable reference // if request.key doesn't change const request = useRequest(args.query, args.variables); @@ -50,55 +103,69 @@ export function useQuery( // Create a new query-source from client.executeQuery const makeQuery$ = useCallback( (opts?: Partial) => { - return client.executeQuery(request, { - requestPolicy: args.requestPolicy, - pollInterval: args.pollInterval, - ...args.context, - ...opts, - }); + // Determine whether suspense is enabled for the given operation + const suspense = isSuspense(client, args.context); + let source: Source | void = suspense + ? sources.get(request.key) + : undefined; + if (!source) { + source = client.executeQuery(request, { + requestPolicy: args.requestPolicy, + pollInterval: args.pollInterval, + ...args.context, + ...opts, + }); + + // Create a suspense source and cache it for the given request + if (suspense) + sources.set(request.key, (source = toSuspenseSource(source))); + } + + return source; }, [client, request, args.requestPolicy, args.pollInterval, args.context] ); + const query$ = useMemo(() => { + return args.pause ? null : makeQuery$(); + }, [args.pause, makeQuery$]); + const [state, update] = useSource( - useMemo(() => (args.pause ? null : makeQuery$()), [args.pause, makeQuery$]), - useCallback( - (query$$, prevState: UseQueryState | undefined) => { - return pipe( - query$$, - switchMap(query$ => { - if (!query$) return fromValue({ fetching: false, stale: false }); - - return concat([ - // Initially set fetching to true - fromValue({ fetching: true, stale: false }), - pipe( - query$, - map(({ stale, data, error, extensions, operation }) => ({ - fetching: false, - stale: !!stale, - data, - error, - operation, - extensions, - })) - ), - // When the source proactively closes, fetching is set to false - fromValue({ fetching: false, stale: false }), - ]); + query$, + useCallback((query$$, prevState?: UseQueryState) => { + return pipe( + query$$, + switchMap(query$ => { + if (!query$) return fromValue({ fetching: false, stale: false }); + + return concat([ + // Initially set fetching to true + fromValue({ fetching: true, stale: false }), + pipe( + query$, + map(({ stale, data, error, extensions, operation }) => ({ + fetching: false, + stale: !!stale, + data, + error, + operation, + extensions, + })) + ), + // When the source proactively closes, fetching is set to false + fromValue({ fetching: false, stale: false }), + ]); + }), + // The individual partial results are merged into each previous result + scan( + (result: UseQueryState, partial) => ({ + ...result, + ...partial, }), - // The individual partial results are merged into each previous result - scan( - (result: UseQueryState, partial) => ({ - ...result, - ...partial, - }), - prevState || initialState - ) - ); - }, - [] - ) + prevState || initialState + ) + ); + }, []) ); // This is the imperative execute function passed to the user @@ -107,5 +174,16 @@ export function useQuery( [update, makeQuery$] ); + useEffect(() => { + sources.delete(request.key); // Delete any cached suspense source + if (!isSuspense(client, args.context)) { + update(query$); + } + }, [update, client, query$, request, args.context]); + + if (isSuspense(client, args.context)) { + update(query$); + } + return [state, executeQuery]; } diff --git a/packages/preact-urql/src/hooks/useSource.ts b/packages/preact-urql/src/hooks/useSource.ts index 1498f09904..84dc52561c 100644 --- a/packages/preact-urql/src/hooks/useSource.ts +++ b/packages/preact-urql/src/hooks/useSource.ts @@ -1,11 +1,9 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { useMemo, useEffect, useState, useRef } from 'preact/hooks'; +import { useMemo, useEffect, useState } from 'preact/hooks'; import { Source, fromValue, makeSubject, pipe, concat, subscribe } from 'wonka'; -import { useClient } from '../context'; - type Updater = (input: T) => void; let currentInit = false; @@ -19,18 +17,14 @@ const isShallowDifferent = (a: any, b: any) => { export function useSource( input: T, - transform: (input$: Source, initial: R | undefined) => Source + transform: (input$: Source, initial?: R) => Source ): [R, Updater] { - const client = useClient(); - const prev = useRef(); - const [input$, updateInput] = useMemo((): [Source, (value: T) => void] => { const subject = makeSubject(); const source = concat([fromValue(input), subject.source]); - let prevInput = input; - const updateInput = (input: T) => { - if (input !== prevInput) subject.next((prevInput = input)); + const updateInput = (nextInput: T) => { + if (nextInput !== input) subject.next((input = nextInput)); }; return [source, updateInput]; @@ -38,38 +32,29 @@ export function useSource( const [state, setState] = useState(() => { currentInit = true; - + let state: R; pipe( - transform(fromValue(input), prev.current), + transform(fromValue(input)), subscribe(value => { - prev.current = value; + state = value; }) ).unsubscribe(); - currentInit = false; - return prev.current!; + return state!; }); useEffect(() => { return pipe( - transform(input$, prev.current), + transform(input$, state), subscribe(value => { if (!currentInit) { setState(prevValue => { - return (prev.current = isShallowDifferent(prevValue, value) - ? value - : prevValue); + return isShallowDifferent(prevValue, value) ? value : prevValue; }); } }) ).unsubscribe; - }, [input$]); - - useEffect(() => { - if (!client.suspense) updateInput(input); - }, [updateInput, input]); - - if (client.suspense) updateInput(input); + }, [input$ /* `state` is only an initialiser */]); return [state, updateInput]; } diff --git a/packages/preact-urql/src/hooks/useSubscription.ts b/packages/preact-urql/src/hooks/useSubscription.ts index 405daead52..d1b0253df1 100644 --- a/packages/preact-urql/src/hooks/useSubscription.ts +++ b/packages/preact-urql/src/hooks/useSubscription.ts @@ -1,6 +1,7 @@ import { DocumentNode } from 'graphql'; -import { useCallback, useRef, useMemo } from 'preact/hooks'; +import { useEffect, useCallback, useRef, useMemo } from 'preact/hooks'; import { pipe, concat, fromValue, switchMap, map, scan } from 'wonka'; + import { TypedDocumentNode, CombinedError, @@ -62,16 +63,14 @@ export function useSubscription( [client, request, args.context] ); + const subscription$ = useMemo(() => { + return args.pause ? null : makeSubscription$(); + }, [args.pause, makeSubscription$]); + const [state, update] = useSource( - useMemo(() => (args.pause ? null : makeSubscription$()), [ - args.pause, - makeSubscription$, - ]), + subscription$, useCallback( - ( - subscription$$, - prevState: UseSubscriptionState | undefined - ) => { + (subscription$$, prevState?: UseSubscriptionState) => { return pipe( subscription$$, switchMap(subscription$ => { @@ -122,5 +121,9 @@ export function useSubscription( [update, makeSubscription$] ); + useEffect(() => { + update(subscription$); + }, [update, subscription$]); + return [state, executeSubscription]; } diff --git a/packages/react-urql/src/hooks/useQuery.ts b/packages/react-urql/src/hooks/useQuery.ts index 1d1aedba52..35804c2202 100644 --- a/packages/react-urql/src/hooks/useQuery.ts +++ b/packages/react-urql/src/hooks/useQuery.ts @@ -1,12 +1,25 @@ import { DocumentNode } from 'graphql'; -import { useCallback, useMemo } from 'react'; -import { pipe, concat, fromValue, switchMap, map, scan } from 'wonka'; +import { useEffect, useCallback, useMemo } from 'react'; import { + Source, + pipe, + share, + takeWhile, + concat, + fromValue, + switchMap, + map, + scan, +} from 'wonka'; + +import { + Client, TypedDocumentNode, CombinedError, OperationContext, RequestPolicy, + OperationResult, Operation, } from '@urql/core'; @@ -38,11 +51,51 @@ export type UseQueryResponse = [ (opts?: Partial) => void ]; +/** Convert the Source to a React Suspense source on demand */ +function toSuspenseSource(source: Source): Source { + const shared = share(source); + let cache: T | void; + let resolve: (value: T) => void; + + return sink => { + let hasSuspended = false; + + pipe( + shared, + takeWhile(result => { + // The first result that is received will resolve the suspense + // promise after waiting for a microtick + if (cache === undefined) Promise.resolve(result).then(resolve); + cache = result; + return !hasSuspended; + }) + )(sink); + + // If we haven't got a previous result then start suspending + // otherwise issue the last known result immediately + if (cache !== undefined) { + const signal = [cache] as [T] & { tag: 1 }; + signal.tag = 1; + sink(signal); + } else { + hasSuspended = true; + sink(0 /* End */); + throw new Promise(_resolve => { + resolve = _resolve; + }); + } + }; +} + +const isSuspense = (client: Client, context?: Partial) => + client.suspense && (!context || context.suspense !== false); + +const sources = new Map>(); + export function useQuery( args: UseQueryArgs ): UseQueryResponse { const client = useClient(); - // This creates a request which will keep a stable reference // if request.key doesn't change const request = useRequest(args.query, args.variables); @@ -50,55 +103,69 @@ export function useQuery( // Create a new query-source from client.executeQuery const makeQuery$ = useCallback( (opts?: Partial) => { - return client.executeQuery(request, { - requestPolicy: args.requestPolicy, - pollInterval: args.pollInterval, - ...args.context, - ...opts, - }); + // Determine whether suspense is enabled for the given operation + const suspense = isSuspense(client, args.context); + let source: Source | void = suspense + ? sources.get(request.key) + : undefined; + if (!source) { + source = client.executeQuery(request, { + requestPolicy: args.requestPolicy, + pollInterval: args.pollInterval, + ...args.context, + ...opts, + }); + + // Create a suspense source and cache it for the given request + if (suspense) + sources.set(request.key, (source = toSuspenseSource(source))); + } + + return source; }, [client, request, args.requestPolicy, args.pollInterval, args.context] ); + const query$ = useMemo(() => { + return args.pause ? null : makeQuery$(); + }, [args.pause, makeQuery$]); + const [state, update] = useSource( - useMemo(() => (args.pause ? null : makeQuery$()), [args.pause, makeQuery$]), - useCallback( - (query$$, prevState: UseQueryState | undefined) => { - return pipe( - query$$, - switchMap(query$ => { - if (!query$) return fromValue({ fetching: false, stale: false }); - - return concat([ - // Initially set fetching to true - fromValue({ fetching: true, stale: false }), - pipe( - query$, - map(({ stale, data, error, extensions, operation }) => ({ - fetching: false, - stale: !!stale, - data, - error, - operation, - extensions, - })) - ), - // When the source proactively closes, fetching is set to false - fromValue({ fetching: false, stale: false }), - ]); + query$, + useCallback((query$$, prevState?: UseQueryState) => { + return pipe( + query$$, + switchMap(query$ => { + if (!query$) return fromValue({ fetching: false, stale: false }); + + return concat([ + // Initially set fetching to true + fromValue({ fetching: true, stale: false }), + pipe( + query$, + map(({ stale, data, error, extensions, operation }) => ({ + fetching: false, + stale: !!stale, + data, + error, + operation, + extensions, + })) + ), + // When the source proactively closes, fetching is set to false + fromValue({ fetching: false, stale: false }), + ]); + }), + // The individual partial results are merged into each previous result + scan( + (result: UseQueryState, partial) => ({ + ...result, + ...partial, }), - // The individual partial results are merged into each previous result - scan( - (result: UseQueryState, partial) => ({ - ...result, - ...partial, - }), - prevState || initialState - ) - ); - }, - [] - ) + prevState || initialState + ) + ); + }, []) ); // This is the imperative execute function passed to the user @@ -107,5 +174,16 @@ export function useQuery( [update, makeQuery$] ); + useEffect(() => { + sources.delete(request.key); // Delete any cached suspense source + if (!isSuspense(client, args.context)) { + update(query$); + } + }, [update, client, query$, request, args.context]); + + if (isSuspense(client, args.context)) { + update(query$); + } + return [state, executeQuery]; } diff --git a/packages/react-urql/src/hooks/useSource.ts b/packages/react-urql/src/hooks/useSource.ts index 676934599f..2ec592b2cc 100644 --- a/packages/react-urql/src/hooks/useSource.ts +++ b/packages/react-urql/src/hooks/useSource.ts @@ -1,11 +1,9 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { useMemo, useEffect, useState, useRef } from 'react'; +import { useMemo, useEffect, useState } from 'react'; import { Source, fromValue, makeSubject, pipe, concat, subscribe } from 'wonka'; -import { useClient } from '../context'; - type Updater = (input: T) => void; let currentInit = false; @@ -19,18 +17,14 @@ const isShallowDifferent = (a: any, b: any) => { export function useSource( input: T, - transform: (input$: Source, initial: R | undefined) => Source + transform: (input$: Source, initial?: R) => Source ): [R, Updater] { - const client = useClient(); - const prev = useRef(); - const [input$, updateInput] = useMemo((): [Source, (value: T) => void] => { const subject = makeSubject(); const source = concat([fromValue(input), subject.source]); - let prevInput = input; - const updateInput = (input: T) => { - if (input !== prevInput) subject.next((prevInput = input)); + const updateInput = (nextInput: T) => { + if (nextInput !== input) subject.next((input = nextInput)); }; return [source, updateInput]; @@ -38,38 +32,29 @@ export function useSource( const [state, setState] = useState(() => { currentInit = true; - + let state: R; pipe( - transform(fromValue(input), prev.current), + transform(fromValue(input)), subscribe(value => { - prev.current = value; + state = value; }) ).unsubscribe(); - currentInit = false; - return prev.current!; + return state!; }); useEffect(() => { return pipe( - transform(input$, prev.current), + transform(input$, state), subscribe(value => { if (!currentInit) { setState(prevValue => { - return (prev.current = isShallowDifferent(prevValue, value) - ? value - : prevValue); + return isShallowDifferent(prevValue, value) ? value : prevValue; }); } }) ).unsubscribe; - }, [input$]); - - useEffect(() => { - if (!client.suspense) updateInput(input); - }, [updateInput, input]); - - if (client.suspense) updateInput(input); + }, [input$ /* `state` is only an initialiser */]); return [state, updateInput]; } diff --git a/packages/react-urql/src/hooks/useSubscription.ts b/packages/react-urql/src/hooks/useSubscription.ts index bd93b040b4..3f1e2676fe 100644 --- a/packages/react-urql/src/hooks/useSubscription.ts +++ b/packages/react-urql/src/hooks/useSubscription.ts @@ -1,5 +1,5 @@ import { DocumentNode } from 'graphql'; -import { useCallback, useRef, useMemo } from 'react'; +import { useEffect, useCallback, useRef, useMemo } from 'react'; import { pipe, concat, fromValue, switchMap, map, scan } from 'wonka'; import { @@ -63,16 +63,14 @@ export function useSubscription( [client, request, args.context] ); + const subscription$ = useMemo(() => { + return args.pause ? null : makeSubscription$(); + }, [args.pause, makeSubscription$]); + const [state, update] = useSource( - useMemo(() => (args.pause ? null : makeSubscription$()), [ - args.pause, - makeSubscription$, - ]), + subscription$, useCallback( - ( - subscription$$, - prevState: UseSubscriptionState | undefined - ) => { + (subscription$$, prevState?: UseSubscriptionState) => { return pipe( subscription$$, switchMap(subscription$ => { @@ -123,5 +121,9 @@ export function useSubscription( [update, makeSubscription$] ); + useEffect(() => { + update(subscription$); + }, [update, subscription$]); + return [state, executeSubscription]; } diff --git a/packages/react-urql/src/test-utils/ssr.test.tsx b/packages/react-urql/src/test-utils/ssr.test.tsx index 872ef64a1f..42746e7661 100644 --- a/packages/react-urql/src/test-utils/ssr.test.tsx +++ b/packages/react-urql/src/test-utils/ssr.test.tsx @@ -25,6 +25,7 @@ const context: OperationContext = { }, requestPolicy: 'cache-first', url: 'http://localhost:3000/graphql', + suspense: true, }; export const queryGql: GraphQLRequest = { @@ -97,23 +98,6 @@ describe('server-side rendering', () => { }); }); - it('correctly executes suspense and populates the SSR cache', async () => { - let promise; - - try { - pipe(client.executeRequestOperation(queryOperation), publish); - } catch (error) { - promise = error; - } - - expect(promise).toBeInstanceOf(Promise); - const result = await promise; - expect(result.data).not.toBe(undefined); - - const data = ssr.extractData(); - expect(Object.keys(data).length).toBe(1); - }); - it('works for an actual component tree', async () => { const Query = () => { useQuery({