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.

Merge branch 'feat/add-suspense-exchange'

+492 -6
+6
.changeset/famous-starfishes-confess.md
··· 1 + --- 2 + '@urql/exchange-suspense': patch 3 + --- 4 + 5 + Move @urql/exchange-suspense to the monorepo and switch it over to @urql/core 6 + See: [`83325a9`](https://github.com/FormidableLabs/urql/commit/83325a9)
-6
exchanges/graphcache/README.md
··· 5 5 <a href="https://npmjs.com/package/@urql/exchange-graphcache"> 6 6 <img alt="NPM Version" src="https://img.shields.io/npm/v/@urql/exchange-graphcache.svg" /> 7 7 </a> 8 - <a href="https://travis-ci.com/FormidableLabs/urql-exchange-graphcache"> 9 - <img alt="Test Status" src="https://api.travis-ci.com/FormidableLabs/urql-exchange-graphcache.svg?branch=master" /> 10 - </a> 11 - <a href="https://codecov.io/gh/formidablelabs/urql-exchange-graphcache"> 12 - <img alt="Test Coverage" src="https://codecov.io/gh/formidablelabs/urql-exchange-graphcache/branch/master/graph/badge.svg" /> 13 - </a> 14 8 <a href="https://bundlephobia.com/result?p=@urql/exchange-graphcache"> 15 9 <img alt="Minified gzip size" src="https://img.shields.io/bundlephobia/minzip/@urql/exchange-graphcache.svg?label=gzip%20size" /> 16 10 </a>
+5
exchanges/suspense/CHANGELOG.md
··· 1 + # Changelog 2 + 3 + ## v0.1.0 4 + 5 + **Initial Release**
+21
exchanges/suspense/LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2018–2020 Formidable 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+163
exchanges/suspense/README.md
··· 1 + <h2 align="center">@urql/exchange-suspense</h2> 2 + <p align="center"> 3 + <strong>An exchange for client-side React Suspense support in <code>urql</code></strong> 4 + <br /><br /> 5 + <a href="https://npmjs.com/package/@urql/exchange-suspense"> 6 + <img alt="NPM Version" src="https://img.shields.io/npm/v/@urql/exchange-suspense.svg" /> 7 + </a> 8 + <a href="https://bundlephobia.com/result?p=@urql/exchange-suspense"> 9 + <img alt="Minified gzip size" 10 + src="https://img.shields.io/bundlephobia/minzip/@urql/exchange-suspense.svg?label=gzip%20size" /> 11 + </a> 12 + <a href="https://github.com/FormidableLabs/urql-exchange-suspense#maintenance-status"> 13 + <img alt="Maintenance Status" src="https://img.shields.io/badge/maintenance-experimental-blueviolet.svg" /> 14 + </a> 15 + </p> 16 + 17 + `@urql/exchange-suspense` is an exchange for the [`urql`](../../README.md) GraphQL client that allows the 18 + use of React Suspense on the client-side with `urql`'s built-in suspense mode. 19 + 20 + `urql` already supports suspense today, but it's typically used to implement prefetching 21 + during server-side rendering with `react-ssr-prepass`, which allows it to execute React 22 + suspense on the server. 23 + But since `<Suspense>` is mainly intended for client-side use it made sense to build and publish 24 + this exchange, which allows you to try out `urql` and suspense in your React app! 25 + 26 + > ⚠️ Note: React's Suspense feature is currently unstable and may still change. 27 + > This exchange is experimental and demonstrates how `urql` already supports and 28 + > interacts with client-side suspense and how it may behave in the future, when React 29 + > Suspense ships and becomes stable. You may use it, but do so at your own risk! 30 + 31 + ## Quick Start Guide 32 + 33 + First install `@urql/exchange-suspense` alongside `urql`: 34 + 35 + ```sh 36 + yarn add @urql/exchange-suspense 37 + # or 38 + npm install --save @urql/exchange-suspense 39 + ``` 40 + 41 + You'll then need to add the `suspenseExchange`, that this package exposes, to your 42 + `urql` Client and set the `suspense` mode to `true`: 43 + 44 + ```js 45 + import { createClient, dedupExchange, cacheExchange, fetchExchange } from 'urql'; 46 + import { suspenseExchange } from '@urql/exchange-suspense'; 47 + 48 + const client = createClient({ 49 + url: 'http://localhost:1234/graphql', 50 + suspense: true, // Enable suspense mode 51 + exchanges: [ 52 + dedupExchange, 53 + suspenseExchange, // Add suspenseExchange to your urql exchanges 54 + cacheExchange, 55 + fetchExchange, 56 + ], 57 + }); 58 + ``` 59 + 60 + **Important:** 61 + In React Suspense when a piece of data is still loading, a promise will 62 + be thrown that tells React to wait for this promise to complete and try rendering the 63 + suspended component again. The `suspenseExchange` works by caching 64 + the result of any operation until React retries, but it doesn't replace the 65 + `cacheExchange`, since it only briefly keeps the result around. 66 + 67 + This means that, in your array of Exchanges, the `suspenseExchange` should be 68 + added _after the `dedupExchange`_ and _before the `cacheExchange`_. 69 + 70 + ## Usage 71 + 72 + After installing `@urql/exchange-suspense` and adding it to your `urql` client, 73 + `urql` will load all your queries in suspense mode. So instead of relying 74 + on the `fetching` flag, you can wrap your components in a `<Suspense>` 75 + element. 76 + 77 + ```js 78 + import React from 'react'; 79 + import { useQuery } from 'urql'; 80 + 81 + const LoadingIndicator = () => <h1>Loading...</h1>; 82 + 83 + const YourContent = () => { 84 + const [result] = useQuery({ query: allPostsQuery }); 85 + // result.fetching will always be false here, as 86 + // this component only renders when it has data 87 + return null; // ... 88 + }; 89 + 90 + <React.Suspense fallback={<LoadingIndicator />}> 91 + <YourContent /> 92 + </React.Suspense>; 93 + ``` 94 + 95 + Note that in React Suspense, the thrown promises bubble up the component tree until the first `React.Suspense` boundary. This means that the Suspense boundary does not need to be the immediate parent of the component that does the fetching! You should place it in the component hierarchy wherever you want to see the fallback loading indicator, e.g. 96 + 97 + ```js 98 + <React.Suspense fallback={<LoadingIndicator />}> 99 + <AnyOtherComponent> 100 + <AsDeepAsYouWant> 101 + <YourContent /> 102 + </AsDeepAsYouWant> 103 + </AnyOtherComponent> 104 + </React.Suspense> 105 + ``` 106 + 107 + [You can also find a fully working demo on CodeSandbox.](https://codesandbox.io/s/urql-client-side-suspense-demo-81obe) 108 + 109 + ## Caveats 110 + 111 + ### About server-side usage 112 + 113 + The suspense exchange is not intended to work for server-side rendering suspense! This is 114 + what the `ssrExchange` is intended for and it's built into the main `urql` package. The 115 + `suspenseExchange` however is just intended for client-side suspense and use with 116 + `<React.Suspense>`. 117 + 118 + The `<React.Suspense>` element currently won't even be rendered during server-side rendering, 119 + and has been disabled in `react-dom/server`. So if you use `suspenseExchange` and 120 + `<React.Suspense>` in your server-side code you may see some unexpected behaviour and 121 + errors. 122 + 123 + ### Usage with `ssrExchange` 124 + 125 + If you're also using the `ssrExchange` for server-side rendered data, you will have to use 126 + an additonal flag to indicate to it when it's running on the server-side and when it's running 127 + on the client-side. 128 + 129 + By default, the `ssrExchange` will look at `client.suspense`. If the `urql` Client is in suspense 130 + mode then the `ssrExchange` assumes that it's running on the server-side. When it's not 131 + in suspense mode (`!client.suspense`) it assumes that it's running on the client-side. 132 + 133 + When you're using `@urql/exchange-suspense` you'll enable the suspense mode on the 134 + client-side as well, which means that you'll have to tell the `ssrExchange` manually 135 + when it's running on the client-side. 136 + 137 + Most of the time you can achieve this by checking `process.browser` in any Webpack 138 + environment. The `ssrExchange` accepts an `isClient` flag that you can set to 139 + true on the client-side. 140 + 141 + ```js 142 + const isClient = !!process.browser; 143 + 144 + const client = createClient({ 145 + url: 'http://localhost:1234/graphql', 146 + suspense: true, 147 + exchanges: [ 148 + dedupExchange, 149 + isClient && suspenseExchange, 150 + ssrExchange({ 151 + initialData: isClient ? window.URQL_DATA : undefined, 152 + // This will need to be passed explicitly to ssrExchange: 153 + isClient: !!isClient 154 + }) 155 + cacheExchange, 156 + fetchExchange, 157 + ].filter(Boolean), 158 + }); 159 + ``` 160 + 161 + ## Maintenance Status 162 + 163 + **Experimental:** This project is quite new. We're not sure what our ongoing maintenance plan for this project will be. Bug reports, feature requests and pull requests are welcome. If you like this project, let us know by starring the repo!
+57
exchanges/suspense/package.json
··· 1 + { 2 + "name": "@urql/exchange-suspense", 3 + "version": "1.8.2", 4 + "description": "An exchange for client-side React Suspense support in urql", 5 + "sideEffects": false, 6 + "homepage": "https://formidable.com/open-source/urql/docs/", 7 + "bugs": "https://github.com/FormidableLabs/urql/issues", 8 + "license": "MIT", 9 + "repository": { 10 + "type": "git", 11 + "url": "https://github.com/FormidableLabs/urql.git", 12 + "directory": "exchanges/suspense" 13 + }, 14 + "keywords": [ 15 + "urql", 16 + "graphql client", 17 + "formidablelabs", 18 + "exchanges", 19 + "react", 20 + "suspense" 21 + ], 22 + "main": "dist/urql-exchange-suspense.cjs.js", 23 + "module": "dist/urql-exchange-suspense.esm.js", 24 + "types": "dist/types/index.d.ts", 25 + "source": "src/index.ts", 26 + "files": [ 27 + "LICENSE", 28 + "CHANGELOG.md", 29 + "README.md", 30 + "dist/" 31 + ], 32 + "scripts": { 33 + "test": "jest", 34 + "clean": "rimraf dist", 35 + "check": "tsc --noEmit", 36 + "build": "rollup -c ../../scripts/rollup/config.js", 37 + "prepare": "../../scripts/prepare/index.js", 38 + "prepublishOnly": "run-s clean test build" 39 + }, 40 + "jest": { 41 + "preset": "../../scripts/jest/preset" 42 + }, 43 + "devDependencies": { 44 + "@types/react": "^16.9.19", 45 + "graphql": "^14.6.0", 46 + "react": "^16.12.0", 47 + "react-dom": "^16.12.0" 48 + }, 49 + "peerDependencies": { 50 + "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0", 51 + "react": ">= 16.8.0" 52 + }, 53 + "dependencies": { 54 + "@urql/core": ">= 1.8.0", 55 + "wonka": "^4.0.7" 56 + } 57 + }
+1
exchanges/suspense/src/index.ts
··· 1 + export { suspenseExchange } from './suspenseExchange';
+126
exchanges/suspense/src/suspenseExchange.test.ts
··· 1 + import { 2 + pipe, 3 + map, 4 + fromValue, 5 + fromArray, 6 + toArray, 7 + makeSubject, 8 + forEach, 9 + delay, 10 + } from 'wonka'; 11 + 12 + import { createClient, Operation, OperationResult } from 'urql'; 13 + import { suspenseExchange } from './suspenseExchange'; 14 + 15 + beforeEach(() => { 16 + jest.useFakeTimers(); 17 + }); 18 + 19 + afterEach(() => { 20 + jest.useRealTimers(); 21 + }); 22 + 23 + it('logs a warning if suspense mode is not activated', () => { 24 + const warn = jest.spyOn(console, 'warn').mockImplementation(() => { /* noop */ }); 25 + const client = createClient({ url: 'https://example.com', suspense: false }); 26 + const forward = jest.fn(() => fromArray([])); 27 + const ops = fromArray([]); 28 + 29 + suspenseExchange({ client, forward })(ops); 30 + expect(forward).toHaveBeenCalledWith(ops); 31 + expect(warn).toHaveBeenCalled(); 32 + warn.mockRestore(); 33 + }); 34 + 35 + it('forwards skipped operations', () => { 36 + const client = createClient({ url: 'https://example.com', suspense: true }); 37 + const operation = client.createRequestOperation('mutation', { 38 + key: 123, 39 + query: {} as any, 40 + }); 41 + const forward = ops => 42 + pipe( 43 + ops, 44 + map(operation => ({ operation } as OperationResult)) 45 + ); 46 + 47 + const res = pipe( 48 + suspenseExchange({ client, forward })(fromValue(operation)), 49 + toArray 50 + ); 51 + 52 + expect(res).toEqual([{ operation }]); 53 + }); 54 + 55 + it('resolves synchronous results immediately', () => { 56 + let prevResult; 57 + 58 + const client = createClient({ url: 'https://example.com', suspense: true }); 59 + const operation = client.createRequestOperation('query', { 60 + key: 123, 61 + query: {} as any, 62 + }); 63 + const resolveResult = jest.fn( 64 + operation => ({ operation } as OperationResult) 65 + ); 66 + const forward = ops => 67 + pipe( 68 + ops, 69 + map(resolveResult) 70 + ); 71 + const { source: ops, next: dispatch } = makeSubject<Operation>(); 72 + 73 + pipe( 74 + suspenseExchange({ client, forward })(ops), 75 + forEach(result => (prevResult = result)) 76 + ); 77 + 78 + dispatch(operation); 79 + expect(prevResult).toEqual({ operation }); 80 + prevResult = undefined; 81 + 82 + dispatch(operation); 83 + expect(prevResult).toEqual({ operation }); 84 + prevResult = undefined; 85 + 86 + expect(resolveResult).toHaveBeenCalledTimes(2); 87 + }); 88 + 89 + it('caches asynchronous results once for suspense', () => { 90 + let prevResult; 91 + 92 + const client = createClient({ url: 'https://example.com', suspense: true }); 93 + const operation = client.createRequestOperation('query', { 94 + key: 123, 95 + query: {} as any, 96 + }); 97 + const resolveResult = jest.fn( 98 + operation => ({ operation } as OperationResult) 99 + ); 100 + const forward = ops => 101 + pipe( 102 + ops, 103 + delay(1), 104 + map(resolveResult) 105 + ); 106 + const { source: ops, next: dispatch } = makeSubject<Operation>(); 107 + 108 + pipe( 109 + suspenseExchange({ client, forward })(ops), 110 + forEach(result => (prevResult = result)) 111 + ); 112 + 113 + dispatch(operation); 114 + expect(resolveResult).toHaveBeenCalledTimes(0); // Delayed so not called yet 115 + expect(prevResult).toBe(undefined); 116 + 117 + jest.advanceTimersByTime(1); 118 + 119 + expect(resolveResult).toHaveBeenCalledTimes(1); // Called after timer advanced 120 + expect(prevResult).toEqual({ operation }); 121 + prevResult = undefined; 122 + 123 + dispatch(operation); 124 + expect(resolveResult).toHaveBeenCalledTimes(1); // Not called again due to suspense cache 125 + expect(prevResult).toEqual({ operation }); 126 + });
+101
exchanges/suspense/src/suspenseExchange.ts
··· 1 + import { pipe, share, filter, merge, map, onPush } from 'wonka'; 2 + import { Exchange, OperationResult, Operation } from '@urql/core'; 3 + 4 + type SuspenseCache = Map<number, OperationResult>; 5 + type SuspenseKeys = Set<number>; 6 + 7 + const shouldSkip = ({ operationName }: Operation) => 8 + operationName !== 'subscription' && operationName !== 'query'; 9 + 10 + export const suspenseExchange: Exchange = ({ client, forward }) => { 11 + // Warn and disable the suspenseExchange when the client's suspense mode isn't enabled 12 + if (!client.suspense) { 13 + if (process.env.NODE_ENV !== 'production') { 14 + console.warn( 15 + '[@urql/exchange-suspense]: suspenseExchange is currently disabled.\n' + 16 + 'To use the suspense exchange with urql the Client needs to put into suspense mode.' + 17 + 'You can do so by passing `suspense: true` when creating the client.' 18 + ); 19 + } 20 + 21 + return ops$ => forward(ops$); 22 + } 23 + 24 + const cache = new Map() as SuspenseCache; 25 + const keys = new Set() as SuspenseKeys; 26 + 27 + const isOperationCached = (operation: Operation) => cache.has(operation.key); 28 + 29 + const isResultImmediate = (result: OperationResult) => 30 + keys.has(result.operation.key); 31 + 32 + return ops$ => { 33 + const sharedOps$ = share(ops$); 34 + 35 + // Every uncached operation that isn't skipped will be marked as immediate and forwarded 36 + const forwardResults$ = pipe( 37 + sharedOps$, 38 + filter(op => shouldSkip(op) || !isOperationCached(op)), 39 + onPush(op => { 40 + if (!shouldSkip(op)) keys.add(op.key); 41 + }), 42 + forward, 43 + share 44 + ); 45 + 46 + // Results that are skipped by suspense (mutations) 47 + const ignoredResults$ = pipe( 48 + forwardResults$, 49 + filter(res => shouldSkip(res.operation)) 50 + ); 51 + 52 + // Results that may have suspended since they did not resolve synchronously 53 + const deferredResults$ = pipe( 54 + forwardResults$, 55 + filter( 56 + res => !shouldSkip(res.operation) && !isOperationCached(res.operation) 57 + ), 58 + onPush((res: OperationResult) => { 59 + const { key } = res.operation; 60 + keys.delete(key); 61 + if (isResultImmediate(res)) { 62 + cache.delete(key); 63 + } else { 64 + cache.set(key, res); 65 + } 66 + }) 67 + ); 68 + 69 + // Every uncached operation that is returned synchronously will be unmarked so that 70 + // deferredResults$ ignores it 71 + const immediateResults$ = pipe( 72 + sharedOps$, 73 + filter(op => !shouldSkip(op) && !isOperationCached(op)), 74 + onPush(op => { 75 + if (!shouldSkip(op)) keys.delete(op.key); 76 + }), 77 + filter<any>(() => false) 78 + ); 79 + 80 + // OperationResults that have been previously cached will be resolved once 81 + // by the suspenseExchange, and will be deleted from the cache immediately after 82 + const cachedResults$ = pipe( 83 + sharedOps$, 84 + filter(op => !shouldSkip(op) && isOperationCached(op)), 85 + map(op => { 86 + const { key } = op; 87 + const result = cache.get(key) as OperationResult; 88 + cache.delete(key); 89 + keys.delete(key); 90 + return result; 91 + }) 92 + ); 93 + 94 + return merge([ 95 + ignoredResults$, 96 + deferredResults$, 97 + immediateResults$, 98 + cachedResults$, 99 + ]); 100 + }; 101 + };
+12
exchanges/suspense/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.json", 3 + "include": ["src"], 4 + "compilerOptions": { 5 + "baseUrl": "./", 6 + "paths": { 7 + "urql": ["../../node_modules/urql/src"], 8 + "*-urql": ["../../node_modules/*-urql/src"], 9 + "@urql/*": ["../../node_modules/@urql/*/src"] 10 + } 11 + } 12 + }