Repository: FormidableLabs/urql
Pull request #79 (2018-05-15 14:48:08)

Improved query subscription updates
It should be possible to provide a `query` and a `subscription` and to describe how to update the query's result with a subscription's result. In this PR this new behaviour is introduced and the updates will be described using the `updateSubscription` function.

After an event from the subscription, the ClientWrapper will also invalidate the query's cache.


Patch:
diff --git a/src/components/client.tsx b/src/components/client.tsx
index 6d1f063f..24658d55 100644
--- a/src/components/client.tsx
+++ b/src/components/client.tsx
@@ -19,6 +19,10 @@ export interface IClientProps {
   subscription?: IQuery; // Subscription Query object
   query?: IQuery | IQuery[]; // Query object or array of Query objects
   mutation?: IMutation; // Mutation object (map)
+  updateSubscription?: (
+    prev: object | null,
+    next: object | null
+  ) => object | null; // Update query with subscription data
   cache?: boolean;
   typeInvalidation?: boolean;
   shouldInvalidate?: (
@@ -53,13 +57,14 @@ export default class UrqlClient extends Component<IClientProps, IClientState> {
     loaded: false,
   };
 
+  willUpdateSubscription = false; // Flag that indicates the subscription's behaviour
   subscription = null; // Stored Subscription Query
   query = null; // Stored Query
   mutations = {}; // Stored Mutation
   typeNames = []; // Typenames that exist on current query
   unsubscribe = null; // Unsubscription function calling back to the client
-  querySubscription = null; // Subscription for ongoing queries
-  mutationSubscription = null; // Subscription for ongoing mutations
+  subscriptionSub = null; // Subscription for ongoing subscription queries
+  querySub = null; // Subscription for ongoing queries
 
   componentDidMount() {
     this.formatProps(this.props);
@@ -82,12 +87,12 @@ export default class UrqlClient extends Component<IClientProps, IClientState> {
       this.unsubscribe();
     }
 
-    if (this.querySubscription !== null) {
-      this.querySubscription.unsubscribe();
+    if (this.subscriptionSub !== null) {
+      this.subscriptionSub.unsubscribe();
     }
 
-    if (this.mutationSubscription !== null) {
-      this.mutationSubscription.unsubscribe();
+    if (this.querySub !== null) {
+      this.querySub.unsubscribe();
     }
   }
 
@@ -124,19 +129,15 @@ export default class UrqlClient extends Component<IClientProps, IClientState> {
   };
 
   formatProps = props => {
-    if (props.subscription && props.query) {
+    if (props.subscription && props.query && !props.updateSubscription) {
       throw new Error(
-        'Passing a query and a subscription prop at the same time is invalid.'
+        'Passing a query and a subscription prop at the same time without an updateSubscription function is invalid.'
       );
     }
 
-    // If subscription exists
-    if (props.subscription) {
-      // Loop through and add typenames
-      this.subscription = formatTypeNames(props.subscription);
-      // Fetch initial data
-      this.subscribeToQuery();
-    }
+    this.willUpdateSubscription =
+      props.subscription && props.query && props.updateSubscription;
+
     // If query exists
     if (props.query) {
       // Loop through and add typenames
@@ -148,6 +149,15 @@ export default class UrqlClient extends Component<IClientProps, IClientState> {
       // Fetch initial data
       this.fetch(undefined, true);
     }
+
+    // If subscription exists
+    if (props.subscription) {
+      // Loop through and add typenames
+      this.subscription = formatTypeNames(props.subscription);
+      // Fetch initial data
+      this.subscribeToQuery();
+    }
+
     // If mutation exists and has keys
     if (props.mutation) {
       this.mutations = {};
@@ -223,6 +233,11 @@ export default class UrqlClient extends Component<IClientProps, IClientState> {
       skipCache = true;
     }
 
+    if (this.querySub !== null) {
+      this.querySub.unsubscribe();
+      this.querySub = null;
+    }
+
     // Start loading state
     this.setState({
       error: null,
@@ -232,28 +247,30 @@ export default class UrqlClient extends Component<IClientProps, IClientState> {
     // If query is not an array
     if (!Array.isArray(this.query)) {
       // Fetch the query
-      this.querySubscription = client
-        .executeQuery$(this.query, skipCache)
-        .subscribe({
-          error: e => {
-            this.setState({
-              error: e,
-              fetching: false,
-            });
-          },
-          next: result => {
-            // Store the typenames
-            this.typeNames = result.typeNames;
-
-            // Update data
-            this.setState({
-              data: result.data || null,
-              error: result.error,
-              fetching: false,
-              loaded: initial ? true : this.state.loaded,
-            });
-          },
-        });
+      this.querySub = client.executeQuery$(this.query, skipCache).subscribe({
+        complete: () => {
+          this.querySub = null;
+        },
+        error: e => {
+          this.querySub = null;
+          this.setState({
+            error: e,
+            fetching: false,
+          });
+        },
+        next: result => {
+          // Store the typenames
+          this.typeNames = result.typeNames;
+
+          // Update data
+          this.setState({
+            data: result.data || null,
+            error: result.error,
+            fetching: false,
+            loaded: initial ? true : this.state.loaded,
+          });
+        },
+      });
     } else {
       const partialData = [];
       const queries$ = this.query.map(query =>
@@ -271,7 +288,7 @@ export default class UrqlClient extends Component<IClientProps, IClientState> {
         })
       );
 
-      this.querySubscription = zipObservables(queries$).subscribe({
+      this.querySub = zipObservables(queries$).subscribe({
         error: e => {
           this.setState({
             data: partialData.map(part => part.data),
@@ -294,34 +311,62 @@ export default class UrqlClient extends Component<IClientProps, IClientState> {
   };
 
   subscribeToQuery = () => {
-    const { client } = this.props;
+    const { client, updateSubscription } = this.props;
 
-    // Start loading state
-    this.setState({
-      error: null,
-      fetching: true,
-    });
+    if (this.subscriptionSub !== null) {
+      this.subscriptionSub.unsubscribe();
+      this.subscriptionSub = null;
+    }
+
+    if (!this.willUpdateSubscription) {
+      // Start loading state
+      this.setState({
+        error: null,
+        fetching: true,
+      });
+    }
 
     // Fetch the query
-    this.querySubscription = client
+    this.subscriptionSub = client
       .executeSubscription$(this.subscription)
       .subscribe({
+        complete: () => {
+          this.subscriptionSub = null;
+        },
         error: e => {
-          this.querySubscription = null;
-
+          this.subscriptionSub = null;
           this.setState({
             error: e,
             fetching: false,
           });
         },
         next: result => {
+          const nextData = result.data || null;
+
           // Update data
-          this.setState({
-            data: result.data || null,
-            error: result.error,
-            fetching: true,
-            loaded: true,
-          });
+          this.setState(
+            state => ({
+              data: this.willUpdateSubscription
+                ? updateSubscription(state.data || null, nextData)
+                : nextData,
+              error: result.error,
+              fetching: false,
+              loaded: true,
+            }),
+            () => {
+              const invalidate =
+                this.willUpdateSubscription &&
+                this.query &&
+                this.props.typeInvalidation !== false;
+              if (invalidate && Array.isArray(this.query)) {
+                this.query.forEach(query => {
+                  client.invalidateQuery(query);
+                });
+              } else if (invalidate) {
+                client.invalidateQuery(this.query);
+              }
+            }
+          );
         },
       });
   };
@@ -337,7 +382,7 @@ export default class UrqlClient extends Component<IClientProps, IClientState> {
 
     return new Promise<IExchangeResult['data']>((resolve, reject) => {
       // Execute mutation
-      this.mutationSubscription = client.executeMutation$(mutation).subscribe({
+      client.executeMutation$(mutation).subscribe({
         error: e => {
           this.setState(
             {
diff --git a/src/components/connect.tsx b/src/components/connect.tsx
index 24ef0954..2aca0338 100644
--- a/src/components/connect.tsx
+++ b/src/components/connect.tsx
@@ -5,8 +5,13 @@ import { Consumer } from './context';
 
 export interface IConnectProps<Data, Mutations> {
   children: (props: UrqlProps<Data, Mutations>) => ReactNode; // Render prop
+  subscription?: IQuery;
   query?: IQuery | IQuery[]; // Query or queries
   mutation?: IMutation; // Mutation map
+  updateSubscription?: (
+    prev: object | null,
+    next: object | null
+  ) => object | null;
   cache?: boolean;
   typeInvalidation?: boolean;
   shouldInvalidate?: (
@@ -71,8 +76,10 @@ export default class Connect<Data = {}, Mutations = {}> extends Component<
           <ClientWrapper
             client={client}
             children={this.props.children}
+            subscription={this.props.subscription}
             query={this.props.query}
             mutation={this.props.mutation}
+            updateSubscription={this.props.updateSubscription}
             cache={this.props.cache}
             typeInvalidation={this.props.typeInvalidation}
             shouldInvalidate={this.props.shouldInvalidate}
diff --git a/src/interfaces/client.ts b/src/interfaces/client.ts
index dd853dc1..a780eab9 100644
--- a/src/interfaces/client.ts
+++ b/src/interfaces/client.ts
@@ -18,6 +18,7 @@ export interface IClient {
   ): Promise<IExchangeResult>;
   executeMutation$(mutationObject: IQuery): Observable<IExchangeResult['data']>;
   executeMutation(mutationObject: IQuery): Promise<IExchangeResult['data']>;
+  invalidateQuery(queryObject: IQuery): Promise<void>;
   refreshAllFromCache(): void;
   subscribe(callback: (event: ClientEvent) => void): () => void;
 }
diff --git a/src/modules/client.ts b/src/modules/client.ts
index 8fa1a40d..b4a3a42e 100644
--- a/src/modules/client.ts
+++ b/src/modules/client.ts
@@ -47,6 +47,7 @@ export default class Client {
     // Bind methods
     this.executeQuery = this.executeQuery.bind(this);
     this.executeMutation = this.executeMutation.bind(this);
+    this.invalidateQuery = this.invalidateQuery.bind(this);
     this.updateSubscribers = this.updateSubscribers.bind(this);
     this.refreshAllFromCache = this.refreshAllFromCache.bind(this);
   }
@@ -175,4 +176,10 @@ export default class Client {
       });
     });
   }
+
+  invalidateQuery(queryObject: IQuery) {
+    const { query, variables } = queryObject;
+    const key = hashString(JSON.stringify({ query, variables }));
+    return this.cache.invalidate(key);
+  }
 }


Test patch:
diff --git a/src/tests/components/__snapshots__/client.test.tsx.snap b/src/tests/components/__snapshots__/client.test.tsx.snap
index 5bccb722..3c6c7df3 100644
--- a/src/tests/components/__snapshots__/client.test.tsx.snap
+++ b/src/tests/components/__snapshots__/client.test.tsx.snap
@@ -1,3 +1,3 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`Client Component should not accept query and subscription props at the same time 1`] = `"Passing a query and a subscription prop at the same time is invalid."`;
+exports[`Client Component should not accept query and subscription props at the same time 1`] = `"Passing a query and a subscription prop at the same time without an updateSubscription function is invalid."`;
diff --git a/src/tests/components/client.test.tsx b/src/tests/components/client.test.tsx
index 6872dc4e..36163c91 100644
--- a/src/tests/components/client.test.tsx
+++ b/src/tests/components/client.test.tsx
@@ -1000,4 +1000,170 @@ describe('Client Component', () => {
       c.formatProps(p);
     }).toThrowErrorMatchingSnapshot();
   });
+
+  it('should handle subscription, query, and updateSubscription props', done => {
+    const data = { todos: [{ id: 1, __typename: 'Todo' }] };
+
+    (global as any).fetch.mockReturnValue(
+      Promise.resolve({
+        status: 200,
+        json: () => ({ data }),
+      })
+    );
+
+    const unsubscribe = jest.fn();
+
+    let observer;
+    const createSubscription = (_, _observer) => {
+      observer = _observer;
+      return { unsubscribe };
+    };
+
+    const clientModule = new ClientModule({
+      url: 'test',
+      transformExchange: x => subscriptionExchange(createSubscription, x),
+    });
+
+    const query = `{ todos { id } }`;
+    const updateSubscription = (_, next) => next;
+
+    let result;
+    // @ts-ignore
+    const client = renderer.create(
+      <Client
+        client={clientModule}
+        query={{ query }}
+        subscription={{ query: `subscription { todos { id } }` }}
+        updateSubscription={updateSubscription}
+        children={args => {
+          result = args;
+          return null;
+        }}
+      />
+    );
+
+    expect(result.data).toBe(null);
+    expect(result.error).toBe(null);
+    expect(result.loaded).toBe(false);
+    expect(Object.keys(clientModule.store).length).toBe(0);
+
+    setTimeout(() => {
+      expect(result.data).toBe(data);
+      expect(result.loaded).toBe(true);
+      expect(Object.keys(clientModule.store).length).toBe(1);
+
+      const newData = { test: 'test1' };
+      observer.next({ data: newData });
+
+      expect(result.data).toBe(newData);
+      expect(Object.keys(clientModule.store).length).toBe(0);
+
+      done();
+    }, 100);
+  });
+
+  it('should handle subscription, query array, and updateSubscription props', done => {
+    const data = { todos: [{ id: 1, __typename: 'Todo' }] };
+
+    (global as any).fetch.mockReturnValue(
+      Promise.resolve({
+        status: 200,
+        json: () => ({ data }),
+      })
+    );
+
+    const unsubscribe = jest.fn();
+
+    let observer;
+    const createSubscription = (_, _observer) => {
+      observer = _observer;
+      return { unsubscribe };
+    };
+
+    const clientModule = new ClientModule({
+      url: 'test',
+      transformExchange: x => subscriptionExchange(createSubscription, x),
+    });
+
+    const query = `{ todos { id } }`;
+    const updateSubscription = (_, next) => next;
+
+    let result;
+    // @ts-ignore
+    const client = renderer.create(
+      <Client
+        client={clientModule}
+        query={[{ query }, { query }]}
+        subscription={{ query: `subscription { todos { id } }` }}
+        updateSubscription={updateSubscription}
+        children={args => {
+          result = args;
+          return null;
+        }}
+      />
+    );
+
+    expect(result.data).toBe(null);
+    expect(result.error).toBe(null);
+    expect(result.loaded).toBe(false);
+    expect(Object.keys(clientModule.store).length).toBe(0);
+
+    setTimeout(() => {
+      expect(result.data).toEqual([data, data]);
+      expect(result.loaded).toBe(true);
+      expect(Object.keys(clientModule.store).length).toBe(1);
+
+      const newData = { test: 'test1' };
+      observer.next({ data: newData });
+
+      expect(result.data).toBe(newData);
+      expect(Object.keys(clientModule.store).length).toBe(0);
+
+      done();
+    }, 100);
+  });
+
+  it('should unsubscribe before starting a new query', () => {
+    (global as any).fetch.mockReturnValue(new Promise(() => {}));
+    const clientModule = new ClientModule({ url: 'test' });
+    const query = `{ todos { id } }`;
+
+    // @ts-ignore
+    const client = renderer.create(
+      <Client client={clientModule} query={{ query }} children={() => null} />
+    );
+
+    expect(client.getInstance().querySub).not.toBeNull();
+    const spy = jest.spyOn(client.getInstance().querySub, 'unsubscribe');
+
+    // Manually trigger fetch
+    client.getInstance().fetch();
+
+    expect(spy).toHaveBeenCalledTimes(1);
+  });
+
+  it('should unsubscribe before starting a new subscription', () => {
+    const createSubscription = (_, _observer) => ({ unsubscribe: () => {} });
+    const clientModule = new ClientModule({
+      url: 'test',
+      transformExchange: x => subscriptionExchange(createSubscription, x),
+    });
+
+    // @ts-ignore
+    const client = renderer.create(
+      <Client
+        client={clientModule}
+        subscription={{ query: `subscription { ideas { id } }` }}
+        children={() => null}
+      />
+    );
+
+    expect(client.getInstance().subscriptionSub).not.toBeNull();
+    const spy = jest.spyOn(client.getInstance().subscriptionSub, 'unsubscribe');
+
+    // Manually trigger fetch
+    client.getInstance().subscribeToQuery();
+
+    expect(spy).toHaveBeenCalledTimes(1);
+  });
 });
diff --git a/src/tests/components/connect-hoc.test.tsx b/src/tests/components/connect-hoc.test.tsx
index 166d9be9..cf0d8b00 100644
--- a/src/tests/components/connect-hoc.test.tsx
+++ b/src/tests/components/connect-hoc.test.tsx
@@ -1,6 +1,6 @@
 /* tslint:disable */
 
-import React from 'react';
+import React, { Component } from 'react';
 import ConnectHOC from '../../components/connect-hoc';
 import renderer from 'react-test-renderer';
 
@@ -56,4 +56,20 @@ describe('Connect HOC', () => {
     let tree = component.toJSON();
     expect(tree).toMatchSnapshot();
   });
+
+  it('should assign reasonable displayNames to components', () => {
+    class Comp extends Component {
+      static displayName = 'Test';
+      render() {
+        return null;
+      }
+    }
+
+    expect(ConnectHOC()(Comp).displayName).toBe('Connect(Test)');
+
+    const TestComp = () => null;
+    expect(ConnectHOC()(TestComp).displayName).toBe('Connect(TestComp)');
+
+    expect(ConnectHOC()({} as any).displayName).toBe('Connect(Component)');
+  });
 });