From 24f38190649b2b7709f6d3a8c27531509da546d0 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Wed, 4 Jan 2017 20:57:43 -0500 Subject: [PATCH 1/4] Use generic component types for WrappedComponent --- src/graphql.tsx | 66 +++++++++++++++++++++++++++++-------------------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/src/graphql.tsx b/src/graphql.tsx index f4353b6be5..06443cacb4 100644 --- a/src/graphql.tsx +++ b/src/graphql.tsx @@ -1,5 +1,7 @@ import { Component, + ComponentClass, + StatelessComponent, createElement, PropTypes, } from 'react'; @@ -52,36 +54,46 @@ export declare interface QueryOptions { skip?: boolean; } -const defaultMapPropsToOptions = props => ({}); -const defaultMapResultToProps = props => props; -const defaultMapPropsToSkip = props => false; +export type WrappedComponent = ComponentClass | StatelessComponent; + +function defaultMapPropsToOptions(props: T) { + return {}; +} + +function defaultMapResultToProps(props: T): T { + return props; +} + +function defaultMapPropsToSkip(props: T) { + return false; +} // the fields we want to copy over to our data prop -function observableQueryFields(observable) { +function observableQueryFields(observable: any) { const fields = pick(observable, 'variables', 'refetch', 'fetchMore', 'updateQuery', 'startPolling', 'stopPolling', 'subscribeToMore'); Object.keys(fields).forEach((key) => { - if (typeof fields[key] === 'function') { - fields[key] = fields[key].bind(observable); + if (typeof (fields as any)[key] === 'function') { + (fields as any)[key] = (fields as any)[key].bind(observable); } }); return fields; } -function getDisplayName(WrappedComponent) { +function getDisplayName(WrappedComponent: WrappedComponent) { return WrappedComponent.displayName || WrappedComponent.name || 'Component'; } // Helps track hot reloading. let nextVersion = 0; -export function withApollo(WrappedComponent) { +export function withApollo(WrappedComponent: WrappedComponent) { const withDisplayName = `withApollo(${getDisplayName(WrappedComponent)})`; - class WithApollo extends Component { + class WithApollo extends Component { static displayName = withDisplayName; static WrappedComponent = WrappedComponent; static contextTypes = { client: PropTypes.object.isRequired }; @@ -89,7 +101,7 @@ export function withApollo(WrappedComponent) { // data storage private client: ApolloClient; // apollo client - constructor(props, context) { + constructor(props: T, context: { client: ApolloClient }) { super(props, context); this.client = context.client; @@ -105,7 +117,7 @@ export function withApollo(WrappedComponent) { render() { const props = assign({}, this.props); props.client = this.client; - return createElement(WrappedComponent, props); + return createElement(WrappedComponent as React.ComponentClass, props); } } @@ -142,11 +154,11 @@ export default function graphql( // Helps track hot reloading. const version = nextVersion++; - return function wrapWithApolloComponent(WrappedComponent) { + return function wrapWithApolloComponent(WrappedComponent: WrappedComponent) { const graphQLDisplayName = `Apollo(${getDisplayName(WrappedComponent)})`; - class GraphQL extends Component { + class GraphQL extends Component { static displayName = graphQLDisplayName; static WrappedComponent = WrappedComponent; static contextTypes = { @@ -155,7 +167,7 @@ export default function graphql( }; // react / redux and react dev tools (HMR) needs - public props: any; // passed props + public props: T; // passed props public version: number; public hasMounted: boolean; @@ -177,7 +189,7 @@ export default function graphql( // the element to render private renderedElement: any; - constructor(props, context) { + constructor(props: T, context: any) { super(props, context); this.version = version; this.client = context.client; @@ -205,7 +217,7 @@ export default function graphql( } } - componentWillReceiveProps(nextProps) { + componentWillReceiveProps(nextProps: T) { if (shallowEqual(this.props, nextProps)) return; this.shouldRerender = true; @@ -226,7 +238,7 @@ export default function graphql( this.subscribeToQuery(); } - shouldComponentUpdate(nextProps, nextState, nextContext) { + shouldComponentUpdate(nextProps: T, nextState: any, nextContext: any) { return !!nextContext || this.shouldRerender; } @@ -237,7 +249,7 @@ export default function graphql( this.hasMounted = false; } - calculateOptions(props = this.props, newOpts?) { + calculateOptions(props = this.props, newOpts?: any) { let opts = mapPropsToOptions(props); if (newOpts && newOpts.variables) { @@ -255,18 +267,18 @@ export default function graphql( for (let { variable, type } of operation.variables) { if (!variable.name || !variable.name.value) continue; - if (typeof props[variable.name.value] !== 'undefined') { - variables[variable.name.value] = props[variable.name.value]; + if (typeof (props as any)[variable.name.value] !== 'undefined') { + (variables as any)[variable.name.value] = (props as any)[variable.name.value]; continue; } // allow optional props if (type.kind !== 'NonNullType') { - variables[variable.name.value] = null; + (variables as any)[variable.name.value] = null; continue; } - invariant(typeof props[variable.name.value] !== 'undefined', + invariant(typeof (props as any)[variable.name.value] !== '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}'` @@ -276,7 +288,7 @@ export default function graphql( return opts; }; - calculateResultProps(result) { + calculateResultProps(result: any) { let name = this.type === DocumentType.Mutation ? 'mutate' : 'data'; if (operationOptions.name) name = operationOptions.name; @@ -315,7 +327,7 @@ export default function graphql( } } - updateQuery(props) { + updateQuery(props: T) { const opts = this.calculateOptions(props) as QueryOptions; // if we skipped initially, we may not have yet created the observable @@ -377,7 +389,7 @@ export default function graphql( this.forceRenderChildren(); }; - const handleError = (error) => { + const handleError = (error: any) => { // Quick fix for https://github.com/apollostack/react-apollo/issues/378 if (error.hasOwnProperty('graphQLErrors')) return next({ error }); throw error; @@ -461,7 +473,7 @@ export default function graphql( render() { if (this.shouldSkip()) { - return createElement(WrappedComponent, this.props); + return createElement(WrappedComponent as React.ComponentClass, this.props); } const { shouldRerender, renderedElement, props } = this; @@ -477,7 +489,7 @@ export default function graphql( if (operationOptions.withRef) mergedPropsAndData.ref = 'wrappedInstance'; - this.renderedElement = createElement(WrappedComponent, mergedPropsAndData); + this.renderedElement = createElement(WrappedComponent as React.ComponentClass, mergedPropsAndData); return this.renderedElement; } } From 99f9968896a5668ba7df6cf89eea5923e769aa72 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Wed, 4 Jan 2017 22:41:48 -0500 Subject: [PATCH 2/4] Enhance type information on observableQueryFields --- src/graphql.tsx | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/graphql.tsx b/src/graphql.tsx index 06443cacb4..1ddfa696a4 100644 --- a/src/graphql.tsx +++ b/src/graphql.tsx @@ -6,6 +6,9 @@ import { PropTypes, } from 'react'; +import { FetchMoreOptions, UpdateQueryOptions } from './../node_modules/apollo-client/core/ObservableQuery'; +import { FetchMoreQueryOptions, SubscribeToMoreOptions } from './../node_modules/apollo-client/core/watchQueryOptions'; + // modules don't export ES6 modules import pick = require('lodash.pick'); import flatten = require('lodash.flatten'); @@ -68,9 +71,18 @@ function defaultMapPropsToSkip(props: T) { return false; } +interface ObservableQueryFields { + variables: any; + refetch(variables?: any): Promise; + fetchMore(fetchMoreOptions: FetchMoreQueryOptions & FetchMoreOptions): Promise; + updateQuery(mapFn: (previousQueryResult: any, options: UpdateQueryOptions) => any): void; + startPolling(pollInterval: number): void; + stopPolling(): void; + subscribeToMore(options: SubscribeToMoreOptions): () => void; +} // the fields we want to copy over to our data prop -function observableQueryFields(observable: any) { - const fields = pick(observable, 'variables', +function observableQueryFields(observable: ObservableQuery): ObservableQueryFields { + const fields = pick(observable, 'variables', 'refetch', 'fetchMore', 'updateQuery', 'startPolling', 'stopPolling', 'subscribeToMore'); Object.keys(fields).forEach((key) => { @@ -180,8 +192,8 @@ export default function graphql( // unsubscribe but never delete queryObservable once it is created. private queryObservable: ObservableQuery | any; private querySubscription: Subscription; - private previousData: any = {}; - private lastSubscriptionData: any; + private previousData: { [i: string]: any } = {}; + private lastSubscriptionData: { [i: string]: any }; // calculated switches to control rerenders private shouldRerender: boolean; @@ -445,30 +457,31 @@ export default function graphql( } const opts = this.calculateOptions(this.props); - const data = {}; - assign(data, observableQueryFields(this.queryObservable)); + const data = assign({}, observableQueryFields(this.queryObservable)); + + type ResultData = { [i: string]: any }; if (this.type === DocumentType.Subscription) { - assign(data, { + return assign(data, { loading: !this.lastSubscriptionData, variables: opts.variables, - }, this.lastSubscriptionData); + }, this.lastSubscriptionData as ResultData); } else { // fetch the current result (if any) from the store const currentResult = this.queryObservable.currentResult(); const { loading, error, networkStatus } = currentResult; - assign(data, { loading, error, networkStatus }); + const dataWithCurrentResult = assign(data, { loading, error, networkStatus }); if (loading) { // while loading, we should use any previous data we have - assign(data, this.previousData, currentResult.data); + return assign(dataWithCurrentResult, this.previousData, currentResult.data as ResultData); } else { - assign(data, currentResult.data); + const result = assign(dataWithCurrentResult, currentResult.data as ResultData); this.previousData = currentResult.data; + return result; } } - return data; } render() { From 9a9c283a6c952b7b5f4cc23c1a447a0e0fe4729a Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Wed, 4 Jan 2017 22:42:55 -0500 Subject: [PATCH 3/4] Attempt to enhance information on calculateResultProps --- src/graphql.tsx | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/graphql.tsx b/src/graphql.tsx index 1ddfa696a4..d4be4b661a 100644 --- a/src/graphql.tsx +++ b/src/graphql.tsx @@ -63,7 +63,7 @@ function defaultMapPropsToOptions(props: T) { return {}; } -function defaultMapResultToProps(props: T): T { +function defaultMapResultToProps(props: T) { return props; } @@ -139,7 +139,7 @@ export function withApollo(WrappedComponent: export interface OperationOption { options?: Object | ((props: any) => QueryOptions | MutationOptions); - props?: (props: any) => any; + props?: (props: TOwnProps) => TMappedProps; skip?: boolean | ((props: any) => boolean); name?: string; withRef?: boolean; @@ -300,14 +300,27 @@ export default function graphql( return opts; }; - calculateResultProps(result: any) { - let name = this.type === DocumentType.Mutation ? 'mutate' : 'data'; - if (operationOptions.name) name = operationOptions.name; - - const newResult = { [name]: result, ownProps: this.props }; - if (mapResultToProps) return mapResultToProps(newResult); + calculateResultProps(result: T) { + // Ugly, but the hope is to allow typescript to do control-flow analysis + // to determine if `data` or `mutate` are the keys + if (operationOptions.name != null) { + let name = operationOptions.name; + const newResult = { [name]: result, ownProps: this.props }; + // Prevents us inferring useful type information :/ + if (mapResultToProps) return mapResultToProps(newResult); + + return { [name]: defaultMapResultToProps(result) }; + } else if (this.type === DocumentType.Mutation) { + const newResult = { mutate: result, ownProps: this.props }; + if (mapResultToProps) return mapResultToProps(newResult); + + return { mutate: defaultMapResultToProps(result) }; + } else { + const newResult = { data: result, ownProps: this.props }; + if (mapResultToProps) return mapResultToProps(newResult); - return { [name]: defaultMapResultToProps(result) }; + return { data: defaultMapResultToProps(result) }; + } } setInitialProps() { @@ -500,7 +513,7 @@ export default function graphql( return renderedElement; } - if (operationOptions.withRef) mergedPropsAndData.ref = 'wrappedInstance'; + if (operationOptions.withRef) mergedPropsAndData['ref'] = 'wrappedInstance'; this.renderedElement = createElement(WrappedComponent as React.ComponentClass, mergedPropsAndData); return this.renderedElement; @@ -508,7 +521,8 @@ export default function graphql( } // Make sure we preserve any custom statics on the original component. - return hoistNonReactStatics(GraphQL, WrappedComponent, {}); - }; + hoistNonReactStatics(GraphQL, WrappedComponent, {}); + return GraphQL as React.ComponentClass; + }; }; From d91ae89baec9327a3e0547b477cf1ef700d47c2c Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Wed, 4 Jan 2017 23:12:22 -0500 Subject: [PATCH 4/4] Use typeof WrappedComponent as return type --- src/graphql.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/graphql.tsx b/src/graphql.tsx index d4be4b661a..069ee48dcf 100644 --- a/src/graphql.tsx +++ b/src/graphql.tsx @@ -523,6 +523,6 @@ export default function graphql( // Make sure we preserve any custom statics on the original component. hoistNonReactStatics(GraphQL, WrappedComponent, {}); - return GraphQL as React.ComponentClass; + return GraphQL as typeof WrappedComponent; }; };