From 84123bb2ced24e06c8d0e86b2ebe9abec210d471 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Fri, 7 Oct 2016 20:56:20 -0400 Subject: [PATCH 1/3] finally working on integrated subscriptions --- src/graphql.tsx | 45 +++++---- src/parser.ts | 22 +++-- test/mocks/mockNetworkInterface.ts | 92 +++++++++++++++++++ test/parser.test.ts | 17 ++++ .../client/graphql/queries-1.test.tsx | 1 - .../client/graphql/subscriptions.test.tsx | 67 ++++++++++++++ 6 files changed, 218 insertions(+), 26 deletions(-) create mode 100644 test/react-web/client/graphql/subscriptions.test.tsx diff --git a/src/graphql.tsx b/src/graphql.tsx index 71721b2ce6..f47261f47a 100644 --- a/src/graphql.tsx +++ b/src/graphql.tsx @@ -142,14 +142,8 @@ export default function graphql( const graphQLDisplayName = `Apollo(${getDisplayName(WrappedComponent)})`; function calculateFragments(fragments): FragmentDefinition[] { - if (!fragments && !operation.fragments.length) { - return fragments; - } - - if (!fragments) { - return fragments = flatten([...operation.fragments]); - } - + if (!fragments && !operation.fragments.length) return fragments; + if (!fragments) return fragments = flatten([...operation.fragments]); return flatten([...fragments, ...operation.fragments]); } @@ -189,7 +183,10 @@ export default function graphql( function fetchData(props, { client }) { if (mapPropsToSkip(props)) return; - if (operation.type === DocumentType.Mutation) return false; + if ( + operation.type === DocumentType.Mutation || operation.type === DocumentType.Subscription + ) return false; + const opts = calculateOptions(props) as any; opts.query = document; @@ -302,6 +299,7 @@ export default function graphql( componentWillUnmount() { if (this.type === DocumentType.Query) this.unsubscribeFromQuery(); + if (this.type === DocumentType.Subscription) this.unsubscribeFromQuery(); this.hasMounted = false; } @@ -309,7 +307,7 @@ export default function graphql( calculateOptions(props, newProps?) { return calculateOptions(props, newProps); }; calculateResultProps(result) { - let name = this.type === DocumentType.Query ? 'data' : 'mutate'; + let name = this.type === DocumentType.Mutation ? 'mutate' : 'data'; if (operationOptions.name) name = operationOptions.name; const newResult = { [name]: result, ownProps: this.props }; @@ -369,7 +367,7 @@ export default function graphql( } subscribeToQuery(props): boolean { - const { watchQuery } = this.client; + const { watchQuery, subscribe } = this.client; const opts = calculateOptions(props) as QueryOptions; if (opts.skip) return; @@ -416,8 +414,9 @@ export default function graphql( const queryOptions: WatchQueryOptions = assign({ query: document }, opts); queryOptions.fragments = calculateFragments(queryOptions.fragments); - const observableQuery = watchQuery(queryOptions); - const { queryId } = observableQuery; + // tslint:disable-next-line + const observableQuery = this.type === DocumentType.Subscription ? subscribe(queryOptions) : watchQuery(queryOptions); + const { queryId } = (observableQuery as any); // the shape of the query has changed if (previousQuery.queryId && previousQuery.queryId !== queryId) { @@ -436,7 +435,7 @@ export default function graphql( } } - handleQueryData(observableQuery: ObservableQuery, { variables }: WatchQueryOptions): void { + handleQueryData(observableQuery: any, { variables }: WatchQueryOptions): void { // bind each handle to updating and rerendering when data // has been recieved let refetch, @@ -446,10 +445,20 @@ export default function graphql( updateQuery, oldData = {}; - const next = ({ data = oldData, loading, error }: any) => { - const { queryId } = observableQuery; - - let initialVariables = this.client.queryManager.getApolloState().queries[queryId].variables; + const next = (result) => { + if (this.type === DocumentType.Subscription) { + result = { + data: result, + loading: false, + error: null, + }; + } + const { data = oldData, loading, error }: any = result; + let initialVariables = {}; + if (this.type !== DocumentType.Subscription) { + const { queryId } = observableQuery; + initialVariables = this.client.queryManager.getApolloState().queries[queryId].variables; + } const resultKeyConflict: boolean = ( 'errors' in data || diff --git a/src/parser.ts b/src/parser.ts index cdf6f2fc63..9da5839ccb 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -12,6 +12,7 @@ import invariant = require('invariant'); export enum DocumentType { Query, Mutation, + Subscription, } export interface IDocumentDefinition { @@ -24,7 +25,7 @@ export interface IDocumentDefinition { // the parser is mainly a safety check for the HOC export function parser(document: Document): IDocumentDefinition { // variables - let fragments, queries, mutations, variables, definitions, type, name; + let fragments, queries, mutations, subscriptions, variables, definitions, type, name; /* @@ -54,26 +55,33 @@ export function parser(document: Document): IDocumentDefinition { (x: OperationDefinition) => x.kind === 'OperationDefinition' && x.operation === 'mutation' ); - if (fragments.length && (!queries.length || !mutations.length)) { + subscriptions = document.definitions.filter( + (x: OperationDefinition) => x.kind === 'OperationDefinition' && x.operation === 'subscription' + ); + + if (fragments.length && (!queries.length || !mutations.length || !subscriptions.length)) { invariant(true, - `Passing only a fragment to 'graphql' is not yet supported. You must include a query or mutation as well` + `Passing only a fragment to 'graphql' is not yet supported. You must include a query, subscription or mutation as well` ); } - if (queries.length && mutations.length) { - invariant((queries.length && mutations.length), + if (queries.length && mutations.length && mutations.length) { + invariant(((queries.length + mutations.length + mutations.length) > 1), // tslint:disable-line - `react-apollo only supports a query or a mutation per HOC. ${document} had ${queries.length} queries and ${mutations.length} muations. You can use 'combineOperations' to join multiple operation types to a component` + `react-apollo only supports a query, subscription, or a mutation per HOC. ${document} had ${queries.length} queries, ${subscriptions.length} subscriptions and ${mutations.length} muations. You can use 'compose' to join multiple operation types to a component` ); } type = queries.length ? DocumentType.Query : DocumentType.Mutation; + if (!queries.length && !mutations.length) type = DocumentType.Subscription; + definitions = queries.length ? queries : mutations; + if (!queries.length && !mutations.length) definitions = subscriptions; if (definitions.length !== 1) { invariant((definitions.length !== 1), // tslint:disable-line - `react-apollo only supports one defintion per HOC. ${document} had ${definitions.length} definitions. You can use 'combineOperations' to join multiple operation types to a component` + `react-apollo only supports one defintion per HOC. ${document} had ${definitions.length} definitions. You can use 'compose' to join multiple operation types to a component` ); } diff --git a/test/mocks/mockNetworkInterface.ts b/test/mocks/mockNetworkInterface.ts index 15f8e9c944..b2b8ac65d4 100644 --- a/test/mocks/mockNetworkInterface.ts +++ b/test/mocks/mockNetworkInterface.ts @@ -1,6 +1,7 @@ import { NetworkInterface, Request, + SubscriptionNetworkInterface, } from 'apollo-client/networkInterface'; import { @@ -17,6 +18,12 @@ export default function mockNetworkInterface( return new MockNetworkInterface(...mockedResponses); } +export function mockSubscriptionNetworkInterface( + mockedSubscriptions: MockedSubscription[], ...mockedResponses: MockedResponse[] +): MockSubscriptionNetworkInterface { + return new MockSubscriptionNetworkInterface(mockedSubscriptions, ...mockedResponses); +} + export interface ParsedRequest { variables?: Object; query?: Document; @@ -31,6 +38,18 @@ export interface MockedResponse { newData?: () => any; } +export interface MockedSubscriptionResult { + result?: GraphQLResult; + error?: Error; + delay?: number; +} + +export interface MockedSubscription { + request: ParsedRequest; + results?: MockedSubscriptionResult[]; + id?: number; +} + export class MockNetworkInterface implements NetworkInterface { private mockedResponsesByKey: { [key: string]: MockedResponse[] } = {}; @@ -87,6 +106,79 @@ export class MockNetworkInterface implements NetworkInterface { } } +export class MockSubscriptionNetworkInterface extends MockNetworkInterface implements SubscriptionNetworkInterface { + public mockedSubscriptionsByKey: { [key: string ]: MockedSubscription[] } = {}; + public mockedSubscriptionsById: { [id: number]: MockedSubscription} = {}; + public handlersById: {[id: number]: (error: any, result: any) => void} = {}; + public subId: number; + + constructor(mockedSubscriptions: MockedSubscription[], mockedResponses: MockedResponse[]) { + super(...mockedResponses); + this.subId = 0; + mockedSubscriptions.forEach((sub) => { + this.addMockedSubscription(sub); + }); + } + public generateSubscriptionId() { + const requestId = this.subId; + this.subId++; + return requestId; + } + + public addMockedSubscription(mockedSubscription: MockedSubscription) { + const key = requestToKey(mockedSubscription.request); + if (mockedSubscription.id === undefined) { + mockedSubscription.id = this.generateSubscriptionId(); + } + + let mockedSubs = this.mockedSubscriptionsByKey[key]; + if (!mockedSubs) { + mockedSubs = []; + this.mockedSubscriptionsByKey[key] = mockedSubs; + } + mockedSubs.push(mockedSubscription); + } + + public subscribe(request: Request, handler: (error: any, result: any) => void): number { + const parsedRequest: ParsedRequest = { + query: request.query, + variables: request.variables, + debugName: request.debugName, + }; + const key = requestToKey(parsedRequest); + if (this.mockedSubscriptionsByKey.hasOwnProperty(key)) { + const subscription = this.mockedSubscriptionsByKey[key].shift(); + this.handlersById[subscription.id] = handler; + this.mockedSubscriptionsById[subscription.id] = subscription; + return subscription.id; + } else { + throw new Error('Network interface does not have subscription associated with this request.'); + } + + }; + + public fireResult(id: number) { + const handler = this.handlersById[id]; + if (this.mockedSubscriptionsById.hasOwnProperty(id.toString())) { + const subscription = this.mockedSubscriptionsById[id]; + if (subscription.results.length === 0) { + throw new Error(`No more mocked subscription responses for the query: ` + + `${print(subscription.request.query)}, variables: ${JSON.stringify(subscription.request.variables)}`); + } + const response = subscription.results.shift(); + setTimeout(() => { + handler(response.error, response.result); + }, response.delay ? response.delay : 0); + } else { + throw new Error('Network interface does not have subscription associated with this id.'); + } + } + + public unsubscribe(id: number) { + delete this.mockedSubscriptionsById[id]; + } +} + function requestToKey(request: ParsedRequest): string { const queryString = request.query && print(request.query); return JSON.stringify({ diff --git a/test/parser.test.ts b/test/parser.test.ts index e68960fd0b..53fbd1f498 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -54,6 +54,9 @@ describe('parser', () => { const mutation = gql`mutation One { user { name } }`; expect(parser(mutation).name).toBe('One'); + + const subscription = gql`subscription One { user { name } }`; + expect(parser(subscription).name).toBe('One'); }); it('should return data as the name of the operation if not named', () => { @@ -65,6 +68,9 @@ describe('parser', () => { const mutation = gql`mutation { user { name } }`; expect(parser(mutation).name).toBe('data'); + + const subscription = gql`subscription { user { name } }`; + expect(parser(subscription).name).toBe('data'); }); it('should return the type of operation', () => { @@ -76,6 +82,9 @@ describe('parser', () => { const mutation = gql`mutation One { user { name } }`; expect(parser(mutation).type).toBe(DocumentType.Mutation); + + const subscription = gql`subscription One { user { name } }`; + expect(parser(subscription).type).toBe(DocumentType.Subscription); }); it('should return the variable definitions of the operation', () => { @@ -87,6 +96,10 @@ describe('parser', () => { const mutation = gql`mutation One($t: String!) { user(t: $t) { name } }`; definition = mutation.definitions[0] as OperationDefinition; expect(parser(mutation).variables).toEqual(definition.variableDefinitions); + + const subscription = gql`subscription One($t: String!) { user(t: $t) { name } }`; + definition = subscription.definitions[0] as OperationDefinition; + expect(parser(subscription).variables).toEqual(definition.variableDefinitions); }); it('should not error if the operation has no variables', () => { @@ -97,6 +110,10 @@ describe('parser', () => { const mutation = gql`mutation { user(t: $t) { name } }`; definition = mutation.definitions[0] as OperationDefinition; expect(parser(mutation).variables).toEqual(definition.variableDefinitions); + + const subscription = gql`subscription { user(t: $t) { name } }`; + definition = subscription.definitions[0] as OperationDefinition; + expect(parser(subscription).variables).toEqual(definition.variableDefinitions); }); diff --git a/test/react-web/client/graphql/queries-1.test.tsx b/test/react-web/client/graphql/queries-1.test.tsx index 7917983cfb..c09c334c6b 100644 --- a/test/react-web/client/graphql/queries-1.test.tsx +++ b/test/react-web/client/graphql/queries-1.test.tsx @@ -645,7 +645,6 @@ describe('queries', () => { componentWillReceiveProps({ apollo: { queries } }) { const queryNumber = Object.keys(queries).length; - console.log(queryNumber, count); if (count === 0) expect(queryNumber).toEqual(1); if (count === 1) expect(queryNumber).toEqual(0); if (count === 2) { diff --git a/test/react-web/client/graphql/subscriptions.test.tsx b/test/react-web/client/graphql/subscriptions.test.tsx new file mode 100644 index 0000000000..afc6a65955 --- /dev/null +++ b/test/react-web/client/graphql/subscriptions.test.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import * as renderer from 'react-test-renderer'; +import gql from 'graphql-tag'; + +import ApolloClient from 'apollo-client'; +import { ApolloError } from 'apollo-client/errors'; + +declare function require(name: string); + +import mockNetworkInterface, { + mockSubscriptionNetworkInterface, +} from '../../../mocks/mockNetworkInterface'; + +import { + // Passthrough, + ProviderMock, +} from '../../../mocks/components'; + +import graphql from '../../../../src/graphql'; + +describe('subscriptions', () => { + const results = ['James Baxley', 'John Pinkerton', 'Sam Clairidge', 'Ben Coleman'].map( + name => ({ result: { user: { name } }, delay: 10 }) + ); + + it('executes a subscription', (done) => { + const query = gql`subscription UserInfo { user { name } }`; + const networkInterface = mockSubscriptionNetworkInterface( + [{ request: { query }, results: [...results] }] + ); + const client = new ApolloClient({ networkInterface }); + // XXX fix in apollo-client + client.subscribe = client.subscribe.bind(client); + + let count = 0; + @graphql(query) + class Container extends React.Component { + componentWillMount(){ + expect(this.props.data.loading).toBeTruthy(); + } + componentWillReceiveProps({ data: { loading, user }}) { + expect(loading).toBeFalsy(); + if (count === 0) expect(user).toEqual(results[0].result.user); + if (count === 1) expect(user).toEqual(results[1].result.user); + if (count === 2) expect(user).toEqual(results[2].result.user); + if (count === 3) { + expect(user).toEqual(results[3].result.user); + done(); + } + count++; + } + render() { + return null; + } + }; + + const interval = setInterval(() => { + networkInterface.fireResult(0); + if (count > 3) clearInterval(interval); + }, 50); + + renderer.create( + + ); + }); + +}); From fb7da0b1c83c670f6fb1bd655a4ab3e6184d229a Mon Sep 17 00:00:00 2001 From: James Baxley Date: Fri, 7 Oct 2016 21:11:17 -0400 Subject: [PATCH 2/3] test lifecycle of subs --- src/graphql.tsx | 55 +++++++++----- .../client/graphql/subscriptions.test.tsx | 71 ++++++++++++++++++- 2 files changed, 106 insertions(+), 20 deletions(-) diff --git a/src/graphql.tsx b/src/graphql.tsx index f47261f47a..582d5bcb5b 100644 --- a/src/graphql.tsx +++ b/src/graphql.tsx @@ -484,16 +484,24 @@ export default function graphql( // cache the changed data for next check oldData = assign({}, data); - this.data = assign({ + if (this.type === DocumentType.Subscription) { + this.data = assign({ + variables: this.data.variables || initialVariables, + loading, + error, + }, data); + } else { + this.data = assign({ variables: this.data.variables || initialVariables, - loading, - refetch, - startPolling, - stopPolling, - fetchMore, - error, - updateQuery, - }, data); + loading, + refetch, + startPolling, + stopPolling, + fetchMore, + error, + updateQuery, + }, data); + } this.forceRenderChildren(); }; @@ -534,17 +542,26 @@ export default function graphql( */ this.querySubscription = observableQuery.subscribe({ next, error: handleError }); - refetch = createBoundRefetch((this.queryObservable as any).refetch); - fetchMore = createBoundRefetch((this.queryObservable as any).fetchMore); - startPolling = (this.queryObservable as any).startPolling; - stopPolling = (this.queryObservable as any).stopPolling; - updateQuery = (this.queryObservable as any).updateQuery; + if (this.type === DocumentType.Query) { + refetch = createBoundRefetch((this.queryObservable as any).refetch); + fetchMore = createBoundRefetch((this.queryObservable as any).fetchMore); + startPolling = (this.queryObservable as any).startPolling; + stopPolling = (this.queryObservable as any).stopPolling; + updateQuery = (this.queryObservable as any).updateQuery; + + // XXX the tests seem to be keeping the error around? + delete this.data.error; + this.data = assign(this.data, { + refetch, startPolling, stopPolling, fetchMore, updateQuery, variables, + }); + } + + if (this.type === DocumentType.Subscription) { + // XXX the tests seem to be keeping the error around? + delete this.data.error; + this.data = assign(this.data, { variables }); + } - // XXX the tests seem to be keeping the error around? - delete this.data.error; - this.data = assign(this.data, { - refetch, startPolling, stopPolling, fetchMore, updateQuery, variables, - }); } forceRenderChildren() { diff --git a/test/react-web/client/graphql/subscriptions.test.tsx b/test/react-web/client/graphql/subscriptions.test.tsx index afc6a65955..48ec9d4773 100644 --- a/test/react-web/client/graphql/subscriptions.test.tsx +++ b/test/react-web/client/graphql/subscriptions.test.tsx @@ -23,6 +23,72 @@ describe('subscriptions', () => { name => ({ result: { user: { name } }, delay: 10 }) ); + it('binds a subscription to props', () => { + const query = gql`subscription UserInfo { user { name } }`; + const networkInterface = mockSubscriptionNetworkInterface( + [{ request: { query }, results: [...results] }] + ); + const client = new ApolloClient({ networkInterface }); + // XXX fix in apollo-client + client.subscribe = client.subscribe.bind(client); + + const ContainerWithData = graphql(query)(({ data }) => { // tslint:disable-line + expect(data).toBeTruthy(); + expect(data.ownProps).toBeFalsy(); + expect(data.loading).toBe(true); + return null; + }); + + const output = renderer.create(); + output.unmount(); + }); + + it('includes the variables in the props', () => { + const query = gql`subscription UserInfo($name: String){ user(name: $name){ name } }`; + const variables = { name: 'James Baxley' }; + const networkInterface = mockSubscriptionNetworkInterface( + [{ request: { query, variables }, results: [...results] }] + ); + const client = new ApolloClient({ networkInterface }); + // XXX fix in apollo-client + client.subscribe = client.subscribe.bind(client); + + const ContainerWithData = graphql(query)(({ data }) => { // tslint:disable-line + expect(data).toBeTruthy(); + expect(data.variables).toEqual(variables); + return null; + }); + + const output = renderer.create( + + ); + output.unmount(); + }); + + it('does not swallow children errors', () => { + const query = gql`subscription UserInfo { user { name } }`; + const networkInterface = mockSubscriptionNetworkInterface( + [{ request: { query }, results: [...results] }] + ); + const client = new ApolloClient({ networkInterface }); + // XXX fix in apollo-client + client.subscribe = client.subscribe.bind(client); + + let bar; + const ContainerWithData = graphql(query)(() => { + bar(); // this will throw + return null; + }); + + try { + renderer.create(); + throw new Error(); + } catch (e) { + expect(e.name).toMatch(/TypeError/); + } + + }); + it('executes a subscription', (done) => { const query = gql`subscription UserInfo { user { name } }`; const networkInterface = mockSubscriptionNetworkInterface( @@ -33,6 +99,7 @@ describe('subscriptions', () => { client.subscribe = client.subscribe.bind(client); let count = 0; + let output; @graphql(query) class Container extends React.Component { componentWillMount(){ @@ -45,6 +112,7 @@ describe('subscriptions', () => { if (count === 2) expect(user).toEqual(results[2].result.user); if (count === 3) { expect(user).toEqual(results[3].result.user); + output.unmount(); done(); } count++; @@ -59,9 +127,10 @@ describe('subscriptions', () => { if (count > 3) clearInterval(interval); }, 50); - renderer.create( + output = renderer.create( ); + }); }); From 873a7fb63c65c9fbd5845acd9caf74146d98d127 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Fri, 7 Oct 2016 21:13:59 -0400 Subject: [PATCH 3/3] update changelogs --- Changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Changelog.md b/Changelog.md index 46ca7ea5dd..049e31e236 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,6 +7,7 @@ Expect active development and potentially significant breaking changes in the `0 - Feature: Remove nested imports for apollo-client. Making local development eaiser. [#234](https://github.com/apollostack/react-apollo/pull/234) - Feature: Move types to dev deps [#251](https://github.com/apollostack/react-apollo/pull/251) - Feature: New method for skipping queries which bypasses HOC internals [#253](https://github.com/apollostack/react-apollo/pull/253) +- Feature: Integrated subscriptions! [#256](https://github.com/apollostack/react-apollo/pull/256) ### v0.5.7