Repository: FormidableLabs/urql
Pull request #903 (2020-07-15 11:33:36)

(exchanges) - implement the focusExchange
This exchange is meant to avoid stale queries, everytime the window reenters focus the currently active queries will be refetched with a `cache-and-network` policy.

TODO:
- tests
- docs

changeset-bot[bot]: ###  💥  No Changeset

Latest commit: f5d04f0f92c5f177c9e24f8d5e71703a085f0be2

Merging this PR will not cause any packages to be released. If these changes should not cause updates to packages in this repo, this is fine 🙂

**If these changes should be published to npm, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/FormidableLabs/urql/new/focus-exchange?filename=.changeset/dull-panthers-boil.md&value=---%0A%22%40urql%2Fexchange-refocus%22%3A%20patch%0A---%0A%0A(exchanges)%20-%20implement%20the%20focusExchange%0A)



Patch:
diff --git a/.changeset/chatty-poems-film.md b/.changeset/chatty-poems-film.md
new file mode 100644
index 00000000..49b9c56b
--- /dev/null
+++ b/.changeset/chatty-poems-film.md
@@ -0,0 +1,5 @@
+---
+'next-urql': minor
+---
+
+Add option called `neverSuspend` to disable `React.Suspense` on next.js.
diff --git a/docs/advanced/server-side-rendering.md b/docs/advanced/server-side-rendering.md
index 533ce5d4..b8e72373 100644
--- a/docs/advanced/server-side-rendering.md
+++ b/docs/advanced/server-side-rendering.md
@@ -241,6 +241,11 @@ export default withUrqlClient(ssrExchange => ({
 Unless the component that is being wrapped already has a `getInitialProps` method, `next-urql` won't add its own SSR logic, which automatically fetches queries during
 server-side rendering. This can be explicitly enabled by passing the `{ ssr: true }` option as a second argument to `withUrqlClient`.
 
+When you are using `getStaticProps`, `getServerSideProps`, or `getStaticPaths`,  you should opt-out of `Suspense` by setting the `neverSuspend` option to `true` in your `withUrqlClient` configuration.
+your `withUrqlClient`.
+During the prepass of your component tree `next-urql` can't know how these functions will alter the props passed to your page component. This injection
+could change the `variables` used in your `useQuery`. This will lead to error being thrown during the subsequent `toString` pass, which isn't supported in React 16.
+
 ### Resetting the client instance
 
 In rare scenario's you possibly will have to reset the client instance (reset all cache, ...), this is an uncommon scenario
diff --git a/docs/api/README.md b/docs/api/README.md
index 2f91a4cb..eea0220f 100644
--- a/docs/api/README.md
+++ b/docs/api/README.md
@@ -21,3 +21,4 @@ more about the core package on the "Core Package" page.](../concepts/core-packag
 - [`@urql/exchange-persisted-fetch` API docs](./persisted-fetch-exchange.md)
 - [`@urql/exchange-request-policy` API docs](./request-policy-exchange.md)
 - [`@urql/exchange-auth` API docs](./auth-exchange.md)
+- [`@urql/exchange-refocus` API docs](./refocus-exchange.md)
diff --git a/docs/api/refocus-exchange.md b/docs/api/refocus-exchange.md
new file mode 100644
index 00000000..268f0b37
--- /dev/null
+++ b/docs/api/refocus-exchange.md
@@ -0,0 +1,32 @@
+---
+title: '@urql/exchange-refocus'
+order: 10
+---
+
+# Refocus exchange
+
+`@urql/exchange-refocus` is an exchange for the `urql` that tracks currently active operations and redispatches them when the
+window regains focus
+
+## Quick Start Guide
+
+First install `@urql/exchange-refocus` alongside `urql`:
+
+```sh
+yarn add @urql/exchange-refocus
+# or
+npm install --save @urql/exchange-refocus
+```
+
+Then add it to your `Client`, preferably after the `dedupExchange` but in front of any asynchronous
+exchanges, like the `fetchExchange`:
+
+```js
+import { createClient, dedupExchange, cacheExchange, fetchExchange } from 'urql';
+import { refocusExchange } from '@urql/exchange-refocus';
+
+const client = createClient({
+  url: '/graphql',
+  exchanges: [dedupExchange, refocusExchange(), cacheExchange, fetchExchange],
+});
+```
diff --git a/docs/concepts/exchanges.md b/docs/concepts/exchanges.md
index b19852f1..ffd481e6 100644
--- a/docs/concepts/exchanges.md
+++ b/docs/concepts/exchanges.md
@@ -32,6 +32,8 @@ Other available exchanges:
 - [`authExchange`](../api/auth-exchange.md): Allows complex authentication flows to be implemented
   easily.
 - [`requestPolicyExchange`](../api/request-policy-exchange.md): Automatically upgrades `cache-only` and `cache-first` operations to `cache-and-network` after a given amount of time.
+- [`refocusExchange`](../api/refocus-exchange.md): Tracks open queries and refetches them
+  when the window regains focus.
 - `devtoolsExchange`: Provides the ability to use the [urql-devtools](https://github.com/FormidableLabs/urql-devtools)
 
 It is also possible to apply custom exchanges to override the default logic.
diff --git a/exchanges/refocus/CHANGELOG.md b/exchanges/refocus/CHANGELOG.md
new file mode 100644
index 00000000..8c30f86a
--- /dev/null
+++ b/exchanges/refocus/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## v0.1.0
+
+**Initial Release**
diff --git a/exchanges/refocus/README.md b/exchanges/refocus/README.md
new file mode 100644
index 00000000..a9095bcd
--- /dev/null
+++ b/exchanges/refocus/README.md
@@ -0,0 +1,27 @@
+# @urql/exchange-refocus
+
+`@urql/exchange-refocus` is an exchange for the [`urql`](../../README.md) GraphQL client that tracks currently active operations and redispatches them when the
+window regains focus
+
+## Quick Start Guide
+
+First install `@urql/exchange-refocus` alongside `urql`:
+
+```sh
+yarn add @urql/exchange-refocus
+# or
+npm install --save @urql/exchange-refocus
+```
+
+Then add it to your `Client`, preferably after the `dedupExchange` but in front of any asynchronous
+exchanges, like the `fetchExchange`:
+
+```js
+import { createClient, dedupExchange, cacheExchange, fetchExchange } from 'urql';
+import { refocusExchange } from '@urql/exchange-refocus';
+
+const client = createClient({
+  url: '/graphql',
+  exchanges: [dedupExchange, refocusExchange(), cacheExchange, fetchExchange],
+});
+```
diff --git a/exchanges/refocus/package.json b/exchanges/refocus/package.json
new file mode 100644
index 00000000..aec05abf
--- /dev/null
+++ b/exchanges/refocus/package.json
@@ -0,0 +1,69 @@
+{
+  "name": "@urql/exchange-refocus",
+  "version": "0.1.0",
+  "description": "An exchange that dispatches active operations when the window regains focus",
+  "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/refocus"
+  },
+  "keywords": [
+    "urql",
+    "graphql client",
+    "formidablelabs",
+    "exchanges",
+    "react",
+    "focus"
+  ],
+  "main": "dist/urql-exchange-refocus",
+  "module": "dist/urql-exchange-refocus.mjs",
+  "types": "dist/types/index.d.ts",
+  "source": "src/index.ts",
+  "exports": {
+    ".": {
+      "import": "./dist/urql-exchange-refocus.mjs",
+      "require": "./dist/urql-exchange-refocus.js",
+      "types": "./dist/types/index.d.ts",
+      "source": "./src/index.ts"
+    },
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "LICENSE",
+    "CHANGELOG.md",
+    "README.md",
+    "dist/"
+  ],
+  "scripts": {
+    "test": "jest",
+    "clean": "rimraf dist",
+    "check": "tsc --noEmit",
+    "lint": "eslint --ext=js,jsx,ts,tsx .",
+    "build": "rollup -c ../../scripts/rollup/config.js",
+    "prepare": "node ../../scripts/prepare/index.js",
+    "prepublishOnly": "run-s clean build"
+  },
+  "jest": {
+    "preset": "../../scripts/jest/preset"
+  },
+  "devDependencies": {
+    "@types/react": "^16.9.19",
+    "graphql": "^15.1.0",
+    "react": "^16.13.0",
+    "react-dom": "^16.13.0"
+  },
+  "peerDependencies": {
+    "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0"
+  },
+  "dependencies": {
+    "@urql/core": ">=1.12.0",
+    "wonka": "^4.0.14"
+  },
+  "publishConfig": {
+    "access": "public"
+  }
+}
diff --git a/exchanges/refocus/src/index.ts b/exchanges/refocus/src/index.ts
new file mode 100644
index 00000000..632b33a7
--- /dev/null
+++ b/exchanges/refocus/src/index.ts
@@ -0,0 +1,1 @@
+export { refocusExchange } from './refocusExchange';
diff --git a/exchanges/refocus/src/refocusExchange.ts b/exchanges/refocus/src/refocusExchange.ts
new file mode 100644
index 00000000..ecf65987
--- /dev/null
+++ b/exchanges/refocus/src/refocusExchange.ts
@@ -0,0 +1,33 @@
+import { pipe, tap } from 'wonka';
+import { Exchange, Operation } from '@urql/core';
+
+export const refocusExchange = (): Exchange => {
+  return ({ client, forward }) => ops$ => {
+    const watchedOperations = new Map<number, Operation>();
+    const observedOperations = new Map<number, number>();
+
+    window.addEventListener('focus', () => {
+      watchedOperations.forEach(op => {
+        client.reexecuteOperation(
+          client.createRequestOperation('query', op, {
+            requestPolicy: 'cache-and-network',
+          })
+        );
+      });
+    });
+
+    const processIncomingOperation = (op: Operation) => {
+      if (op.operationName === 'query' && !observedOperations.has(op.key)) {
+        observedOperations.set(op.key, 1);
+        watchedOperations.set(op.key, op);
+      }
+
+      if (op.operationName === 'teardown' && observedOperations.has(op.key)) {
+        observedOperations.delete(op.key);
+        watchedOperations.delete(op.key);
+      }
+    };
+
+    return forward(pipe(ops$, tap(processIncomingOperation)));
+  };
+};
diff --git a/exchanges/refocus/tsconfig.json b/exchanges/refocus/tsconfig.json
new file mode 100644
index 00000000..5797ce61
--- /dev/null
+++ b/exchanges/refocus/tsconfig.json
@@ -0,0 +1,13 @@
+{
+  "extends": "../../tsconfig.json",
+  "include": ["src"],
+  "compilerOptions": {
+    "baseUrl": "./",
+    "paths": {
+      "urql": ["../../node_modules/urql/src"],
+      "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/core/*": ["../../node_modules/@urql/core/src/*"],
+      "@urql/*": ["../../node_modules/@urql/*/src"]
+    }
+  }
+}
diff --git a/packages/next-urql/README.md b/packages/next-urql/README.md
index 9d8e6379..2ea48d25 100644
--- a/packages/next-urql/README.md
+++ b/packages/next-urql/README.md
@@ -246,3 +246,8 @@ Using `withUrqlClient` on a page that has `getInitialProps` will opt that compon
 component has children using `getInitialProps` but that component itself is not and you want to opt in to this behavior you'll have to set the second argument of
 `withUrqlClient`, this means `withUrqlClient(() => clientOptiosn, { ssr:true })`.
 This measure is available so we can support `getStaticProps`, ...
+
+When you are using `getStaticProps`, `getServerSideProps`, or `getStaticPaths`,  you should opt-out of `Suspense` by setting the `neverSuspend` option to `true` in your `withUrqlClient` configuration.
+your `withUrqlClient`.
+During the prepass of your component tree `next-urql` can't know how these functions will alter the props passed to your page component. This injection
+could change the `variables` used in your `useQuery`. This will lead to error being thrown during the subsequent `toString` pass, which isn't supported in React 16.
diff --git a/packages/next-urql/src/types.ts b/packages/next-urql/src/types.ts
index c9541977..d58b39f6 100644
--- a/packages/next-urql/src/types.ts
+++ b/packages/next-urql/src/types.ts
@@ -51,4 +51,5 @@ export interface SSRExchange extends Exchange {
 
 export interface WithUrqlClientOptions {
   ssr?: boolean;
+  neverSuspend?: boolean;
 }
diff --git a/packages/next-urql/src/with-urql-client.tsx b/packages/next-urql/src/with-urql-client.tsx
index 03bbe5b8..8c8538af 100644
--- a/packages/next-urql/src/with-urql-client.tsx
+++ b/packages/next-urql/src/with-urql-client.tsx
@@ -29,9 +29,10 @@ export function withUrqlClient(
   options?: WithUrqlClientOptions
 ) {
   if (!options) options = {};
+
   return (AppOrPage: NextPage<any> | typeof NextApp) => {
-    const shouldBindGetInitialprops = Boolean(
-      AppOrPage.getInitialProps || options!.ssr
+    const shouldEnableSuspense = Boolean(
+      (AppOrPage.getInitialProps || options!.ssr) && !options!.neverSuspend
     );
 
     const withUrql = ({ urqlClient, urqlState, ...rest }: WithUrqlProps) => {
@@ -59,8 +60,7 @@ export function withUrqlClient(
         }
 
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-        return initUrqlClient(clientConfig, shouldBindGetInitialprops)!;
-        // eslint-disable-next-line react-hooks/exhaustive-deps
+        return initUrqlClient(clientConfig, shouldEnableSuspense)!;
       }, [urqlClient, urqlState, forceUpdate[0]]);
 
       const resetUrqlClient = () => {
@@ -83,7 +83,7 @@ export function withUrqlClient(
     // Set the displayName to indicate use of withUrqlClient.
     withUrql.displayName = `withUrqlClient(${getDisplayName(AppOrPage)})`;
 
-    if (shouldBindGetInitialprops) {
+    if (AppOrPage.getInitialProps || options!.ssr) {
       withUrql.getInitialProps = async (appOrPageCtx: NextUrqlContext) => {
         const { AppTree } = appOrPageCtx;
 
@@ -104,7 +104,8 @@ export function withUrqlClient(
             fetchExchange,
           ];
         }
-        const urqlClient = initUrqlClient(clientConfig, true);
+
+        const urqlClient = initUrqlClient(clientConfig, !options!.neverSuspend);
 
         if (urqlClient) {
           (ctx as NextUrqlContext).urqlClient = urqlClient;
@@ -127,7 +128,8 @@ export function withUrqlClient(
         const appTreeProps = isApp ? props : { pageProps: props };
 
         // Run the prepass step on AppTree. This will run all urql queries on the server.
-        await ssrPrepass(<AppTree {...appTreeProps} />);
+        if (!options!.neverSuspend)
+          await ssrPrepass(<AppTree {...appTreeProps} />);
 
         return {
           ...pageProps,


Test patch:
diff --git a/exchanges/refocus/src/refocusExchange.test.ts b/exchanges/refocus/src/refocusExchange.test.ts
new file mode 100644
index 00000000..b6a988cd
--- /dev/null
+++ b/exchanges/refocus/src/refocusExchange.test.ts
@@ -0,0 +1,92 @@
+import gql from 'graphql-tag';
+
+import { pipe, map, makeSubject, publish, tap } from 'wonka';
+
+import {
+  createClient,
+  Operation,
+  OperationResult,
+  ExchangeIO,
+} from '@urql/core';
+import { refocusExchange } from './refocusExchange';
+
+const dispatchDebug = jest.fn();
+
+const queryOne = gql`
+  {
+    author {
+      id
+      name
+    }
+  }
+`;
+
+const queryOneData = {
+  __typename: 'Query',
+  author: {
+    __typename: 'Author',
+    id: '123',
+    name: 'Author',
+  },
+};
+
+let client, op, ops$, next;
+beforeEach(() => {
+  client = createClient({ url: 'http://0.0.0.0' });
+  op = client.createRequestOperation('query', {
+    key: 1,
+    query: queryOne,
+  });
+
+  ({ source: ops$, next } = makeSubject<Operation>());
+});
+
+it(`attaches a listener and redispatches queries on call`, () => {
+  const response = jest.fn(
+    (forwardOp: Operation): OperationResult => {
+      return {
+        operation: forwardOp,
+        data: queryOneData,
+      };
+    }
+  );
+
+  let listener;
+  const spy = jest
+    .spyOn(window, 'addEventListener')
+    .mockImplementation((_keyword, fn) => {
+      listener = fn;
+    });
+  const reexecuteSpy = jest
+    .spyOn(client, 'reexecuteOperation')
+    .mockImplementation(() => ({}));
+
+  const result = jest.fn();
+  const forward: ExchangeIO = ops$ => {
+    return pipe(ops$, map(response));
+  };
+
+  pipe(
+    refocusExchange()({
+      forward,
+      client,
+      dispatchDebug,
+    })(ops$),
+    tap(result),
+    publish
+  );
+
+  expect(spy).toBeCalledTimes(1);
+  expect(spy).toBeCalledWith('focus', expect.anything());
+
+  next(op);
+
+  listener();
+  expect(reexecuteSpy).toBeCalledTimes(1);
+  expect(reexecuteSpy).toBeCalledWith({
+    context: expect.anything(),
+    key: 1,
+    query: queryOne,
+    operationName: 'query',
+  });
+});
diff --git a/packages/next-urql/src/__tests__/with-urql-client.spec.tsx b/packages/next-urql/src/__tests__/with-urql-client.spec.tsx
index 95e4b668..679acf19 100644
--- a/packages/next-urql/src/__tests__/with-urql-client.spec.tsx
+++ b/packages/next-urql/src/__tests__/with-urql-client.spec.tsx
@@ -162,4 +162,50 @@ describe('withUrqlClient', () => {
       expect(exchange).toBeCalledTimes(1);
     });
   });
+
+  describe('never-suspend', () => {
+    // Simulate a token that might be passed in a request to the server-rendered application.
+    const token = Math.random().toString(36).slice(-10);
+    let mockSsrExchange;
+
+    const mockContext: NextUrqlPageContext = {
+      AppTree: MockAppTree,
+      pathname: '/',
+      query: {},
+      asPath: '/',
+      req: {
+        headers: {
+          cookie: token,
+        },
+      } as NextUrqlPageContext['req'],
+      urqlClient: {} as Client,
+    };
+
+    beforeEach(() => {
+      Component = withUrqlClient(
+        (ssrExchange, ctx) => ({
+          url: 'http://localhost:3000',
+          fetchOptions: {
+            headers: { Authorization: (ctx && ctx.req!.headers!.cookie) || '' },
+          },
+          exchanges: [(mockSsrExchange = ssrExchange)],
+        }),
+        { ssr: true, neverSuspend: true }
+      )(MockApp);
+    });
+
+    it('should not enable suspense', async () => {
+      Component.getInitialProps &&
+        (await Component.getInitialProps(mockContext));
+
+      expect(spyInitUrqlClient).toHaveBeenCalledWith(
+        {
+          url: 'http://localhost:3000',
+          fetchOptions: { headers: { Authorization: token } },
+          exchanges: [mockSsrExchange],
+        },
+        false
+      );
+    });
+  });
 });