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

Merge branch 'feat/add-suspense-exchange'

+6
.changeset/famous-starfishes-confess.md
···
···
+
---
+
'@urql/exchange-suspense': patch
+
---
+
+
Move @urql/exchange-suspense to the monorepo and switch it over to @urql/core
+
See: [`83325a9`](https://github.com/FormidableLabs/urql/commit/83325a9)
-6
exchanges/graphcache/README.md
···
<a href="https://npmjs.com/package/@urql/exchange-graphcache">
<img alt="NPM Version" src="https://img.shields.io/npm/v/@urql/exchange-graphcache.svg" />
</a>
-
<a href="https://travis-ci.com/FormidableLabs/urql-exchange-graphcache">
-
<img alt="Test Status" src="https://api.travis-ci.com/FormidableLabs/urql-exchange-graphcache.svg?branch=master" />
-
</a>
-
<a href="https://codecov.io/gh/formidablelabs/urql-exchange-graphcache">
-
<img alt="Test Coverage" src="https://codecov.io/gh/formidablelabs/urql-exchange-graphcache/branch/master/graph/badge.svg" />
-
</a>
<a href="https://bundlephobia.com/result?p=@urql/exchange-graphcache">
<img alt="Minified gzip size" src="https://img.shields.io/bundlephobia/minzip/@urql/exchange-graphcache.svg?label=gzip%20size" />
</a>
···
<a href="https://npmjs.com/package/@urql/exchange-graphcache">
<img alt="NPM Version" src="https://img.shields.io/npm/v/@urql/exchange-graphcache.svg" />
</a>
<a href="https://bundlephobia.com/result?p=@urql/exchange-graphcache">
<img alt="Minified gzip size" src="https://img.shields.io/bundlephobia/minzip/@urql/exchange-graphcache.svg?label=gzip%20size" />
</a>
+5
exchanges/suspense/CHANGELOG.md
···
···
+
# Changelog
+
+
## v0.1.0
+
+
**Initial Release**
+21
exchanges/suspense/LICENSE
···
···
+
MIT License
+
+
Copyright (c) 2018–2020 Formidable
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy
+
of this software and associated documentation files (the "Software"), to deal
+
in the Software without restriction, including without limitation the rights
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+
copies of the Software, and to permit persons to whom the Software is
+
furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in all
+
copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+
SOFTWARE.
+163
exchanges/suspense/README.md
···
···
+
<h2 align="center">@urql/exchange-suspense</h2>
+
<p align="center">
+
<strong>An exchange for client-side React Suspense support in <code>urql</code></strong>
+
<br /><br />
+
<a href="https://npmjs.com/package/@urql/exchange-suspense">
+
<img alt="NPM Version" src="https://img.shields.io/npm/v/@urql/exchange-suspense.svg" />
+
</a>
+
<a href="https://bundlephobia.com/result?p=@urql/exchange-suspense">
+
<img alt="Minified gzip size"
+
src="https://img.shields.io/bundlephobia/minzip/@urql/exchange-suspense.svg?label=gzip%20size" />
+
</a>
+
<a href="https://github.com/FormidableLabs/urql-exchange-suspense#maintenance-status">
+
<img alt="Maintenance Status" src="https://img.shields.io/badge/maintenance-experimental-blueviolet.svg" />
+
</a>
+
</p>
+
+
`@urql/exchange-suspense` is an exchange for the [`urql`](../../README.md) GraphQL client that allows the
+
use of React Suspense on the client-side with `urql`'s built-in suspense mode.
+
+
`urql` already supports suspense today, but it's typically used to implement prefetching
+
during server-side rendering with `react-ssr-prepass`, which allows it to execute React
+
suspense on the server.
+
But since `<Suspense>` 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!
+
+
> ⚠️ Note: React's Suspense feature is currently unstable and may still change.
+
> This exchange is experimental and demonstrates how `urql` already supports and
+
> interacts with client-side suspense and how it may behave in the future, when React
+
> Suspense ships and becomes stable. You may use it, but do so at your own risk!
+
+
## Quick Start Guide
+
+
First install `@urql/exchange-suspense` alongside `urql`:
+
+
```sh
+
yarn add @urql/exchange-suspense
+
# or
+
npm install --save @urql/exchange-suspense
+
```
+
+
You'll then need to add the `suspenseExchange`, that this package exposes, to your
+
`urql` Client and set the `suspense` mode to `true`:
+
+
```js
+
import { createClient, dedupExchange, cacheExchange, fetchExchange } from 'urql';
+
import { suspenseExchange } from '@urql/exchange-suspense';
+
+
const client = createClient({
+
url: 'http://localhost:1234/graphql',
+
suspense: true, // Enable suspense mode
+
exchanges: [
+
dedupExchange,
+
suspenseExchange, // Add suspenseExchange to your urql exchanges
+
cacheExchange,
+
fetchExchange,
+
],
+
});
+
```
+
+
**Important:**
+
In React Suspense when a piece of data is still loading, a promise will
+
be thrown that tells React to wait for this promise to complete and try rendering the
+
suspended component again. The `suspenseExchange` works by caching
+
the result of any operation until React retries, but it doesn't replace the
+
`cacheExchange`, since it only briefly keeps the result around.
+
+
This means that, in your array of Exchanges, the `suspenseExchange` should be
+
added _after the `dedupExchange`_ and _before the `cacheExchange`_.
+
+
## Usage
+
+
After installing `@urql/exchange-suspense` and adding it to your `urql` client,
+
`urql` will load all your queries in suspense mode. So instead of relying
+
on the `fetching` flag, you can wrap your components in a `<Suspense>`
+
element.
+
+
```js
+
import React from 'react';
+
import { useQuery } from 'urql';
+
+
const LoadingIndicator = () => <h1>Loading...</h1>;
+
+
const YourContent = () => {
+
const [result] = useQuery({ query: allPostsQuery });
+
// result.fetching will always be false here, as
+
// this component only renders when it has data
+
return null; // ...
+
};
+
+
<React.Suspense fallback={<LoadingIndicator />}>
+
<YourContent />
+
</React.Suspense>;
+
```
+
+
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.
+
+
```js
+
<React.Suspense fallback={<LoadingIndicator />}>
+
<AnyOtherComponent>
+
<AsDeepAsYouWant>
+
<YourContent />
+
</AsDeepAsYouWant>
+
</AnyOtherComponent>
+
</React.Suspense>
+
```
+
+
[You can also find a fully working demo on CodeSandbox.](https://codesandbox.io/s/urql-client-side-suspense-demo-81obe)
+
+
## Caveats
+
+
### About server-side usage
+
+
The suspense exchange is not intended to work for server-side rendering suspense! This is
+
what the `ssrExchange` is intended for and it's built into the main `urql` package. The
+
`suspenseExchange` however is just intended for client-side suspense and use with
+
`<React.Suspense>`.
+
+
The `<React.Suspense>` element currently won't even be rendered during server-side rendering,
+
and has been disabled in `react-dom/server`. So if you use `suspenseExchange` and
+
`<React.Suspense>` in your server-side code you may see some unexpected behaviour and
+
errors.
+
+
### Usage with `ssrExchange`
+
+
If you're also using the `ssrExchange` for server-side rendered data, you will have to use
+
an additonal flag to indicate to it when it's running on the server-side and when it's running
+
on the client-side.
+
+
By default, the `ssrExchange` will look at `client.suspense`. If the `urql` Client is in suspense
+
mode then the `ssrExchange` assumes that it's running on the server-side. When it's not
+
in suspense mode (`!client.suspense`) it assumes that it's running on the client-side.
+
+
When you're using `@urql/exchange-suspense` you'll enable the suspense mode on the
+
client-side as well, which means that you'll have to tell the `ssrExchange` manually
+
when it's running on the client-side.
+
+
Most of the time you can achieve this by checking `process.browser` in any Webpack
+
environment. The `ssrExchange` accepts an `isClient` flag that you can set to
+
true on the client-side.
+
+
```js
+
const isClient = !!process.browser;
+
+
const client = createClient({
+
url: 'http://localhost:1234/graphql',
+
suspense: true,
+
exchanges: [
+
dedupExchange,
+
isClient && suspenseExchange,
+
ssrExchange({
+
initialData: isClient ? window.URQL_DATA : undefined,
+
// This will need to be passed explicitly to ssrExchange:
+
isClient: !!isClient
+
})
+
cacheExchange,
+
fetchExchange,
+
].filter(Boolean),
+
});
+
```
+
+
## Maintenance Status
+
+
**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
···
···
+
{
+
"name": "@urql/exchange-suspense",
+
"version": "1.8.2",
+
"description": "An exchange for client-side React Suspense support in urql",
+
"sideEffects": false,
+
"homepage": "https://formidable.com/open-source/urql/docs/",
+
"bugs": "https://github.com/FormidableLabs/urql/issues",
+
"license": "MIT",
+
"repository": {
+
"type": "git",
+
"url": "https://github.com/FormidableLabs/urql.git",
+
"directory": "exchanges/suspense"
+
},
+
"keywords": [
+
"urql",
+
"graphql client",
+
"formidablelabs",
+
"exchanges",
+
"react",
+
"suspense"
+
],
+
"main": "dist/urql-exchange-suspense.cjs.js",
+
"module": "dist/urql-exchange-suspense.esm.js",
+
"types": "dist/types/index.d.ts",
+
"source": "src/index.ts",
+
"files": [
+
"LICENSE",
+
"CHANGELOG.md",
+
"README.md",
+
"dist/"
+
],
+
"scripts": {
+
"test": "jest",
+
"clean": "rimraf dist",
+
"check": "tsc --noEmit",
+
"build": "rollup -c ../../scripts/rollup/config.js",
+
"prepare": "../../scripts/prepare/index.js",
+
"prepublishOnly": "run-s clean test build"
+
},
+
"jest": {
+
"preset": "../../scripts/jest/preset"
+
},
+
"devDependencies": {
+
"@types/react": "^16.9.19",
+
"graphql": "^14.6.0",
+
"react": "^16.12.0",
+
"react-dom": "^16.12.0"
+
},
+
"peerDependencies": {
+
"graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0",
+
"react": ">= 16.8.0"
+
},
+
"dependencies": {
+
"@urql/core": ">= 1.8.0",
+
"wonka": "^4.0.7"
+
}
+
}
+1
exchanges/suspense/src/index.ts
···
···
+
export { suspenseExchange } from './suspenseExchange';
+126
exchanges/suspense/src/suspenseExchange.test.ts
···
···
+
import {
+
pipe,
+
map,
+
fromValue,
+
fromArray,
+
toArray,
+
makeSubject,
+
forEach,
+
delay,
+
} from 'wonka';
+
+
import { createClient, Operation, OperationResult } from 'urql';
+
import { suspenseExchange } from './suspenseExchange';
+
+
beforeEach(() => {
+
jest.useFakeTimers();
+
});
+
+
afterEach(() => {
+
jest.useRealTimers();
+
});
+
+
it('logs a warning if suspense mode is not activated', () => {
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => { /* noop */ });
+
const client = createClient({ url: 'https://example.com', suspense: false });
+
const forward = jest.fn(() => fromArray([]));
+
const ops = fromArray([]);
+
+
suspenseExchange({ client, forward })(ops);
+
expect(forward).toHaveBeenCalledWith(ops);
+
expect(warn).toHaveBeenCalled();
+
warn.mockRestore();
+
});
+
+
it('forwards skipped operations', () => {
+
const client = createClient({ url: 'https://example.com', suspense: true });
+
const operation = client.createRequestOperation('mutation', {
+
key: 123,
+
query: {} as any,
+
});
+
const forward = ops =>
+
pipe(
+
ops,
+
map(operation => ({ operation } as OperationResult))
+
);
+
+
const res = pipe(
+
suspenseExchange({ client, forward })(fromValue(operation)),
+
toArray
+
);
+
+
expect(res).toEqual([{ operation }]);
+
});
+
+
it('resolves synchronous results immediately', () => {
+
let prevResult;
+
+
const client = createClient({ url: 'https://example.com', suspense: true });
+
const operation = client.createRequestOperation('query', {
+
key: 123,
+
query: {} as any,
+
});
+
const resolveResult = jest.fn(
+
operation => ({ operation } as OperationResult)
+
);
+
const forward = ops =>
+
pipe(
+
ops,
+
map(resolveResult)
+
);
+
const { source: ops, next: dispatch } = makeSubject<Operation>();
+
+
pipe(
+
suspenseExchange({ client, forward })(ops),
+
forEach(result => (prevResult = result))
+
);
+
+
dispatch(operation);
+
expect(prevResult).toEqual({ operation });
+
prevResult = undefined;
+
+
dispatch(operation);
+
expect(prevResult).toEqual({ operation });
+
prevResult = undefined;
+
+
expect(resolveResult).toHaveBeenCalledTimes(2);
+
});
+
+
it('caches asynchronous results once for suspense', () => {
+
let prevResult;
+
+
const client = createClient({ url: 'https://example.com', suspense: true });
+
const operation = client.createRequestOperation('query', {
+
key: 123,
+
query: {} as any,
+
});
+
const resolveResult = jest.fn(
+
operation => ({ operation } as OperationResult)
+
);
+
const forward = ops =>
+
pipe(
+
ops,
+
delay(1),
+
map(resolveResult)
+
);
+
const { source: ops, next: dispatch } = makeSubject<Operation>();
+
+
pipe(
+
suspenseExchange({ client, forward })(ops),
+
forEach(result => (prevResult = result))
+
);
+
+
dispatch(operation);
+
expect(resolveResult).toHaveBeenCalledTimes(0); // Delayed so not called yet
+
expect(prevResult).toBe(undefined);
+
+
jest.advanceTimersByTime(1);
+
+
expect(resolveResult).toHaveBeenCalledTimes(1); // Called after timer advanced
+
expect(prevResult).toEqual({ operation });
+
prevResult = undefined;
+
+
dispatch(operation);
+
expect(resolveResult).toHaveBeenCalledTimes(1); // Not called again due to suspense cache
+
expect(prevResult).toEqual({ operation });
+
});
+101
exchanges/suspense/src/suspenseExchange.ts
···
···
+
import { pipe, share, filter, merge, map, onPush } from 'wonka';
+
import { Exchange, OperationResult, Operation } from '@urql/core';
+
+
type SuspenseCache = Map<number, OperationResult>;
+
type SuspenseKeys = Set<number>;
+
+
const shouldSkip = ({ operationName }: Operation) =>
+
operationName !== 'subscription' && operationName !== 'query';
+
+
export const suspenseExchange: Exchange = ({ client, forward }) => {
+
// Warn and disable the suspenseExchange when the client's suspense mode isn't enabled
+
if (!client.suspense) {
+
if (process.env.NODE_ENV !== 'production') {
+
console.warn(
+
'[@urql/exchange-suspense]: suspenseExchange is currently disabled.\n' +
+
'To use the suspense exchange with urql the Client needs to put into suspense mode.' +
+
'You can do so by passing `suspense: true` when creating the client.'
+
);
+
}
+
+
return ops$ => forward(ops$);
+
}
+
+
const cache = new Map() as SuspenseCache;
+
const keys = new Set() as SuspenseKeys;
+
+
const isOperationCached = (operation: Operation) => cache.has(operation.key);
+
+
const isResultImmediate = (result: OperationResult) =>
+
keys.has(result.operation.key);
+
+
return ops$ => {
+
const sharedOps$ = share(ops$);
+
+
// Every uncached operation that isn't skipped will be marked as immediate and forwarded
+
const forwardResults$ = pipe(
+
sharedOps$,
+
filter(op => shouldSkip(op) || !isOperationCached(op)),
+
onPush(op => {
+
if (!shouldSkip(op)) keys.add(op.key);
+
}),
+
forward,
+
share
+
);
+
+
// Results that are skipped by suspense (mutations)
+
const ignoredResults$ = pipe(
+
forwardResults$,
+
filter(res => shouldSkip(res.operation))
+
);
+
+
// Results that may have suspended since they did not resolve synchronously
+
const deferredResults$ = pipe(
+
forwardResults$,
+
filter(
+
res => !shouldSkip(res.operation) && !isOperationCached(res.operation)
+
),
+
onPush((res: OperationResult) => {
+
const { key } = res.operation;
+
keys.delete(key);
+
if (isResultImmediate(res)) {
+
cache.delete(key);
+
} else {
+
cache.set(key, res);
+
}
+
})
+
);
+
+
// Every uncached operation that is returned synchronously will be unmarked so that
+
// deferredResults$ ignores it
+
const immediateResults$ = pipe(
+
sharedOps$,
+
filter(op => !shouldSkip(op) && !isOperationCached(op)),
+
onPush(op => {
+
if (!shouldSkip(op)) keys.delete(op.key);
+
}),
+
filter<any>(() => false)
+
);
+
+
// OperationResults that have been previously cached will be resolved once
+
// by the suspenseExchange, and will be deleted from the cache immediately after
+
const cachedResults$ = pipe(
+
sharedOps$,
+
filter(op => !shouldSkip(op) && isOperationCached(op)),
+
map(op => {
+
const { key } = op;
+
const result = cache.get(key) as OperationResult;
+
cache.delete(key);
+
keys.delete(key);
+
return result;
+
})
+
);
+
+
return merge([
+
ignoredResults$,
+
deferredResults$,
+
immediateResults$,
+
cachedResults$,
+
]);
+
};
+
};
+12
exchanges/suspense/tsconfig.json
···
···
+
{
+
"extends": "../../tsconfig.json",
+
"include": ["src"],
+
"compilerOptions": {
+
"baseUrl": "./",
+
"paths": {
+
"urql": ["../../node_modules/urql/src"],
+
"*-urql": ["../../node_modules/*-urql/src"],
+
"@urql/*": ["../../node_modules/@urql/*/src"]
+
}
+
}
+
}