extends React.Component<
private endSubscription = () => {
if (this.querySubscription) {
this.querySubscription.unsubscribe();
+ delete this.querySubscription;
}
};
}
diff --git a/src/browser.ts b/src/browser.ts
index c566433216..7794e02ed2 100644
--- a/src/browser.ts
+++ b/src/browser.ts
@@ -1,4 +1,4 @@
-export { default as getDataFromTree, walkTree } from './getDataFromTree';
+export { default as getDataFromTree } from './getDataFromTree';
export * from './getDataFromTree';
export { default as ApolloConsumer } from './ApolloConsumer';
@@ -13,8 +13,12 @@ export * from './Query';
export { default as Mutation } from './Mutation';
export * from './Mutation';
-export { default as graphql } from './graphql';
-export * from './graphql';
+export { default as Subscription } from './Subscriptions';
+export * from './Subscriptions';
+
+export { graphql } from './graphql';
+export * from './query-hoc';
+export * from './mutation-hoc';
export { default as withApollo } from './withApollo';
diff --git a/src/graphql.tsx b/src/graphql.tsx
index 3acf15b98d..d6cac4f1a9 100644
--- a/src/graphql.tsx
+++ b/src/graphql.tsx
@@ -1,76 +1,14 @@
+// pacify typescript (we have to turn off no unused locals though :cry:)
import * as React from 'react';
-import * as PropTypes from 'prop-types';
-import ApolloClient, { ApolloError, ObservableQuery, ApolloQueryResult } from 'apollo-client';
-import { ZenObservable } from 'zen-observable-ts';
-import { parser, DocumentType } from './parser';
import { DocumentNode } from 'graphql';
-import {
- MutationOpts,
- OperationOption,
- QueryOpts,
- GraphqlQueryControls,
- MutationFunc,
- OptionProps,
- DataProps,
- MutateProps,
-} from './types';
-import { OperationVariables } from './index';
-
-const hoistNonReactStatics = require('hoist-non-react-statics');
-const shallowEqual = require('fbjs/lib/shallowEqual');
-const invariant = require('invariant');
-
-const defaultMapPropsToOptions = () => ({});
-const defaultMapResultToProps: (props: P) => P = props => props;
-const defaultMapPropsToSkip = () => false;
-
-type ObservableQueryKeys =
- | 'variables'
- | 'refetch'
- | 'fetchMore'
- | 'updateQuery'
- | 'startPolling'
- | 'stopPolling'
- | 'subscribeToMore';
-
-const observableQueryKeys: ObservableQueryKeys[] = [
- 'variables',
- 'refetch',
- 'fetchMore',
- 'updateQuery',
- 'startPolling',
- 'stopPolling',
- 'subscribeToMore',
-];
-
-// the fields we want to copy over to our data prop
-function observableQueryFields(
- observable: T,
-): Pick {
- let obj: { [K in ObservableQueryKeys]?: T[K] } = {};
-
- observableQueryKeys.forEach(key => {
- if (!(key in observable)) return;
-
- const val = observable[key];
- if (typeof val === 'function') {
- obj[key] = val.bind(observable);
- } else {
- obj[key] = val;
- }
- });
-
- return obj as Pick;
-}
-
-function getDisplayName(WrappedComponent: React.ComponentType
) {
- return WrappedComponent.displayName || WrappedComponent.name || 'Component';
-}
+import { parser, DocumentType } from './parser';
+import { OperationOption, DataProps, MutateProps } from './types';
-// Helps track hot reloading.
-let nextVersion = 0;
+import { query } from './query-hoc';
+import { mutation } from './mutation-hoc';
+import { subscribe } from './subscription-hoc';
-export default function graphql<
+export function graphql<
TProps extends TGraphQLVariables | {} = {},
TData = {},
TGraphQLVariables = {},
@@ -80,590 +18,15 @@ export default function graphql<
document: DocumentNode,
operationOptions: OperationOption = {},
) {
- // extract options
- const {
- options = defaultMapPropsToOptions,
- skip = defaultMapPropsToSkip,
- alias = 'Apollo',
- } = operationOptions;
-
- let mapPropsToOptions = options as (props: any) => QueryOpts | MutationOpts;
- if (typeof mapPropsToOptions !== 'function')
- mapPropsToOptions = () => options as QueryOpts | MutationOpts;
-
- let mapPropsToSkip = skip as (props: any) => boolean;
- if (typeof mapPropsToSkip !== 'function') mapPropsToSkip = () => skip as any;
-
- const mapResultToProps = operationOptions.props;
-
- // safety check on the operation
- const operation = parser(document);
-
- // Helps track hot reloading.
- const version = nextVersion++;
-
- function wrapWithApolloComponent(
- WrappedComponent: React.ComponentType,
- ): React.ComponentClass {
- const graphQLDisplayName = `${alias}(${getDisplayName(WrappedComponent)})`;
-
- type GraphqlProps = TProps;
-
- interface GraphqlContext {
- client: ApolloClient;
- getQueryRecycler: () => void;
- }
-
- class GraphQL extends React.Component {
- static displayName = graphQLDisplayName;
- static WrappedComponent = WrappedComponent;
- static contextTypes = {
- client: PropTypes.object,
- getQueryRecycler: PropTypes.func,
- };
-
- // react / redux and react dev tools (HMR) needs
- public version: number;
- public hasMounted: boolean;
-
- // data storage
- private client: ApolloClient; // apollo client
- private type: DocumentType;
-
- // request / action storage. Note that we delete querySubscription if we
- // unsubscribe but never delete queryObservable once it is created. We
- // only delete queryObservable when we unmount the component.
- private queryObservable: ObservableQuery | any;
- private querySubscription: ZenObservable.Subscription;
- private previousData: any = {};
- private lastSubscriptionData: any;
- private refetcherQueue: {
- args: any;
- resolve: (value?: any | PromiseLike) => void;
- reject: (reason?: any) => void;
- };
-
- // calculated switches to control rerenders
- private shouldRerender: boolean;
-
- // the element to render
- private renderedElement: any;
-
- // wrapped instance
- private wrappedInstance: any;
-
- // last props returned from mapResultsToProps
- private lastResultProps: TChildProps;
-
- constructor(props: GraphqlProps, context: GraphqlContext) {
- super(props, context);
-
- this.version = version;
- this.type = operation.type;
- this.dataForChildViaMutation = this.dataForChildViaMutation.bind(this);
- this.setWrappedInstance = this.setWrappedInstance.bind(this);
- }
-
- componentWillMount() {
- if (!this.shouldSkip(this.props)) {
- this.setInitialProps();
- }
- }
-
- componentDidMount() {
- this.hasMounted = true;
- if (this.type === DocumentType.Mutation) return;
-
- if (!this.shouldSkip(this.props)) {
- this.subscribeToQuery();
- // call any stacked refetch functions
- if (this.refetcherQueue) {
- const { args, resolve, reject } = this.refetcherQueue;
- this.queryObservable
- .refetch(args)
- .then(resolve)
- .catch(reject);
- }
- }
- }
-
- componentWillReceiveProps(nextProps: GraphqlProps, nextContext: GraphqlContext) {
- if (this.shouldSkip(nextProps)) {
- if (!this.shouldSkip(this.props)) {
- // if this has changed, we better unsubscribe
- this.unsubscribeFromQuery();
- }
- return;
- }
-
- const { client } = mapPropsToOptions(nextProps);
-
- if (
- shallowEqual(this.props, nextProps) &&
- (this.client === client || this.client === nextContext.client)
- ) {
- return;
- }
-
- this.shouldRerender = true;
-
- if (this.client !== client && this.client !== nextContext.client) {
- if (client) {
- this.client = client;
- } else {
- this.client = nextContext.client;
- }
- this.unsubscribeFromQuery();
- this.queryObservable = null;
- this.previousData = {};
-
- this.updateQuery(nextProps);
- if (!this.shouldSkip(nextProps)) {
- this.subscribeToQuery();
- }
- return;
- }
- if (this.type === DocumentType.Mutation) {
- return;
- }
- if (
- this.type === DocumentType.Subscription &&
- operationOptions.shouldResubscribe &&
- operationOptions.shouldResubscribe(this.props, nextProps)
- ) {
- this.unsubscribeFromQuery();
- delete this.queryObservable;
- this.updateQuery(nextProps);
- this.subscribeToQuery();
- return;
- }
-
- this.updateQuery(nextProps);
- this.subscribeToQuery();
- }
-
- componentWillUnmount() {
- if (this.type === DocumentType.Query) {
- // Recycle the query observable if there ever was one.
- if (this.queryObservable) {
- const recycler = this.getQueryRecycler();
- if (recycler) {
- recycler.recycle(this.queryObservable);
- delete this.queryObservable;
- }
- }
-
- // It is critical that this happens prior to recyling the query
- // if not it breaks the loading state / network status because
- // an orphan observer is created in AC (intended) which is cleaned up
- // when the browser has time via a setTimeout(0)
- // Unsubscribe from our query subscription.
- this.unsubscribeFromQuery();
- }
-
- if (this.type === DocumentType.Subscription) this.unsubscribeFromQuery();
-
- this.hasMounted = false;
- }
-
- getQueryRecycler() {
- return this.context.getQueryRecycler && this.context.getQueryRecycler(GraphQL);
- }
-
- getClient(props: GraphqlProps): ApolloClient {
- if (this.client) return this.client;
- const { client } = mapPropsToOptions(props);
-
- if (client) {
- this.client = client;
- } else {
- this.client = this.context.client;
- }
-
- invariant(
- !!this.client,
- `Could not find "client" in the context of ` +
- `"${graphQLDisplayName}". ` +
- `Wrap the root component in an `,
- );
-
- return this.client;
- }
-
- calculateOptions(props = this.props, newOpts?: QueryOpts | MutationOpts) {
- let opts = mapPropsToOptions(props);
-
- if (newOpts && newOpts.variables) {
- newOpts.variables = Object.assign({}, opts.variables, newOpts.variables);
- }
- if (newOpts) opts = Object.assign({}, opts, newOpts);
-
- if (opts.variables || !operation.variables.length) return opts;
-
- let variables: OperationVariables = {};
- for (let { variable, type } of operation.variables) {
- if (!variable.name || !variable.name.value) continue;
-
- const variableName = variable.name.value;
- const variableProp = (props as any)[variableName];
-
- if (typeof variableProp !== 'undefined') {
- variables[variableName] = variableProp;
- continue;
- }
-
- // allow optional props
- if (type.kind !== 'NonNullType') {
- variables[variableName] = null;
- continue;
- }
-
- invariant(
- typeof variableProp !== 'undefined',
- `The operation '${operation.name}' wrapping '${getDisplayName(WrappedComponent)}' ` +
- `is expecting a variable: '${
- variable.name.value
- }' but it was not found in the props ` +
- `passed to '${graphQLDisplayName}'`,
- );
- }
- opts = { ...opts, variables };
- return opts;
- }
-
- calculateResultProps(result: (GraphqlQueryControls & TData) | MutationFunc) {
- let name = this.type === DocumentType.Mutation ? 'mutate' : 'data';
- if (operationOptions.name) name = operationOptions.name;
-
- const newResult: OptionProps = {
- [name]: result,
- ownProps: this.props,
- };
- if (mapResultToProps) {
- this.lastResultProps = mapResultToProps(newResult, this.lastResultProps);
- return this.lastResultProps;
- }
-
- return { [name]: defaultMapResultToProps(result) };
- }
-
- setInitialProps() {
- if (this.type === DocumentType.Mutation) {
- return;
- }
-
- // Create the observable but don't subscribe yet. The query won't
- // fire until we do.
- const opts: QueryOpts = this.calculateOptions(this.props);
-
- this.createQuery(opts);
- }
-
- createQuery(opts: QueryOpts, props: any = this.props) {
- if (this.type === DocumentType.Subscription) {
- this.queryObservable = this.getClient(props).subscribe(
- Object.assign({ query: document }, opts),
- );
- } else {
- // Try to reuse an `ObservableQuery` instance from our recycler. If
- // we get null then there is no instance to reuse and we should
- // create a new `ObservableQuery`. Otherwise we will use our old one.
- const recycler = this.getQueryRecycler();
- let queryObservable = null;
- if (recycler) queryObservable = recycler.reuse(opts);
-
- if (queryObservable === null) {
- this.queryObservable = this.getClient(props).watchQuery(
- Object.assign(
- {
- query: document,
- metadata: {
- reactComponent: {
- displayName: graphQLDisplayName,
- },
- },
- },
- opts,
- ),
- );
- } else {
- this.queryObservable = queryObservable;
- }
- }
- }
-
- updateQuery(props: Readonly) {
- const opts = this.calculateOptions(props) as QueryOpts;
-
- // if we skipped initially, we may not have yet created the observable
- if (!this.queryObservable) {
- this.createQuery(opts, props);
- }
-
- if (this.queryObservable._setOptionsNoResult) {
- // Since we don't care about the result, use a hacky version to
- // work around https://github.com/apollostack/apollo-client/pull/694
- // This workaround is only present in Apollo Client 0.4.21
- this.queryObservable._setOptionsNoResult(opts);
- } else {
- if (this.queryObservable.setOptions) {
- this.queryObservable
- .setOptions(opts)
- // The error will be passed to the child container, so we don't
- // need to log it here. We could conceivably log something if
- // an option was set. OTOH we don't log errors w/ the original
- // query. See https://github.com/apollostack/react-apollo/issues/404
- .catch(() => null);
- }
- }
- }
-
- // For server-side rendering (see getDataFromTree.ts)
- fetchData(): Promise> | boolean {
- if (this.shouldSkip()) return false;
- if (
- operation.type === DocumentType.Mutation ||
- operation.type === DocumentType.Subscription
- )
- return false;
-
- const opts = this.calculateOptions() as any;
- if (opts.ssr === false) return false;
- if (opts.fetchPolicy === 'network-only' || opts.fetchPolicy === 'cache-and-network') {
- opts.fetchPolicy = 'cache-first'; // ignore force fetch in SSR;
- }
-
- const observable = this.getClient(this.props).watchQuery(
- Object.assign({ query: document }, opts),
- );
- const result = observable.currentResult();
-
- if (result.loading) {
- return observable.result();
- } else {
- return false;
- }
- }
-
- subscribeToQuery() {
- if (this.querySubscription) {
- return;
- }
-
- const next = (results: any) => {
- if (this.type === DocumentType.Subscription) {
- // Subscriptions don't currently support `currentResult`, so we
- // need to do this ourselves
- this.lastSubscriptionData = results;
- }
-
- if (results.data) {
- const clashingKeys = Object.keys(observableQueryFields(results.data));
- invariant(
- clashingKeys.length === 0,
- `the result of the '${graphQLDisplayName}' operation contains ` +
- `keys that conflict with the return object.` +
- clashingKeys.map(k => `'${k}'`).join(', ') +
- ` not allowed.`,
- );
- }
-
- this.forceRenderChildren();
- };
-
- const handleError = (error: any) => {
- this.resubscribeToQuery();
- // Quick fix for https://github.com/apollostack/react-apollo/issues/378
- if (error.hasOwnProperty('graphQLErrors')) return next({ error });
- throw error;
- };
- /*
-
- Since `setState()` can throw an error if the child had a render error,
- we can't use the `error` part of the query subscription. If we do, all children
- errors are captured as NetworkErrors which isn't true / helpful.
-
- Instead, we subscribe to the store for network errors and re-render that way
- */
- this.querySubscription = this.queryObservable.subscribe({
- next,
- error: handleError,
- });
- }
-
- unsubscribeFromQuery() {
- if (this.querySubscription) {
- (this.querySubscription as ZenObservable.Subscription).unsubscribe();
- delete this.querySubscription;
- }
- }
-
- resubscribeToQuery() {
- const lastSubscription = this.querySubscription;
- if (lastSubscription) {
- delete this.querySubscription;
- }
- const { lastError, lastResult } = this.queryObservable;
- // If lastError is set, the observable will immediately
- // send it, causing the stream to terminate on initialization.
- // We clear everything here and restore it afterward to
- // make sure the new subscription sticks.
- this.queryObservable.resetLastResults();
- this.subscribeToQuery();
- Object.assign(this.queryObservable, { lastError, lastResult });
- if (lastSubscription) {
- (lastSubscription as ZenObservable.Subscription).unsubscribe();
- }
- }
-
- shouldSkip(props = this.props) {
- return mapPropsToSkip(props);
- }
-
- forceRenderChildren() {
- // force a rerender that goes through shouldComponentUpdate
- this.shouldRerender = true;
- if (this.hasMounted) this.forceUpdate();
- }
-
- getWrappedInstance() {
- invariant(
- operationOptions.withRef,
- `To access the wrapped instance, you need to specify ` +
- `{ withRef: true } in the options`,
- );
-
- return this.wrappedInstance;
- }
-
- setWrappedInstance(ref: React.ComponentClass) {
- this.wrappedInstance = ref;
- }
-
- dataForChildViaMutation(mutationOpts?: MutationOpts) {
- const opts = this.calculateOptions(this.props, mutationOpts);
-
- if (typeof opts.variables === 'undefined') delete opts.variables;
-
- (opts as any).mutation = document;
- return this.getClient(this.props).mutate(opts as any) as Promise>;
- }
-
- dataForChild() {
- if (this.type === DocumentType.Mutation) {
- return this.dataForChildViaMutation;
- }
-
- const opts = this.calculateOptions(this.props);
- const data = {};
- Object.assign(data, observableQueryFields(this.queryObservable));
-
- if (this.type === DocumentType.Subscription) {
- Object.assign(
- data,
- {
- loading: !this.lastSubscriptionData,
- variables: opts.variables,
- },
- this.lastSubscriptionData && this.lastSubscriptionData.data,
- );
- } else {
- // fetch the current result (if any) from the store
- const currentResult = this.queryObservable.currentResult();
- const { loading, networkStatus, errors } = currentResult;
- let { error } = currentResult;
- // until a set naming convention for networkError and graphQLErrors is decided upon, we map errors (graphQLErrors) to the error props
- if (errors && errors.length > 0) {
- error = new ApolloError({ graphQLErrors: errors });
- }
- Object.assign(data, { loading, networkStatus });
-
- // Define the error property on the data object. If the user does
- // not get the error object from `data` within 10 milliseconds
- // then we will log the error to the console.
- //
- // 10 milliseconds is an arbitrary number picked to work around any
- // potential asynchrony in React rendering. It is not super important
- // that the error be logged ASAP, but 10 ms is enough to make it
- // _feel_ like it was logged ASAP while still tolerating asynchrony.
- let logErrorTimeoutId = setTimeout(() => {
- if (error) {
- let errorMessage = error;
- if (error.stack) {
- errorMessage = error.stack.includes(error.message)
- ? error.stack
- : `${error.message}\n${error.stack}`;
- }
-
- console.error(`Unhandled (in react-apollo:${graphQLDisplayName})`, errorMessage);
- }
- }, 10);
- Object.defineProperty(data, 'error', {
- configurable: true,
- enumerable: true,
- get: () => {
- clearTimeout(logErrorTimeoutId);
- return error;
- },
- });
-
- if (loading) {
- // while loading, we should use any previous data we have
- Object.assign(data, this.previousData, currentResult.data);
- } else if (error) {
- // if there is error, use any previously cached data
- Object.assign(data, (this.queryObservable.getLastResult() || {}).data);
- } else {
- Object.assign(data, currentResult.data);
- this.previousData = currentResult.data;
- }
-
- // handle race condition where refetch is called on child mount
- if (!this.querySubscription) {
- (data as GraphqlQueryControls).refetch = args => {
- return new Promise((r, f) => {
- this.refetcherQueue = { resolve: r, reject: f, args };
- });
- };
- }
- }
- return data as GraphqlQueryControls & TData;
- }
-
- render() {
- if (this.shouldSkip()) {
- if (operationOptions.withRef) {
- return (
-
- );
- }
- return ;
- }
-
- const { shouldRerender, renderedElement, props } = this;
- this.shouldRerender = false;
-
- if (!shouldRerender && renderedElement && renderedElement.type === WrappedComponent) {
- return renderedElement;
- }
-
- const data = this.dataForChild();
- const clientProps = this.calculateResultProps(data);
- const mergedPropsAndData = Object.assign({}, props, clientProps);
-
- if (operationOptions.withRef) (mergedPropsAndData as any).ref = this.setWrappedInstance;
- this.renderedElement = ;
- return this.renderedElement;
- }
- }
-
- // Make sure we preserve any custom statics on the original component.
- return hoistNonReactStatics(GraphQL, WrappedComponent, {});
+ switch (parser(document).type) {
+ case DocumentType.Mutation:
+ return mutation(document, operationOptions);
+ case DocumentType.Subscription:
+ return subscribe(document, operationOptions);
+ // case DocumentType.Fragment:
+ // throw new Error('fragments cannont currently be used on their own');
+ case DocumentType.Query:
+ default:
+ return query(document, operationOptions);
}
-
- return wrapWithApolloComponent;
}
diff --git a/src/hoc-utils.tsx b/src/hoc-utils.tsx
new file mode 100644
index 0000000000..2d31373462
--- /dev/null
+++ b/src/hoc-utils.tsx
@@ -0,0 +1,78 @@
+import * as React from 'react';
+const invariant = require('invariant');
+
+import { OperationVariables } from './types';
+import { DocumentType, IDocumentDefinition } from './parser';
+
+export const defaultMapPropsToOptions = () => ({});
+export const defaultMapResultToProps: (props: P) => P = props => props;
+export const defaultMapPropsToSkip = () => false;
+
+export function getDisplayName
(WrappedComponent: React.ComponentType
) {
+ return WrappedComponent.displayName || WrappedComponent.name || 'Component';
+}
+
+export function calculateVariablesFromProps(
+ operation: IDocumentDefinition,
+ props: TProps,
+ graphQLDisplayName: string,
+ wrapperName: string,
+) {
+ let variables: OperationVariables = {};
+ for (let { variable, type } of operation.variables) {
+ if (!variable.name || !variable.name.value) continue;
+
+ const variableName = variable.name.value;
+ const variableProp = (props as any)[variableName];
+
+ if (typeof variableProp !== 'undefined') {
+ variables[variableName] = variableProp;
+ continue;
+ }
+
+ // allow optional props
+ if (type.kind !== 'NonNullType') {
+ variables[variableName] = null;
+ continue;
+ }
+
+ if (operation.type === DocumentType.Mutation) return;
+ invariant(
+ typeof variableProp !== 'undefined',
+ `The operation '${operation.name}' wrapping '${wrapperName}' ` +
+ `is expecting a variable: '${variable.name.value}' but it was not found in the props ` +
+ `passed to '${graphQLDisplayName}'`,
+ );
+ }
+ return variables;
+}
+
+export type RefSetter = (ref: React.ComponentClass) => void | void;
+
+// base class for hocs to easily manage refs
+export class GraphQLBase extends React.Component<
+ TProps,
+ TState
+> {
+ public withRef: boolean;
+ // wrapped instance
+ private wrappedInstance: React.ComponentClass;
+
+ constructor(props: TProps) {
+ super(props);
+ this.setWrappedInstance = this.setWrappedInstance.bind(this);
+ }
+
+ getWrappedInstance() {
+ invariant(
+ this.withRef,
+ `To access the wrapped instance, you need to specify ` + `{ withRef: true } in the options`,
+ );
+
+ return this.wrappedInstance;
+ }
+
+ setWrappedInstance(ref: React.ComponentClass) {
+ this.wrappedInstance = ref;
+ }
+}
diff --git a/src/mutation-hoc.tsx b/src/mutation-hoc.tsx
new file mode 100644
index 0000000000..7a253f2fb5
--- /dev/null
+++ b/src/mutation-hoc.tsx
@@ -0,0 +1,82 @@
+import * as React from 'react';
+import { DocumentNode } from 'graphql';
+const hoistNonReactStatics = require('hoist-non-react-statics');
+
+import { parser } from './parser';
+import { MutationOpts, OperationOption, OptionProps, MutateProps } from './types';
+import { default as Mutation } from './Mutation';
+import {
+ defaultMapPropsToOptions,
+ getDisplayName,
+ calculateVariablesFromProps,
+ GraphQLBase,
+} from './hoc-utils';
+
+export function mutation<
+ TProps extends TGraphQLVariables | {} = {},
+ TData = {},
+ TGraphQLVariables = {},
+ TChildProps = Partial>
+>(
+ document: DocumentNode,
+ operationOptions: OperationOption = {},
+) {
+ // this is memoized so if coming from `graphql` there is nearly no extra cost
+ const operation = parser(document);
+ // extract options
+
+ const { options = defaultMapPropsToOptions, alias = 'Apollo' } = operationOptions;
+
+ let mapPropsToOptions = options as (props: any) => MutationOpts;
+ if (typeof mapPropsToOptions !== 'function') mapPropsToOptions = () => options as MutationOpts;
+
+ return (
+ WrappedComponent: React.ComponentType,
+ ): React.ComponentClass => {
+ const graphQLDisplayName = `${alias}(${getDisplayName(WrappedComponent)})`;
+ class GraphQL extends GraphQLBase {
+ static displayName = graphQLDisplayName;
+ static WrappedComponent = WrappedComponent;
+ render() {
+ let props = this.props;
+ const opts = mapPropsToOptions(props);
+
+ if (operationOptions.withRef) {
+ this.withRef = true;
+ props = Object.assign({}, props, {
+ ref: this.setWrappedInstance,
+ });
+ }
+ if (!opts.variables && operation.variables.length > 0) {
+ opts.variables = calculateVariablesFromProps(
+ operation,
+ props,
+ graphQLDisplayName,
+ getDisplayName(WrappedComponent),
+ );
+ }
+
+ return (
+
+ {(mutate, _result) => {
+ const name = operationOptions.name || 'mutate';
+ let childProps = { [name]: mutate };
+ if (operationOptions.props) {
+ const newResult: OptionProps = {
+ [name]: mutate,
+ ownProps: props,
+ };
+ childProps = operationOptions.props(newResult) as any;
+ }
+
+ return ;
+ }}
+
+ );
+ }
+ }
+
+ // Make sure we preserve any custom statics on the original component.
+ return hoistNonReactStatics(GraphQL, WrappedComponent, {});
+ };
+}
diff --git a/src/parser.ts b/src/parser.ts
index e49c050fd9..8ae27ead2f 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -19,8 +19,12 @@ export interface IDocumentDefinition {
variables: VariableDefinitionNode[];
}
+const cache = new Map();
+
// the parser is mainly a safety check for the HOC
export function parser(document: DocumentNode): IDocumentDefinition {
+ const cached = cache.get(document);
+ if (cached) return cached;
// variables
let variables, type, name;
@@ -90,5 +94,7 @@ export function parser(document: DocumentNode): IDocumentDefinition {
name = 'data'; // fallback to using data if no name
}
- return { name, type, variables };
+ const payload = { name, type, variables };
+ cache.set(document, payload);
+ return payload;
}
diff --git a/src/query-hoc.tsx b/src/query-hoc.tsx
new file mode 100644
index 0000000000..1dd42861dd
--- /dev/null
+++ b/src/query-hoc.tsx
@@ -0,0 +1,144 @@
+import * as React from 'react';
+import { ApolloError } from 'apollo-client';
+import { DocumentNode } from 'graphql';
+const hoistNonReactStatics = require('hoist-non-react-statics');
+
+import { parser } from './parser';
+import { OperationOption, QueryOpts, OptionProps, DataProps } from './types';
+import { default as Query } from './Query';
+import {
+ getDisplayName,
+ GraphQLBase,
+ calculateVariablesFromProps,
+ defaultMapPropsToOptions,
+ defaultMapPropsToSkip,
+} from './hoc-utils';
+
+// XXX lets remove this in 3.0 now that React has proper error boundaries
+const logUnhandledError = (r: any, graphQLDisplayName: string) => {
+ if (r.error) {
+ const error = r.error;
+
+ // Define the error property on the data object. If the user does
+ // not get the error object from `data` within 10 milliseconds
+ // then we will log the error to the console.
+ //
+ // 10 milliseconds is an arbitrary number picked to work around any
+ // potential asynchrony in React rendering. It is not super important
+ // that the error be logged ASAP, but 10 ms is enough to make it
+ // _feel_ like it was logged ASAP while still tolerating asynchrony.
+ let logErrorTimeoutId = setTimeout(() => {
+ if (error) {
+ let errorMessage: string | ApolloError = error;
+ if (error.stack) {
+ errorMessage = error.stack.includes(error.message)
+ ? error.stack
+ : `${error.message}\n${error.stack}`;
+ }
+
+ console.error(`Unhandled (in react-apollo:${graphQLDisplayName})`, errorMessage);
+ }
+ }, 10);
+ Object.defineProperty(r, 'error', {
+ configurable: true,
+ enumerable: true,
+ get: () => {
+ clearTimeout(logErrorTimeoutId);
+ return error;
+ },
+ });
+ }
+};
+
+export function query<
+ TProps extends TGraphQLVariables | {} = {},
+ TData = {},
+ TGraphQLVariables = {},
+ TChildProps = Partial>
+>(
+ document: DocumentNode,
+ operationOptions: OperationOption = {},
+) {
+ // this is memoized so if coming from `graphql` there is nearly no extra cost
+ const operation = parser(document);
+ // extract options
+ const {
+ options = defaultMapPropsToOptions,
+ skip = defaultMapPropsToSkip,
+ alias = 'Apollo',
+ } = operationOptions;
+
+ let mapPropsToOptions = options as (props: any) => QueryOpts;
+ if (typeof mapPropsToOptions !== 'function') mapPropsToOptions = () => options as QueryOpts;
+
+ let mapPropsToSkip = skip as (props: any) => boolean;
+ if (typeof mapPropsToSkip !== 'function') mapPropsToSkip = () => skip as any;
+
+ // allow for advanced referential equality checks
+ let lastResultProps: TChildProps | void;
+ return (
+ WrappedComponent: React.ComponentType,
+ ): React.ComponentClass => {
+ const graphQLDisplayName = `${alias}(${getDisplayName(WrappedComponent)})`;
+ class GraphQL extends GraphQLBase {
+ static displayName = graphQLDisplayName;
+ static WrappedComponent = WrappedComponent;
+
+ render() {
+ let props = this.props;
+ const shouldSkip = mapPropsToSkip(props);
+ const opts = shouldSkip ? Object.create(null) : mapPropsToOptions(props);
+
+ if (!shouldSkip && !opts.variables && operation.variables.length > 0) {
+ opts.variables = calculateVariablesFromProps(
+ operation,
+ props,
+ graphQLDisplayName,
+ getDisplayName(WrappedComponent),
+ );
+ }
+ return (
+
+ {({ client: _, data, ...r }) => {
+ // XXX remove in 3.0
+ logUnhandledError(r, graphQLDisplayName);
+ if (operationOptions.withRef) {
+ this.withRef = true;
+ props = Object.assign({}, props, {
+ ref: this.setWrappedInstance,
+ });
+ }
+ // if we have skipped, no reason to manage any reshaping
+ if (shouldSkip) return ;
+ // the HOC's historically hoisted the data from the execution result
+ // up onto the result since it was passed as a nested prop
+ // we massage the Query components shape here to replicate that
+ const result = Object.assign(r, data || {});
+ const name = operationOptions.name || 'data';
+ let childProps = { [name]: result };
+ if (operationOptions.props) {
+ const newResult: OptionProps = {
+ [name]: result,
+ ownProps: props as TProps,
+ };
+ lastResultProps = operationOptions.props(newResult, lastResultProps);
+ childProps = lastResultProps;
+ }
+
+ return ;
+ }}
+
+ );
+ }
+ }
+
+ // Make sure we preserve any custom statics on the original component.
+ return hoistNonReactStatics(GraphQL, WrappedComponent, {});
+ };
+}
diff --git a/src/subscription-hoc.tsx b/src/subscription-hoc.tsx
new file mode 100644
index 0000000000..62e5d0013f
--- /dev/null
+++ b/src/subscription-hoc.tsx
@@ -0,0 +1,116 @@
+import * as React from 'react';
+import { DocumentNode } from 'graphql';
+const hoistNonReactStatics = require('hoist-non-react-statics');
+
+import { parser } from './parser';
+import { OperationOption, QueryOpts, OptionProps, DataProps } from './types';
+import { default as Subscription } from './Subscriptions';
+import {
+ getDisplayName,
+ GraphQLBase,
+ calculateVariablesFromProps,
+ defaultMapPropsToOptions,
+ defaultMapPropsToSkip,
+} from './hoc-utils';
+
+export function subscribe<
+ TProps extends TGraphQLVariables | {} = {},
+ TData = {},
+ TGraphQLVariables = {},
+ TChildProps = Partial>
+>(
+ document: DocumentNode,
+ operationOptions: OperationOption = {},
+) {
+ // this is memoized so if coming from `graphql` there is nearly no extra cost
+ const operation = parser(document);
+ // extract options
+ const {
+ options = defaultMapPropsToOptions,
+ skip = defaultMapPropsToSkip,
+ alias = 'Apollo',
+ shouldResubscribe,
+ } = operationOptions;
+
+ let mapPropsToOptions = options as (props: any) => QueryOpts;
+ if (typeof mapPropsToOptions !== 'function') mapPropsToOptions = () => options as QueryOpts;
+
+ let mapPropsToSkip = skip as (props: any) => boolean;
+ if (typeof mapPropsToSkip !== 'function') mapPropsToSkip = () => skip as any;
+
+ // allow for advanced referential equality checks
+ let lastResultProps: TChildProps | void;
+ return (
+ WrappedComponent: React.ComponentType,
+ ): React.ComponentClass => {
+ const graphQLDisplayName = `${alias}(${getDisplayName(WrappedComponent)})`;
+ class GraphQL extends GraphQLBase {
+ static displayName = graphQLDisplayName;
+ static WrappedComponent = WrappedComponent;
+ constructor(props: TProps) {
+ super(props);
+ this.state = { resubscribe: false };
+ }
+ componentWillReceiveProps(nextProps: TProps) {
+ if (!shouldResubscribe) return;
+ this.setState({
+ resubscribe: shouldResubscribe(this.props, nextProps),
+ });
+ }
+
+ render() {
+ let props = this.props;
+ const shouldSkip = mapPropsToSkip(props);
+ const opts = shouldSkip ? Object.create(null) : mapPropsToOptions(props);
+
+ if (!shouldSkip && !opts.variables && operation.variables.length > 0) {
+ opts.variables = calculateVariablesFromProps(
+ operation,
+ props,
+ graphQLDisplayName,
+ getDisplayName(WrappedComponent),
+ );
+ }
+ return (
+
+ {({ data, ...r }) => {
+ if (operationOptions.withRef) {
+ this.withRef = true;
+ props = Object.assign({}, props, {
+ ref: this.setWrappedInstance,
+ });
+ }
+ // if we have skipped, no reason to manage any reshaping
+ if (shouldSkip) return ;
+ // the HOC's historically hoisted the data from the execution result
+ // up onto the result since it was passed as a nested prop
+ // we massage the Query components shape here to replicate that
+ const result = Object.assign(r, data || {});
+ const name = operationOptions.name || 'data';
+ let childProps = { [name]: result };
+ if (operationOptions.props) {
+ const newResult: OptionProps = {
+ [name]: result,
+ ownProps: props as TProps,
+ };
+ lastResultProps = operationOptions.props(newResult, lastResultProps);
+ childProps = lastResultProps;
+ }
+
+ return ;
+ }}
+
+ );
+ }
+ }
+
+ // Make sure we preserve any custom statics on the original component.
+ return hoistNonReactStatics(GraphQL, WrappedComponent, {});
+ };
+}
diff --git a/src/types.ts b/src/types.ts
index 31d237928e..ada8f567b1 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -112,7 +112,7 @@ export interface OperationOption<
| QueryOpts
| MutationOpts
| ((props: TProps) => QueryOpts | MutationOpts);
- props?: (props: OptionProps, lastProps: TChildProps) => TChildProps;
+ props?: (props: OptionProps, lastProps?: TChildProps | void) => TChildProps;
skip?: boolean | ((props: any) => boolean);
name?: string;
withRef?: boolean;
diff --git a/test/client/Mutation.test.tsx b/test/client/Mutation.test.tsx
index 10dabf7f08..de1ca20b78 100644
--- a/test/client/Mutation.test.tsx
+++ b/test/client/Mutation.test.tsx
@@ -100,6 +100,96 @@ it('performs a mutation', done => {
);
});
+it('can bind only the mutation and not rerender by props', done => {
+ let count = 0;
+ const Component = () => (
+
+ {(createTodo, result) => {
+ if (count === 0) {
+ expect(result).toBeUndefined();
+ setTimeout(() => {
+ createTodo().then(r => {
+ expect(r!.data).toEqual(data);
+ done();
+ });
+ });
+ } else if (count === 1) {
+ done.fail('rerender happened with ignoreResults turned on');
+ }
+ count++;
+ return ;
+ }}
+
+ );
+
+ mount(
+
+
+ ,
+ );
+});
+
+it('returns a resolved promise when calling the mutation function', done => {
+ let called = false;
+ const Component = () => (
+
+ {createTodo => {
+ if (!called) {
+ setTimeout(() => {
+ createTodo().then(result => {
+ expect(result!.data).toEqual(data);
+ done();
+ });
+ });
+ }
+ called = true;
+
+ return null;
+ }}
+
+ );
+
+ mount(
+
+
+ ,
+ );
+});
+
+it('returns rejected promise when calling the mutation function', done => {
+ let called = false;
+ const Component = () => (
+
+ {createTodo => {
+ if (!called) {
+ setTimeout(() => {
+ createTodo().catch(error => {
+ expect(error).toEqual(new Error('Network error: Error 1'));
+ done();
+ });
+ });
+ }
+
+ called = true;
+
+ return null;
+ }}
+
+ );
+
+ const mocksWithErrors = [
+ {
+ request: { query: mutation },
+ error: new Error('Error 1'),
+ },
+ ];
+
+ mount(
+
+
+ ,
+ );
+});
it('only shows result for the latest mutation that is in flight', done => {
let count = 0;
@@ -259,9 +349,11 @@ it('renders an error state', done => {
{(createTodo, result) => {
if (count === 0) {
- setTimeout(() => {
- createTodo();
- });
+ setTimeout(() =>
+ createTodo().catch(err => {
+ expect(err).toEqual(new Error('Network error: error occurred'));
+ }),
+ );
} else if (count === 1 && result) {
expect(result.loading).toBeTruthy();
} else if (count === 2 && result) {
@@ -297,11 +389,9 @@ it('calls the onError prop if the mutation encounters an error', done => {
};
onError = (error: Error) => {
- expect(error).toEqual(new Error('Network error: error occurred'));
+ expect(error.message).toMatch('Network error: error occurred');
onRenderCalled = true;
- this.setState({
- mutationError: true,
- });
+ this.setState({ mutationError: true });
};
render() {
@@ -312,9 +402,7 @@ it('calls the onError prop if the mutation encounters an error', done => {
{(createTodo, result) => {
if (!result) {
expect(mutationError).toBe(false);
- setTimeout(() => {
- createTodo();
- });
+ setTimeout(() => createTodo());
}
if (onRenderCalled) {
expect(mutationError).toBe(true);
diff --git a/test/client/Query.test.tsx b/test/client/Query.test.tsx
index 43aef62449..469d6a6444 100644
--- a/test/client/Query.test.tsx
+++ b/test/client/Query.test.tsx
@@ -70,7 +70,7 @@ describe('Query component', () => {
expect(rest).toMatchSnapshot('result in render prop while loading');
expect(clientResult).toBe(client);
} else {
- expect(rest).toMatchSnapshot('result in render prop');
+ expect(stripSymbols(rest)).toMatchSnapshot('result in render prop');
done();
}
});
@@ -538,7 +538,6 @@ describe('Query component', () => {
{result => {
catchAsyncError(done, () => {
expect(result.loading).toBeFalsy();
- expect(result.data).toBeUndefined();
expect(result.networkStatus).toBe(NetworkStatus.ready);
done();
});
@@ -836,16 +835,12 @@ describe('Query component', () => {
return (
{result => {
- if (result.loading) {
- return null;
- }
+ if (result.loading) return null;
catchAsyncError(done, () => {
if (count === 0) {
expect(stripSymbols(result.data)).toEqual(data1);
setTimeout(() => {
- this.setState({
- query: query2,
- });
+ this.setState({ query: query2 });
});
}
if (count === 1) {
@@ -1094,13 +1089,13 @@ describe('Query component', () => {
expect(result.loading).toBeTruthy();
break;
case 1:
- if (!result.data) {
+ if (!result.data!.allPeople) {
done.fail('Should have data by this point');
break;
}
// First result is loaded, run a refetch to get the second result
// which is an error.
- expect(stripSymbols(result.data.allPeople)).toEqual(data.allPeople);
+ expect(stripSymbols(result.data!.allPeople)).toEqual(data.allPeople);
setTimeout(() => {
result.refetch().then(() => {
done.fail('Expected error value on first refetch.');
diff --git a/test/internal-api/Subscription.test.tsx b/test/client/Subscription.test.tsx
similarity index 99%
rename from test/internal-api/Subscription.test.tsx
rename to test/client/Subscription.test.tsx
index ece54fcee1..7268a33a41 100644
--- a/test/internal-api/Subscription.test.tsx
+++ b/test/client/Subscription.test.tsx
@@ -9,7 +9,7 @@ import { InMemoryCache as Cache } from 'apollo-cache-inmemory';
import { MockSubscriptionLink } from '../../src/test-utils';
import { ApolloProvider } from '../../src';
import catchAsyncError from '../test-utils/catchAsyncError';
-import Subscription from '../../src/Subscriptions';
+import { Subscription } from '../../src';
const results = ['Luke Skywalker', 'Han Solo', 'Darth Vader', 'Leia Skywalker'].map(name => ({
result: { data: { user: { name } } },
diff --git a/test/client/__snapshots__/Query.test.tsx.snap b/test/client/__snapshots__/Query.test.tsx.snap
index f9c6b600da..272ebe8215 100644
--- a/test/client/__snapshots__/Query.test.tsx.snap
+++ b/test/client/__snapshots__/Query.test.tsx.snap
@@ -7,27 +7,19 @@ Object {
"people": Array [
Object {
"name": "Luke Skywalker",
- Symbol(id): "$ROOT_QUERY.allPeople({\\"first\\":1}).people.0",
},
],
- Symbol(id): "$ROOT_QUERY.allPeople({\\"first\\":1})",
},
- Symbol(id): "ROOT_QUERY",
},
- "error": undefined,
- "fetchMore": [Function],
"loading": false,
"networkStatus": 7,
- "refetch": [Function],
- "startPolling": [Function],
- "stopPolling": [Function],
- "updateQuery": [Function],
+ "variables": Object {},
}
`;
exports[`Query component calls the children prop: result in render prop while loading 1`] = `
Object {
- "data": undefined,
+ "data": Object {},
"error": undefined,
"fetchMore": [Function],
"loading": true,
@@ -35,6 +27,8 @@ Object {
"refetch": [Function],
"startPolling": [Function],
"stopPolling": [Function],
+ "subscribeToMore": [Function],
"updateQuery": [Function],
+ "variables": Object {},
}
`;
diff --git a/test/client/getDataFromTree.test.tsx b/test/client/getDataFromTree.test.tsx
index 84f40c9be3..8358acb101 100644
--- a/test/client/getDataFromTree.test.tsx
+++ b/test/client/getDataFromTree.test.tsx
@@ -393,6 +393,7 @@ describe('SSR', () => {
const apolloClient = new ApolloClient({
link,
cache: new Cache({ addTypename: false }),
+ ssrMode: true,
});
interface Props {}
diff --git a/test/client/graphql/mutations/queries.test.tsx b/test/client/graphql/mutations/queries.test.tsx
index b704a8c2b6..449e156030 100644
--- a/test/client/graphql/mutations/queries.test.tsx
+++ b/test/client/graphql/mutations/queries.test.tsx
@@ -185,6 +185,7 @@ describe('graphql(mutation) query integration', () => {
,
);
});
+
it('allows for updating queries from a mutation automatically', done => {
const query: DocumentNode = gql`
query getMini($id: ID!) {
@@ -258,9 +259,11 @@ describe('graphql(mutation) query integration', () => {
class extends React.Component> {
componentWillReceiveProps(props: ChildProps) {
if (count === 1) {
- props.mutate!().then(result => {
- expect(stripSymbols(result.data)).toEqual(mutationData);
- });
+ props.mutate!()
+ .then(result => {
+ expect(stripSymbols(result.data)).toEqual(mutationData);
+ })
+ .catch(done.fail);
}
}
render() {
diff --git a/test/client/graphql/mutations/recycled-queries.test.tsx b/test/client/graphql/mutations/recycled-queries.test.tsx
index a4a69c2dec..4ca7a7f2a2 100644
--- a/test/client/graphql/mutations/recycled-queries.test.tsx
+++ b/test/client/graphql/mutations/recycled-queries.test.tsx
@@ -409,6 +409,176 @@ describe('graphql(mutation) update queries', () => {