diff --git a/.flowconfig b/.flowconfig new file mode 100644 index 0000000000..c2a4545473 --- /dev/null +++ b/.flowconfig @@ -0,0 +1,12 @@ +[ignore] +.*/examples/**/.* +.*/node_modules/art/.* +.*/node_modules/react-native/**/.* + +[include] + +[libs] +./index.js.flow + +[options] +suppress_comment= \\(.\\|\n\\)*\\$ExpectError diff --git a/Changelog.md b/Changelog.md index e455bf7861..b807098753 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,9 +1,13 @@ # Change log -Expect active development and potentially significant breaking changes in the `0.x` track. We'll try to be diligent about releasing a `1.0` version in a timely fashion (ideally within 1 or 2 months), so that we can take advantage of SemVer to signify breaking changes from that point on. - ### vNext +### 1.4.0 +#### BREAKING FOR TYPESCRIPT USERS +- Feature: Enhanced typescript definitions to allow for more valid type checking of graphql HOC [PR #695](https://github.com/apollographql/react-apollo/pull/695) +- Feature: Flow types: [PR #695](https://github.com/apollographql/react-apollo/pull/695) + + ### 1.3.0 - Feature: Support tree shaking and smaller (marginally) bundles via rollup [PR #691](https://github.com/apollographql/react-apollo/pull/691) - Fix: Render full markup on the server when using the `cache-and-network` fetchPolicy [PR #688](https://github.com/apollographql/react-apollo/pull/688) diff --git a/index.js.flow b/index.js.flow new file mode 100644 index 0000000000..dca467b241 --- /dev/null +++ b/index.js.flow @@ -0,0 +1,165 @@ +import type { + ApolloClient, + MutationQueryReducersMap, + ApolloQueryResult, + ApolloError, + FetchPolicy, + FetchMoreOptions, + UpdateQueryOptions, + FetchMoreQueryOptions, + SubscribeToMoreOptions, +} from "apollo-client"; +import type { Store } from "redux"; +import type { DocumentNode, VariableDefinitionNode } from "graphql"; + +declare module "react-apollo" { + declare type StatelessComponent

= (props: P) => ?React$Element; + + declare export interface ProviderProps { + store?: Store, + client: ApolloClient, + } + + declare export class ApolloProvider extends React$Component { + props: ProviderProps, + childContextTypes: { + store: Store, + client: ApolloClient, + }, + contextTypes: { + store: Store, + }, + getChildContext(): { + store: Store, + client: ApolloClient, + }, + render(): React$Element<*>, + } + declare export type MutationFunc = ( + opts: MutationOpts + ) => Promise>; + + declare export type DefaultChildProps = { + data: QueryProps & R, + mutate: MutationFunc, + } & P; + + declare export interface MutationOpts { + variables?: { [key: string]: mixed }, + optimisticResponse?: Object, + updateQueries?: MutationQueryReducersMap, + } + + declare export interface QueryOpts { + ssr?: boolean, + variables?: { + [key: string]: mixed, + }, + fetchPolicy?: FetchPolicy, + pollInterval?: number, + skip?: boolean, + } + + declare export interface QueryProps { + error?: ApolloError, + networkStatus: number, + loading: boolean, + variables: { + [variable: string]: any, + }, + fetchMore: ( + fetchMoreOptions: FetchMoreQueryOptions & FetchMoreOptions + ) => Promise>, + refetch: (variables?: any) => Promise>, + startPolling: (pollInterval: number) => void, + stopPolling: () => void, + subscribeToMore: (options: SubscribeToMoreOptions) => () => void, + updateQuery: ( + mapFn: (previousQueryResult: any, options: UpdateQueryOptions) => any + ) => void, + } + + declare export interface OptionProps { + ownProps: TProps, + data?: QueryProps & TResult, + mutate?: MutationFunc, + } + + declare export type OptionDescription

= ( + props: P + ) => QueryOpts | MutationOpts; + + declare export interface OperationOption { + options?: OptionDescription, + props?: (props: OptionProps) => any, + skip?: boolean | ((props: any) => boolean), + name?: string, + withRef?: boolean, + shouldResubscribe?: (props: TProps, nextProps: TProps) => boolean, + alias?: string, + } + + declare export interface OperationComponent< + TResult: Object = {}, + TOwnProps: Object = {}, + TMergedProps = DefaultChildProps + > { + ( + component: + | StatelessComponent + | React$Component<*, TMergedProps, *> + ): React$Component<*, TOwnProps, *>, + } + + declare export function graphql( + document: DocumentNode, + operationOptions?: OperationOption + ): OperationComponent; + + declare export interface IDocumentDefinition { + type: DocumentType, + name: string, + variables: VariableDefinitionNode[], + } + + declare export function parser(document: DocumentNode): IDocumentDefinition; + + declare export interface Context { + client?: ApolloClient, + store?: Store, + [key: string]: any + } + + declare export interface QueryTreeArgument { + rootElement: React$Element<*>, + rootContext?: Context + } + + declare export interface QueryResult { + query: Promise>, + element: React$Element<*>, + context: Context + } + + declare export function walkTree( + element: React$Element<*>, + context: Context, + visitor: ( + element: React$Element<*>, + instance: any, + context: Context + ) => boolean | void + ): void; + + declare export function getDataFromTree( + rootElement: React$Element<*>, + rootContext?: any, + fetchRoot?: boolean + ): Promise; + + declare export function renderToStringWithData( + component: React$Element<*> + ): Promise; + + declare export function cleanupApolloState(apolloState: any): void; +} diff --git a/package.json b/package.json index cad815d2b6..94742c54a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-apollo", - "version": "1.3.0", + "version": "1.4.0", "description": "React data container for Apollo Client", "main": "lib/react-apollo.umd.js", "module": "./lib/index.js", @@ -14,10 +14,11 @@ "test-watch": "jest --watch", "posttest": "npm run lint", "filesize": "npm run compile:browser && ./scripts/filesize.js --file=./dist/index.min.js --maxGzip=20", + "flow-check": "flow check", "compile": "tsc", - "bundle": "rollup -c && rollup -c rollup.browser.config.js && rollup -c rollup.test-utils.config.js", + "bundle": "rollup -c && rollup -c rollup.browser.config.js && rollup -c rollup.test-utils.config.js && cp ./index.js.flow ./lib", "compile:browser": "rm -rf ./dist && mkdir ./dist && browserify ./lib/react-apollo.browser.umd.js --i graphql-tag --i react --i apollo-client -o=./dist/index.js && npm run minify:browser && npm run compress:browser", - "minify:browser": "uglifyjs --compress --mangle --screw-ie8 -o=./dist/index.min.js -- ./dist/index.js", + "minify:browser": "uglifyjs -c -m -o ./dist/index.min.js -- ./dist/index.js", "compress:browser": "./scripts/gzip.js --file=./dist/index.min.js", "watch": "tsc -w", "lint": "tslint 'src/*.ts*' && tslint 'test/*.ts*'" @@ -54,9 +55,10 @@ "json" ], "modulePathIgnorePatterns": [ - "/examples" + "/examples", + "/test/flow.js" ], - "testRegex": "/test/.*.test.(ts|tsx|js)$", + "testRegex": "(/test/.*|\\.(test|spec))\\.(ts|tsx|js)$", "collectCoverage": true }, "license": "MIT", @@ -92,7 +94,7 @@ "@types/redux-form": "^6.3.2", "@types/redux-immutable": "^3.0.30", "@types/sinon": "^2.1.1", - "babel-jest": "^20.0.0", + "babel-jest": "^19.0.0", "babel-preset-react-native": "^1.9.0", "browserify": "^14.1.0", "cheerio": "^0.22.0", @@ -103,7 +105,7 @@ "graphql": "^0.9.1", "immutable": "^3.8.1", "isomorphic-fetch": "^2.2.1", - "jest": "^20.0.0", + "jest": "^19.0.0", "jest-react-native": "^18.0.0", "jsdom": "^11.0.0", "lodash": "^4.16.6", @@ -114,7 +116,7 @@ "react": "^15.5.4", "react-addons-test-utils": "^15.5.1", "react-dom": "^15.5.4", - "react-native": "^0.44.1", + "react-native": "^0.42.3", "react-redux": "^5.0.3", "react-test-renderer": "^15.5.4", "recompose": "^0.23.0", @@ -127,10 +129,10 @@ "swapi-graphql": "0.0.6", "travis-weigh-in": "^1.0.2", "tslint": "^5.1.0", - "typescript": "^2.2.0", + "typescript": "^2.3.0", "typescript-require": "^0.2.9-1", "typings": "^2.1.0", - "uglify-js": "^2.6.2" + "uglify-js": "^3.0.13" }, "dependencies": { "apollo-client": "^1.2.2", diff --git a/src/browser.ts b/src/browser.ts index 1893bd351f..fd8c7160c5 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -1,5 +1,14 @@ export { default as ApolloProvider } from './ApolloProvider'; -export { default as graphql, InjectedGraphQLProps } from './graphql'; +export { + default as graphql, + MutationOpts, + QueryOpts, + QueryProps, + MutationFunc, + OptionProps, + DefaultChildProps, + OperationOption, + } from './graphql'; export { withApollo } from './withApollo'; // expose easy way to join queries from redux diff --git a/src/graphql.tsx b/src/graphql.tsx index 2c3d911905..a68ec8c219 100644 --- a/src/graphql.tsx +++ b/src/graphql.tsx @@ -29,19 +29,19 @@ import ApolloClient, { } from 'apollo-client'; import { - // GraphQLResult, + ExecutionResult, DocumentNode, } from 'graphql'; import { parser, DocumentType } from './parser'; -export declare interface MutationOptions { +export declare interface MutationOpts { variables?: Object; optimisticResponse?: Object; updateQueries?: MutationQueryReducersMap; } -export declare interface QueryOptions { +export declare interface QueryOpts { ssr?: boolean; variables?: { [key: string]: any }; fetchPolicy?: FetchPolicy; @@ -50,7 +50,7 @@ export declare interface QueryOptions { skip?: boolean; } -export interface GraphQLDataProps { +export interface QueryProps { error?: ApolloError; networkStatus: number; loading: boolean; @@ -65,8 +65,33 @@ export interface GraphQLDataProps { updateQuery: (mapFn: (previousQueryResult: any, options: UpdateQueryOptions) => any) => void; } -export interface InjectedGraphQLProps { - data?: T & GraphQLDataProps; +export type MutationFunc = (opts: MutationOpts) => Promise>; + +export interface OptionProps { + ownProps: TProps; + data?: QueryProps & TResult; + mutate?: MutationFunc; +} + +export type DefaultChildProps = P & { data?: QueryProps & R, mutate?: MutationFunc }; + +export interface OperationOption { + options?: QueryOpts | MutationOpts | ((props: TProps) => QueryOpts | MutationOpts); + props?: (props: OptionProps) => any; + skip?: boolean | ((props: any) => boolean); + name?: string; + withRef?: boolean; + shouldResubscribe?: (props: TProps, nextProps: TProps) => boolean; + alias?: string; +} + +export type CompositeComponent

= ComponentClass

| StatelessComponent

; + +export interface ComponentDecorator { + (component: CompositeComponent): ComponentClass; +} +export interface InferableComponentDecorator { + >(component: T): T; } const defaultMapPropsToOptions = props => ({}); @@ -94,29 +119,19 @@ function getDisplayName(WrappedComponent) { // Helps track hot reloading. let nextVersion = 0; -export interface OperationOption { - options?: Object | ((props: any) => QueryOptions | MutationOptions); - props?: (props: any) => any; - skip?: boolean | ((props: any) => boolean); - name?: string; - withRef?: boolean; - shouldResubscribe?: (props: any, nextProps: any) => boolean; - alias?: string; -} - -export interface WrapWithApollo { - | StatelessComponent

)>(component: TComponentConstruct): TComponentConstruct; -} - -export default function graphql( +export default function graphql>( document: DocumentNode, - operationOptions: OperationOption = {}, -) { + operationOptions: OperationOption = {}, +): ComponentDecorator { // extract options - const { options = defaultMapPropsToOptions, skip = defaultMapPropsToSkip, alias = 'Apollo' } = operationOptions; + const { + options = defaultMapPropsToOptions, + skip = defaultMapPropsToSkip, + alias = 'Apollo', + } = operationOptions; - let mapPropsToOptions = options as (props: any) => QueryOptions | MutationOptions; + let mapPropsToOptions = options as (props: any) => QueryOpts | MutationOpts; if (typeof mapPropsToOptions !== 'function') mapPropsToOptions = () => options; let mapPropsToSkip = skip as (props: any) => boolean; @@ -130,7 +145,7 @@ export default function graphql( // Helps track hot reloading. const version = nextVersion++; - const wrapWithApolloComponent: WrapWithApollo = WrappedComponent => { + function wrapWithApolloComponent(WrappedComponent) { const graphQLDisplayName = `${alias}(${getDisplayName(WrappedComponent)})`; @@ -142,7 +157,7 @@ export default function graphql( // However, this is an unlikely scenario. const recycler = new ObservableQueryRecycler(); - class GraphQL extends Component { + class GraphQL extends Component { static displayName = graphQLDisplayName; static WrappedComponent = WrappedComponent; static contextTypes = { @@ -297,11 +312,11 @@ export default function graphql( return opts; } - calculateResultProps(result) { + calculateResultProps(result: (QueryProps & TResult) | MutationFunc) { let name = this.type === DocumentType.Mutation ? 'mutate' : 'data'; if (operationOptions.name) name = operationOptions.name; - const newResult = { [name]: result, ownProps: this.props }; + const newResult: OptionProps = { [name]: result, ownProps: this.props }; if (mapResultToProps) return mapResultToProps(newResult); return { [name]: defaultMapResultToProps(result) }; @@ -314,12 +329,12 @@ export default function graphql( // Create the observable but don't subscribe yet. The query won't // fire until we do. - const opts: QueryOptions = this.calculateOptions(this.props); + const opts: QueryOpts = this.calculateOptions(this.props); this.createQuery(opts); } - createQuery(opts: QueryOptions) { + createQuery(opts: QueryOpts) { if (this.type === DocumentType.Subscription) { this.queryObservable = this.client.subscribe(assign({ query: document, @@ -346,7 +361,7 @@ export default function graphql( } updateQuery(props) { - const opts = this.calculateOptions(props) as QueryOptions; + const opts = this.calculateOptions(props) as QueryOpts; // if we skipped initially, we may not have yet created the observable if (!this.queryObservable) { @@ -441,7 +456,7 @@ export default function graphql( shouldSkip(props = this.props) { return mapPropsToSkip(props) || - (mapPropsToOptions(props) as QueryOptions).skip; + (mapPropsToOptions(props) as QueryOpts).skip; } forceRenderChildren() { @@ -461,7 +476,7 @@ export default function graphql( dataForChild() { if (this.type === DocumentType.Mutation) { - return (mutationOpts: MutationOptions) => { + return (mutationOpts: MutationOpts) => { const opts = this.calculateOptions(this.props, mutationOpts); if (typeof opts.variables === 'undefined') delete opts.variables; @@ -520,7 +535,7 @@ export default function graphql( this.previousData = currentResult.data; } } - return data; + return (data as QueryProps & TResult); } render() { @@ -547,7 +562,7 @@ export default function graphql( // Make sure we preserve any custom statics on the original component. return hoistNonReactStatics(GraphQL, WrappedComponent, {}); - }; + } return wrapWithApolloComponent; } @@ -611,7 +626,7 @@ class ObservableQueryRecycler { * All mutations that occured between the time of recycling and the time of * reusing have been applied. */ - public reuse (options: QueryOptions): ObservableQuery { + public reuse (options: QueryOpts): ObservableQuery { if (this.observableQueries.length <= 0) { return null; } diff --git a/src/server.ts b/src/server.ts index a272e6612b..f052590e2e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,34 +1,34 @@ -import { Children } from 'react'; +import { Children, ReactElement, ComponentClass, StatelessComponent } from 'react'; import * as ReactDOM from 'react-dom/server'; -import ApolloClient from 'apollo-client'; +import ApolloClient, { ApolloQueryResult } from 'apollo-client'; const assign = require('object-assign'); -declare interface Context { +export declare interface Context { client?: ApolloClient; store?: any; [key: string]: any; } -declare interface QueryTreeArgument { - rootElement: any; +export declare interface QueryTreeArgument { + rootElement: ReactElement; rootContext?: Context; } -declare interface QueryResult { - query: Promise; - element: any; - context: any; +export declare interface QueryResult { + query: Promise>; + element: ReactElement; + context: Context; } // Recurse an React Element tree, running visitor on each element. // If visitor returns `false`, don't call the element's render function // or recurse into its child elements export function walkTree( - element: any, - context: any, - visitor: (element: any, instance: any, context: any) => boolean | void, + element: ReactElement, + context: Context, + visitor: (element: ReactElement, instance: any, context: Context) => boolean | void, ) { const Component = element.type; // a stateless functional component or a class @@ -40,7 +40,10 @@ export function walkTree( // Are we are a react class? // https://github.com/facebook/react/blob/master/src/renderers/shared/stack/reconciler/ReactCompositeComponent.js#L66 if (Component.prototype && Component.prototype.isReactComponent) { - const instance = new Component(props, context); + // typescript force casting since typescript doesn't have definitions for class + // methods + const _component = Component as any; + const instance = new _component(props, context); // In case the user doesn't pass these to super in the constructor instance.props = instance.props || props; instance.context = instance.context || context; @@ -73,7 +76,9 @@ export function walkTree( return; } - child = Component(props, context); + // typescript casting for stateless component + const _component = Component as StatelessComponent; + child = _component(props, context); } if (child) { @@ -118,7 +123,7 @@ function getQueriesFromTree( } // XXX component Cache -export function getDataFromTree(rootElement, rootContext: any = {}, fetchRoot: boolean = true): Promise { +export function getDataFromTree(rootElement: ReactElement, rootContext: any = {}, fetchRoot: boolean = true): Promise { let queries = getQueriesFromTree({ rootElement, rootContext }, fetchRoot); @@ -149,7 +154,7 @@ export function getDataFromTree(rootElement, rootContext: any = {}, fetchRoot: b }); } -export function renderToStringWithData(component) { +export function renderToStringWithData(component: ReactElement): Promise { return getDataFromTree(component) .then(() => ReactDOM.renderToString(component)); } diff --git a/src/withApollo.tsx b/src/withApollo.tsx index 2145845ab3..81637cea08 100644 --- a/src/withApollo.tsx +++ b/src/withApollo.tsx @@ -33,19 +33,15 @@ import { import { parser, DocumentType } from './parser'; import { OperationOption, - MutationOptions, - QueryOptions, - GraphQLDataProps, - InjectedGraphQLProps, } from './graphql'; function getDisplayName(WrappedComponent) { return WrappedComponent.displayName || WrappedComponent.name || 'Component'; } -export function withApollo( +export function withApollo( WrappedComponent, - operationOptions: OperationOption = {}, + operationOptions: OperationOption = {}, ) { const withDisplayName = `withApollo(${getDisplayName(WrappedComponent)})`; diff --git a/test/flow.js b/test/flow.js new file mode 100644 index 0000000000..4c1fb0d3e9 --- /dev/null +++ b/test/flow.js @@ -0,0 +1,34 @@ +/* + + This file is used to validate the flow typings for react-apollo. + Currently it just serves as a smoke test around used imports and + common usage patterns. + + Ideally this should include tests for all of the functionality of + react-apollo + +*/ + +// @flow +import { graphql } from "react-apollo"; +import type { OperationComponent } from "react-apollo"; +import type { DocumentNode } from "graphql"; +import gql from "graphql-tag"; + +const query: DocumentNode = gql`{ foo }`; +const mutation: DocumentNode = gql`mutation { foo }`; + +type IQuery = { + foo: string, +}; + +// common errors + +const withData: OperationComponent = graphql(query); + +const ComponentWithData = withData(({ data: { foo }}) => { + // $ExpectError + if (foo > 1) return ; + + return null; +}); diff --git a/test/index.ts b/test/index.ts deleted file mode 100644 index e36c6bbe79..0000000000 --- a/test/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { install } from 'source-map-support'; - -install({ environment: 'node' }); diff --git a/test/react-web/client/graphql/queries/index.test.tsx b/test/react-web/client/graphql/queries/index.test.tsx index 8b39db915f..f97be216d9 100644 --- a/test/react-web/client/graphql/queries/index.test.tsx +++ b/test/react-web/client/graphql/queries/index.test.tsx @@ -15,6 +15,7 @@ declare function require(name: string); import { mockNetworkInterface } from '../../../../../src/test-utils'; import { ApolloProvider, graphql} from '../../../../../src'; +import { DocumentType } from '../../../../../src/parser'; // XXX: this is also defined in apollo-client // I'm not sure why mocha doesn't provide something like this, you can't @@ -40,9 +41,15 @@ describe('queries', () => { const networkInterface = mockNetworkInterface({ request: { query }, result: { data } }); const client = new ApolloClient({ networkInterface, addTypename: false }); - const ContainerWithData = graphql(query)(({ data }) => { // tslint:disable-line + type ResultData = { + allPeople?: { + people: { name: string }[] + } + } + + + const ContainerWithData = graphql(query)(({ data }) => { // tslint:disable-line expect(data).toBeTruthy(); - expect(data.ownProps).toBeFalsy(); expect(data.loading).toBe(true); return null; }); diff --git a/test/react-web/client/graphql/queries/reducer.test.tsx b/test/react-web/client/graphql/queries/reducer.test.tsx index 355e306ba2..575754c111 100644 --- a/test/react-web/client/graphql/queries/reducer.test.tsx +++ b/test/react-web/client/graphql/queries/reducer.test.tsx @@ -40,8 +40,15 @@ describe('[queries] reducer', () => { const networkInterface = mockNetworkInterface({ request: { query }, result: { data } }); const client = new ApolloClient({ networkInterface, addTypename: false }); - const props = ({ data }) => ({ showSpinner: data.loading }); - const ContainerWithData = graphql(query, { props })(({ showSpinner }) => { + type ResultData = { + getThing: { thing: boolean } + } + type ResultShape = { + showSpinner: boolean + } + + const props = (result) => ({ showSpinner: result.data && result.data.loading }); + const ContainerWithData = graphql(query, { props })(({ showSpinner }) => { expect(showSpinner).toBe(true); return null; }); @@ -60,7 +67,16 @@ describe('[queries] reducer', () => { expect(ownProps.sample).toBe(1); return { showSpinner: data.loading }; }; - const ContainerWithData = graphql(query, { props })(({ showSpinner }) => { + type ResultData = { + getThing: { thing: boolean } + } + type ReducerResult = { + showSpinner: boolean, + } + type Props = { + sample: number + } + const ContainerWithData = graphql(query, { props })(({ showSpinner }) => { expect(showSpinner).toBe(true); return null; }); @@ -77,9 +93,20 @@ describe('[queries] reducer', () => { const networkInterface = mockNetworkInterface({ request: { query }, result: { data } }); const client = new ApolloClient({ networkInterface, addTypename: false }); - @graphql(query, { props: ({ data }) => ({ thingy: data.getThing }) }) // tslint:disable-line - class Container extends React.Component { - componentWillReceiveProps(props) { + type Result = { + getThing?: { thing: boolean } + } + + type PropsResult = { + thingy: boolean + } + + const withData = graphql(query, { + props: ({ data }) => ({ thingy: data.getThing }) + }) + + class Container extends React.Component { + componentWillReceiveProps(props: PropsResult) { expect(props.thingy).toEqual(data.getThing); done(); } @@ -88,7 +115,9 @@ describe('[queries] reducer', () => { } }; - renderer.create(); + const ContainerWithData = withData(Container); + + renderer.create(); });