diff --git a/Changelog.md b/Changelog.md index 6808804b45..c5c0b7453c 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,12 @@ # Change log +## vNext + +- When using `React.createContext` and SSR, we now make sure the context + provider value is reset to the previous value it had after its children are + walked.
+ [@mitchellhamilton](https://github.com/mitchellhamilton) in [#2304](https://github.com/apollographql/react-apollo/pull/2304) + ## 2.2.1 (September 26, 2018) - Revert: "Typescript: use `Partial` instead of `TData | {}`, for the diff --git a/src/getDataFromTree.ts b/src/getDataFromTree.ts index 8008f09c8a..a0467c11f8 100755 --- a/src/getDataFromTree.ts +++ b/src/getDataFromTree.ts @@ -1,253 +1,275 @@ -import React from 'react'; - -export interface Context { - [key: string]: any; -} - -interface PromiseTreeArgument { - rootElement: React.ReactNode; - rootContext?: Context; -} -interface FetchComponent extends React.Component { - fetchData(): Promise; -} - -interface PromiseTreeResult { - promise: Promise; - context: Context; - instance: FetchComponent; -} - -interface PreactElement

{ - attributes: P; -} - -function getProps

(element: React.ReactElement

| PreactElement

): P { - return (element as React.ReactElement

).props || (element as PreactElement

).attributes; -} - -function isReactElement(element: React.ReactNode): element is React.ReactElement { - return !!(element as any).type; -} - -function isComponentClass(Comp: React.ComponentType): Comp is React.ComponentClass { - return Comp.prototype && (Comp.prototype.render || Comp.prototype.isReactComponent); -} - -function providesChildContext( - instance: React.Component, -): instance is React.Component & React.ChildContextProvider { - return !!(instance as any).getChildContext; -} - -// Recurse a 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: React.ReactNode, - context: Context, - visitor: ( - element: React.ReactNode, - instance: React.Component | null, - context: Context, - childContext?: Context, - ) => boolean | void, -) { - if (Array.isArray(element)) { - element.forEach(item => walkTree(item, context, visitor)); - return; - } - - if (!element) { - return; - } - - // A stateless functional component or a class - if (isReactElement(element)) { - if (typeof element.type === 'function') { - const Comp = element.type; - const props = Object.assign({}, Comp.defaultProps, getProps(element)); - let childContext = context; - let child; - - // Are we are a react class? - if (isComponentClass(Comp)) { - const instance = new Comp(props, context); - // In case the user doesn't pass these to super in the constructor. - // Note: `Component.props` are now readonly in `@types/react`, so - // we're using `defineProperty` as a workaround (for now). - Object.defineProperty(instance, 'props', { - value: instance.props || props, - }); - instance.context = instance.context || context; - - // Set the instance state to null (not undefined) if not set, to match React behaviour - instance.state = instance.state || null; - - // Override setState to just change the state, not queue up an update - // (we can't do the default React thing as we aren't mounted - // "properly", however we don't need to re-render as we only support - // setState in componentWillMount, which happens *before* render). - instance.setState = newState => { - if (typeof newState === 'function') { - // React's TS type definitions don't contain context as a third parameter for - // setState's updater function. - // Remove this cast to `any` when that is fixed. - newState = (newState as any)(instance.state, instance.props, instance.context); - } - instance.state = Object.assign({}, instance.state, newState); - }; - - if (Comp.getDerivedStateFromProps) { - const result = Comp.getDerivedStateFromProps(instance.props, instance.state); - if (result !== null) { - instance.state = Object.assign({}, instance.state, result); - } - } else if (instance.UNSAFE_componentWillMount) { - instance.UNSAFE_componentWillMount(); - } else if (instance.componentWillMount) { - instance.componentWillMount(); - } - - if (providesChildContext(instance)) { - childContext = Object.assign({}, context, instance.getChildContext()); - } - - if (visitor(element, instance, context, childContext) === false) { - return; - } - - child = instance.render(); - } else { - // Just a stateless functional - if (visitor(element, null, context) === false) { - return; - } - - child = Comp(props, context); - } - - if (child) { - if (Array.isArray(child)) { - child.forEach(item => walkTree(item, childContext, visitor)); - } else { - walkTree(child, childContext, visitor); - } - } - } else if ((element.type as any)._context || (element.type as any).Consumer) { - // A React context provider or consumer - if (visitor(element, null, context) === false) { - return; - } - - let child; - if ((element.type as any)._context) { - // A provider - sets the context value before rendering children - ((element.type as any)._context as any)._currentValue = element.props.value; - child = element.props.children; - } else { - // A consumer - child = element.props.children((element.type as any)._currentValue); - } - - if (child) { - if (Array.isArray(child)) { - child.forEach(item => walkTree(item, context, visitor)); - } else { - walkTree(child, context, visitor); - } - } - } else { - // A basic string or dom element, just get children - if (visitor(element, null, context) === false) { - return; - } - - if (element.props && element.props.children) { - React.Children.forEach(element.props.children, (child: any) => { - if (child) { - walkTree(child, context, visitor); - } - }); - } - } - } else if (typeof element === 'string' || typeof element === 'number') { - // Just visit these, they are leaves so we don't keep traversing. - visitor(element, null, context); - } - // TODO: Portals? -} - -function hasFetchDataFunction(instance: React.Component): instance is FetchComponent { - return typeof (instance as any).fetchData === 'function'; -} - -function isPromise(promise: Object): promise is Promise { - return typeof (promise as any).then === 'function'; -} - -function getPromisesFromTree({ - rootElement, - rootContext = {}, -}: PromiseTreeArgument): PromiseTreeResult[] { - const promises: PromiseTreeResult[] = []; - - walkTree(rootElement, rootContext, (_, instance, context, childContext) => { - if (instance && hasFetchDataFunction(instance)) { - const promise = instance.fetchData(); - if (isPromise(promise)) { - promises.push({ promise, context: childContext || context, instance }); - return false; - } - } - }); - - return promises; -} - -function getDataAndErrorsFromTree( - rootElement: React.ReactNode, - rootContext: any = {}, - storeError: Function, -): Promise { - const promises = getPromisesFromTree({ rootElement, rootContext }); - - if (!promises.length) { - return Promise.resolve(); - } - - const mappedPromises = promises.map(({ promise, context, instance }) => { - return promise - .then(_ => getDataAndErrorsFromTree(instance.render(), context, storeError)) - .catch(e => storeError(e)); - }); - - return Promise.all(mappedPromises); -} - -function processErrors(errors: any[]) { - switch (errors.length) { - case 0: - break; - case 1: - throw errors.pop(); - default: - const wrapperError: any = new Error( - `${errors.length} errors were thrown when executing your fetchData functions.`, - ); - wrapperError.queryErrors = errors; - throw wrapperError; - } -} - -export default function getDataFromTree( - rootElement: React.ReactNode, - rootContext: any = {}, -): Promise { - const errors: any[] = []; - const storeError = (error: any) => errors.push(error); - - return getDataAndErrorsFromTree(rootElement, rootContext, storeError).then(_ => - processErrors(errors), - ); -} +import React from 'react'; + +export interface Context { + [key: string]: any; +} + +interface PromiseTreeArgument { + rootElement: React.ReactNode; + rootContext: Context; + rootNewContext: Map; +} +interface FetchComponent extends React.Component { + fetchData(): Promise; +} + +interface PromiseTreeResult { + promise: Promise; + context: Context; + instance: FetchComponent; + newContext: Map; +} + +interface PreactElement

{ + attributes: P; +} + +function getProps

(element: React.ReactElement

| PreactElement

): P { + return (element as React.ReactElement

).props || (element as PreactElement

).attributes; +} + +function isReactElement(element: React.ReactNode): element is React.ReactElement { + return !!(element as any).type; +} + +function isComponentClass(Comp: React.ComponentType): Comp is React.ComponentClass { + return Comp.prototype && (Comp.prototype.render || Comp.prototype.isReactComponent); +} + +function providesChildContext( + instance: React.Component, +): instance is React.Component & React.ChildContextProvider { + return !!(instance as any).getChildContext; +} + +// Recurse a 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: React.ReactNode, + context: Context, + visitor: ( + element: React.ReactNode, + instance: React.Component | null, + newContextMap: Map, + context: Context, + childContext?: Context, + ) => boolean | void, + newContext: Map = new Map(), +) { + if (Array.isArray(element)) { + element.forEach(item => walkTree(item, context, visitor, newContext)); + return; + } + + if (!element) { + return; + } + + // A stateless functional component or a class + if (isReactElement(element)) { + if (typeof element.type === 'function') { + const Comp = element.type; + const props = Object.assign({}, Comp.defaultProps, getProps(element)); + let childContext = context; + let child; + + // Are we are a react class? + if (isComponentClass(Comp)) { + const instance = new Comp(props, context); + // In case the user doesn't pass these to super in the constructor. + // Note: `Component.props` are now readonly in `@types/react`, so + // we're using `defineProperty` as a workaround (for now). + Object.defineProperty(instance, 'props', { + value: instance.props || props, + }); + instance.context = instance.context || context; + + // Set the instance state to null (not undefined) if not set, to match React behaviour + instance.state = instance.state || null; + + // Override setState to just change the state, not queue up an update + // (we can't do the default React thing as we aren't mounted + // "properly", however we don't need to re-render as we only support + // setState in componentWillMount, which happens *before* render). + instance.setState = newState => { + if (typeof newState === 'function') { + // React's TS type definitions don't contain context as a third parameter for + // setState's updater function. + // Remove this cast to `any` when that is fixed. + newState = (newState as any)(instance.state, instance.props, instance.context); + } + instance.state = Object.assign({}, instance.state, newState); + }; + + if (Comp.getDerivedStateFromProps) { + const result = Comp.getDerivedStateFromProps(instance.props, instance.state); + if (result !== null) { + instance.state = Object.assign({}, instance.state, result); + } + } else if (instance.UNSAFE_componentWillMount) { + instance.UNSAFE_componentWillMount(); + } else if (instance.componentWillMount) { + instance.componentWillMount(); + } + + if (providesChildContext(instance)) { + childContext = Object.assign({}, context, instance.getChildContext()); + } + + if (visitor(element, instance, newContext, context, childContext) === false) { + return; + } + + child = instance.render(); + } else { + // Just a stateless functional + if (visitor(element, null, newContext, context) === false) { + return; + } + + child = Comp(props, context); + } + + if (child) { + if (Array.isArray(child)) { + child.forEach(item => walkTree(item, childContext, visitor, newContext)); + } else { + walkTree(child, childContext, visitor, newContext); + } + } + } else if ((element.type as any)._context || (element.type as any).Consumer) { + // A React context provider or consumer + if (visitor(element, null, newContext, context) === false) { + return; + } + + let child; + if (!!(element.type as any)._context) { + // A provider - sets the context value before rendering children + // this needs to clone the map because this value should only apply to children of the provider + newContext = new Map(newContext); + newContext.set(element.type, element.props.value); + child = element.props.children; + } else { + // A consumer + let value = (element.type as any)._currentValue; + if (newContext.has((element.type as any).Provider)) { + value = newContext.get((element.type as any).Provider); + } + child = element.props.children(value); + } + + if (child) { + if (Array.isArray(child)) { + child.forEach(item => walkTree(item, context, visitor, newContext)); + } else { + walkTree(child, context, visitor, newContext); + } + } + } else { + // A basic string or dom element, just get children + if (visitor(element, null, newContext, context) === false) { + return; + } + + if (element.props && element.props.children) { + React.Children.forEach(element.props.children, (child: any) => { + if (child) { + walkTree(child, context, visitor, newContext); + } + }); + } + } + } else if (typeof element === 'string' || typeof element === 'number') { + // Just visit these, they are leaves so we don't keep traversing. + visitor(element, null, newContext, context); + } + // TODO: Portals? +} + +function hasFetchDataFunction(instance: React.Component): instance is FetchComponent { + return typeof (instance as any).fetchData === 'function'; +} + +function isPromise(promise: Object): promise is Promise { + return typeof (promise as any).then === 'function'; +} + +function getPromisesFromTree({ + rootElement, + rootContext, + rootNewContext, +}: PromiseTreeArgument): PromiseTreeResult[] { + const promises: PromiseTreeResult[] = []; + + walkTree( + rootElement, + rootContext, + (_, instance, newContext, context, childContext) => { + if (instance && hasFetchDataFunction(instance)) { + const promise = instance.fetchData(); + if (isPromise(promise)) { + promises.push({ + promise, + context: childContext || context, + instance, + newContext, + }); + return false; + } + } + }, + rootNewContext, + ); + + return promises; +} + +function getDataAndErrorsFromTree( + rootElement: React.ReactNode, + rootContext: Object, + storeError: Function, + rootNewContext: Map = new Map(), +): Promise { + const promises = getPromisesFromTree({ rootElement, rootContext, rootNewContext }); + + if (!promises.length) { + return Promise.resolve(); + } + + const mappedPromises = promises.map(({ promise, context, instance, newContext }) => { + return promise + .then(_ => getDataAndErrorsFromTree(instance.render(), context, storeError, newContext)) + .catch(e => storeError(e)); + }); + + return Promise.all(mappedPromises); +} + +function processErrors(errors: any[]) { + switch (errors.length) { + case 0: + break; + case 1: + throw errors.pop(); + default: + const wrapperError: any = new Error( + `${errors.length} errors were thrown when executing your fetchData functions.`, + ); + wrapperError.queryErrors = errors; + throw wrapperError; + } +} + +export default function getDataFromTree( + rootElement: React.ReactNode, + rootContext: any = {}, +): Promise { + const errors: any[] = []; + const storeError = (error: any) => errors.push(error); + + return getDataAndErrorsFromTree(rootElement, rootContext, storeError).then(_ => + processErrors(errors), + ); +} diff --git a/test/server/server.test.tsx b/test/server/server.test.tsx index 9b2c93cb1f..8b73f9a478 100644 --- a/test/server/server.test.tsx +++ b/test/server/server.test.tsx @@ -11,97 +11,97 @@ import { GraphQLID, DocumentNode, } from 'graphql'; -import { graphql, ApolloProvider, renderToStringWithData, ChildProps } from '../../src'; +import { graphql, ApolloProvider, renderToStringWithData, ChildProps, Query } from '../../src'; import gql from 'graphql-tag'; import { InMemoryCache as Cache } from 'apollo-cache-inmemory'; -describe('SSR', () => { - describe('`renderToStringWithData`', () => { - // XXX break into smaller tests - // XXX mock all queries - it('should work on a non trivial example', function() { - const planetMap = new Map([['Planet:1', { id: 'Planet:1', name: 'Tatooine' }]]); +const planetMap = new Map([['Planet:1', { id: 'Planet:1', name: 'Tatooine' }]]); - const shipMap = new Map([ - [ - 'Ship:2', - { - id: 'Ship:2', - name: 'CR90 corvette', - films: ['Film:4', 'Film:6', 'Film:3'], - }, - ], - [ - 'Ship:3', - { - id: 'Ship:3', - name: 'Star Destroyer', - films: ['Film:4', 'Film:5', 'Film:6'], - }, - ], - ]); +const shipMap = new Map([ + [ + 'Ship:2', + { + id: 'Ship:2', + name: 'CR90 corvette', + films: ['Film:4', 'Film:6', 'Film:3'], + }, + ], + [ + 'Ship:3', + { + id: 'Ship:3', + name: 'Star Destroyer', + films: ['Film:4', 'Film:5', 'Film:6'], + }, + ], +]); - const filmMap = new Map([ - ['Film:3', { id: 'Film:3', title: 'Revenge of the Sith' }], - ['Film:4', { id: 'Film:4', title: 'A New Hope' }], - ['Film:5', { id: 'Film:5', title: 'the Empire Strikes Back' }], - ['Film:6', { id: 'Film:6', title: 'Return of the Jedi' }], - ]); +const filmMap = new Map([ + ['Film:3', { id: 'Film:3', title: 'Revenge of the Sith' }], + ['Film:4', { id: 'Film:4', title: 'A New Hope' }], + ['Film:5', { id: 'Film:5', title: 'the Empire Strikes Back' }], + ['Film:6', { id: 'Film:6', title: 'Return of the Jedi' }], +]); - const PlanetType = new GraphQLObjectType({ - name: 'Planet', - fields: { - id: { type: GraphQLID }, - name: { type: GraphQLString }, - }, - }); +const PlanetType = new GraphQLObjectType({ + name: 'Planet', + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + }, +}); - const FilmType = new GraphQLObjectType({ - name: 'Film', - fields: { - id: { type: GraphQLID }, - title: { type: GraphQLString }, - }, - }); +const FilmType = new GraphQLObjectType({ + name: 'Film', + fields: { + id: { type: GraphQLID }, + title: { type: GraphQLString }, + }, +}); - const ShipType = new GraphQLObjectType({ - name: 'Ship', - fields: { - id: { type: GraphQLID }, - name: { type: GraphQLString }, - films: { - type: new GraphQLList(FilmType), - resolve: ({ films }) => films.map((id: string) => filmMap.get(id)), - }, - }, - }); +const ShipType = new GraphQLObjectType({ + name: 'Ship', + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + films: { + type: new GraphQLList(FilmType), + resolve: ({ films }) => films.map((id: string) => filmMap.get(id)), + }, + }, +}); - const QueryType = new GraphQLObjectType({ - name: 'Query', - fields: { - allPlanets: { - type: new GraphQLList(PlanetType), - resolve: () => Array.from(planetMap.values()), - }, - allShips: { - type: new GraphQLList(ShipType), - resolve: () => Array.from(shipMap.values()), - }, - ship: { - type: ShipType, - args: { id: { type: GraphQLID } }, - resolve: (_, { id }) => shipMap.get(id), - }, - film: { - type: FilmType, - args: { id: { type: GraphQLID } }, - resolve: (_, { id }) => filmMap.get(id), - }, - }, - }); +const QueryType = new GraphQLObjectType({ + name: 'Query', + fields: { + allPlanets: { + type: new GraphQLList(PlanetType), + resolve: () => Array.from(planetMap.values()), + }, + allShips: { + type: new GraphQLList(ShipType), + resolve: () => Array.from(shipMap.values()), + }, + ship: { + type: ShipType, + args: { id: { type: GraphQLID } }, + resolve: (_, { id }) => shipMap.get(id), + }, + film: { + type: FilmType, + args: { id: { type: GraphQLID } }, + resolve: (_, { id }) => filmMap.get(id), + }, + }, +}); - const Schema = new GraphQLSchema({ query: QueryType }); +const Schema = new GraphQLSchema({ query: QueryType }); +describe('SSR', () => { + describe('`renderToStringWithData`', () => { + // XXX break into smaller tests + // XXX mock all queries + it('should work on a non trivial example', function() { const apolloClient = new ApolloClient({ link: new ApolloLink(config => { return new Observable(observer => { @@ -263,4 +263,105 @@ describe('SSR', () => { }); }); }); + + it('should work with React.createContext', async () => { + // Preact doesn't support createContext so this test won't run in Preact + if (React.createContext) { + let defaultValue = 'default'; + let Context = React.createContext(defaultValue); + let providerValue = 'provider'; + expect( + await renderToStringWithData( + + + + {val => { + expect(val).toBe(defaultValue); + return val; + }} + + , + ), + ).toBe(defaultValue); + expect( + await renderToStringWithData( + + + {val => { + expect(val).toBe(providerValue); + return val; + }} + + , + ), + ).toBe(providerValue); + expect( + await renderToStringWithData( + + {val => { + expect(val).toBe(defaultValue); + return val; + }} + , + ), + ).toBe(defaultValue); + let ContextForUndefined = React.createContext(defaultValue); + + expect( + await renderToStringWithData( + + + {val => { + expect(val).toBeUndefined(); + return val === undefined ? 'works' : 'broken'; + }} + + , + ), + ).toBe('works'); + + const apolloClient = new ApolloClient({ + link: new ApolloLink(config => { + return new Observable(observer => { + execute(Schema, print(config.query), null, null, config.variables, config.operationName) + .then(result => { + observer.next(result); + observer.complete(); + }) + .catch(e => { + observer.error(e); + }); + }); + }), + cache: new Cache(), + }); + + expect( + await renderToStringWithData( + + + + {() => ( + + {val => { + expect(val).toBe(providerValue); + return val; + }} + + )} + + + , + ), + ).toBe(providerValue); + } + }); });