From 284711594c09d985dedcb11fb17d908ebb059976 Mon Sep 17 00:00:00 2001 From: James Baxley Date: Mon, 11 Jul 2016 22:49:42 -0400 Subject: [PATCH] Integrated SSR (#83) create initial system for SSR and server side hydration --- Changelog.md | 50 +++-- global.d.ts | 6 + package.json | 7 +- src/connect.tsx | 8 +- src/server.ts | 195 ++++++++++++++++++ test/client/connect/queries.tsx | 6 +- test/server/index.tsx | 354 +++++++++++++++++++++++++++++++- tslint.json | 5 +- 8 files changed, 592 insertions(+), 39 deletions(-) create mode 100644 src/server.ts diff --git a/Changelog.md b/Changelog.md index 7f9238b143..880bb31ac6 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,42 +2,48 @@ 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. +### v0.3.16 + +- Feature: integrated SSR [#83](https://github.com/apollostack/react-apollo/pull/83) +- Feature: added ability to hoist statics on components [#99](https://github.com/apollostack/react-apollo/pull/99) +- Bug: Don't strip data away from the component when the query errors [#98](https://github.com/apollostack/react-apollo/pull/98) + ### v0.3.15 -Bug: Fixed issue where react native would error on aggressive cloneing of client +- Bug: Fixed issue where react native would error on aggressive cloneing of client ### v0.3.14 -Feature: pass through all methods on apollo client +- Feature: pass through all methods on apollo client ### v0.3.13 -Bug: fixed issue causing errors to be passed to apollo-client [#89](https://github.com/apollostack/react-apollo/pull/89) +- Bug: fixed issue causing errors to be passed to apollo-client [#89](https://github.com/apollostack/react-apollo/pull/89) ### v0.3.11/12 -Bug: fixed overrendering of components on redux state changes +- Bug: fixed overrendering of components on redux state changes ### v0.3.10 -Bug: fixed bug where SSR would fail due to later updates. This should also prevent unmounted components from throwing errors. +- Bug: fixed bug where SSR would fail due to later updates. This should also prevent unmounted components from throwing errors. ### v0.3.9 -Feature: provide add `watchQuery` to components via `connect` +- Feature: provide add `watchQuery` to components via `connect` ### v.0.3.8 -Bug: Don't use old props on store change change +- Bug: Don't use old props on store change change ### v.0.3.7 -Bug: Reset loading state when a refetched query has returned +- Bug: Reset loading state when a refetched query has returned ### v0.3.6 -Bug: Loading state is no longer true on uncalled mutations. -Improvement: don't set the loading state to false if forceFetch is true +- Bug: Loading state is no longer true on uncalled mutations. +- Improvement: don't set the loading state to false if forceFetch is true ### v0.3.5 @@ -45,32 +51,32 @@ Return promise from the refetch method ### v0.3.4 -Bug: Fix bug where state / props weren't accurate when executing mutations. -Perf: Increase performance by limiting re-renders and re-execution of queries. +- Bug: Fix bug where state / props weren't accurate when executing mutations. +- - Improvement: Increase performance by limiting re-renders and re-execution of queries. Chore: Split tests to make them easier to maintain. ### v0.3.2 || v0.3.3 (publish fix) -Feature: add `startPolling` and `stopPolling` to the prop object for queries -Bug: Fix bug where full options were not being passed to watchQuery +- Feature: add `startPolling` and `stopPolling` to the prop object for queries +- Bug: Fix bug where full options were not being passed to watchQuery ### v0.3.1 -Support 0.3.0 of apollo-client +- Feature: Support 0.3.0 of apollo-client ### v0.3.0 -Change Provider export to be ApolloProvider and use Provider from react-redux +- Feature: Change Provider export to be ApolloProvider and use Provider from react-redux ### v0.2.1 -Support 0.1.0 and 0.2.0 of apollo-client +- Feature: Support 0.1.0 and 0.2.0 of apollo-client ### v0.2.0 **Breaking change:** -Remove `result` key in favor of dynamic key matching root fields of the query or mutation. (https://github.com/apollostack/react-apollo/pull/31) +- Feature: Remove `result` key in favor of dynamic key matching root fields of the query or mutation. (https://github.com/apollostack/react-apollo/pull/31) ```js { @@ -92,19 +98,19 @@ becomes ### v0.1.5 -Get state directly from redux store internally +- Bug: Get state directly from redux store internally ### v0.1.4 -Fix bug with willReceiveProps +- Bug: Fix bug with willReceiveProps ### v0.1.2 -Adjust loading lifecycle marker to better match the behavior of apollo-client (https://github.com/apollostack/react-apollo/pull/11) +Bug: - Adjust loading lifecycle marker to better match the behavior of apollo-client [#11](https://github.com/apollostack/react-apollo/pull/11) ### v0.1.1 -Update to support new observable API from apollo-client (https://github.com/apollostack/react-apollo/pull/9) +Feature: - Update to support new observable API from apollo-client [#9](https://github.com/apollostack/react-apollo/pull/9) ### v0.1.0 diff --git a/global.d.ts b/global.d.ts index cf13b0ba3b..90ee6649b5 100644 --- a/global.d.ts +++ b/global.d.ts @@ -11,6 +11,7 @@ declare module 'lodash.isequal' { export = main.isEqual; } + declare module 'hoist-non-react-statics' { interface Component { new(...args:any[]); @@ -26,3 +27,8 @@ declare module 'hoist-non-react-statics' { namespace hoistNonReactStatics {} export = hoistNonReactStatics; } + +declare module 'lodash.flatten' { + import main = require('~lodash/index'); + export = main.flatten; +} diff --git a/package.json b/package.json index 45dad464c2..71356fe940 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,18 @@ { "name": "react-apollo", - "version": "0.3.15", + "version": "0.3.16", "description": "React data container for Apollo Client", "main": "index.js", "scripts": { "pretest": "npm run compile", "test": "mocha --require ./test/fixtures/setup.js --reporter spec --full-trace --recursive ./lib/test", "posttest": "npm run lint", - "filesize": "npm run compile:browser && ./scripts/filesize.js --file=./dist/index.min.js --maxGzip=13", + "filesize": "npm run compile:browser && ./scripts/filesize.js --file=./dist/index.min.js --maxGzip=15", "compile": "tsc", "compile:browser": "rm -rf ./dist && mkdir ./dist && browserify ./lib/src/index.js --i react --i apollo-client -o=./dist/index.js && npm run minify:browser", "minify:browser": "uglifyjs --compress --mangle --screw-ie8 -o=./dist/index.min.js -- ./dist/index.js", "watch": "tsc -w", - "lint": "tslint src/*.ts* && tslint test/*.ts*", + "lint": "tslint 'src/*.ts*' && tslint 'test/*.ts*'", "coverage": "istanbul cover ./node_modules/mocha/bin/_mocha -- --require ./test/fixtures/setup.js --reporter spec --full-trace --recursive ./lib/test", "postcoverage": "remap-istanbul --input coverage/coverage.json --type lcovonly --output coverage/lcov.info" }, @@ -70,6 +70,7 @@ "dependencies": { "hoist-non-react-statics": "^1.2.0", "invariant": "^2.2.1", + "lodash.flatten": "^4.2.0", "lodash.isequal": "^4.1.1", "lodash.isobject": "^3.0.2", "object-assign": "^4.0.1", diff --git a/src/connect.tsx b/src/connect.tsx index 1de44b5a44..622ed3f337 100644 --- a/src/connect.tsx +++ b/src/connect.tsx @@ -73,6 +73,11 @@ export default function connect(opts?: ConnectOptions) { let { mapQueriesToProps, mapMutationsToProps } = opts; + let mapQueries; + if (mapQueriesToProps) { + mapQueries = true; + } + // clean up the options for passing to redux delete opts.mapQueriesToProps; delete opts.mapMutationsToProps; @@ -103,7 +108,6 @@ export default function connect(opts?: ConnectOptions) { // Helps track hot reloading. const version = nextVersion++; - return function wrapWithApolloComponent(WrappedComponent) { // react-redux will wrap this further with Connect(...). const apolloConnectDisplayName = `Apollo(${getDisplayName(WrappedComponent)})`; @@ -115,6 +119,8 @@ export default function connect(opts?: ConnectOptions) { store: PropTypes.object.isRequired, client: PropTypes.object.isRequired, }; + // for use with getData during SSR + static mapQueriesToProps = mapQueries ? mapQueriesToProps : false; // react / redux and react dev tools (HMR) needs public state: any; // redux state diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000000..0f0ffa4b94 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,195 @@ + +import { Children, createElement } from 'react'; +import * as ReactDOM from 'react-dom/server'; +import ApolloClient from 'apollo-client'; +import flatten = require('lodash.flatten'); +import assign = require('object-assign'); + +/* + +React components can return a `falsy` (null, false) value, +representation of a native DOM component (such as
or React.DOM.div()) +or another composite component. Components can have a render function (for components). +They can also pass through children which we want to analyze as well. + +To get data from `connect()` components we do a few things: + +1. if ssr is not falsy, move the query to a place to batch call it + +Ideally, we go through the tree and find all `connect()`s (recursively going through tree) +If we reach the end of all nodes, we kick off the queries. Once queries have returned, +we try to go through their children components again to see if we discover any +more queries. Then once we reach th end, we render the dom. + +We recursively do this until the tree is done. + +So! Given a component: + +1. See if it is falsy (end of line) +2. Bulid the context and props (global props + parent props) +3. See if the component is a `connect()` +3a. Get the queries using props + state +3b. as long as ssr != false, pass the query to the array to be called +4. Create the component (or child if connect) (`componentWillMount` will run) +5. Render the component +6. Repeat + +*/ + +declare interface Context { + client?: ApolloClient; + store?: any; + [key: string]: any; +} + +declare interface QueryTreeArgument { + component: any; + queries?: any[]; + context?: Context; +} + +export function getPropsFromChild(child) { + const { props, type } = child; + let ownProps = assign({}, props); + if (type && type.defaultProps) ownProps = assign(type.defaultProps, props); + return ownProps; +} + +export function getChildFromComponent(component) { + // See if this is a class, or stateless function + if (component && component.render) return component.render(); + return component; +} + +export function processQueries(queries, client): Promise { + queries = flatten(queries) + .map((queryDetails: any) => { + const { query, component, ownProps, key, context } = queryDetails; + return client.query(query) + .then(result => { + const { data, errors } = result as any; + ownProps[key] = assign({ loading: false, errors }, data); + return { component, ownProps: assign({}, ownProps), context: assign({}, context) }; + }); + }); + + return Promise.all(queries); +} + +const defaultReactProps = { loading: true, errors: null }; +function getQueriesFromTree({ component, context = {}, queries = []}: QueryTreeArgument) { + + if (!component) return; + let { client, store } = context; + + // stateless function + if (typeof component === 'function') component = { type: component }; + const { type, props } = component; + + if (typeof type === 'function') { + let ComponentClass = type; + let ownProps = getPropsFromChild(component); + const { state } = context; + + // see if this is a connect type + if (typeof type.mapQueriesToProps === 'function') { + const data = type.mapQueriesToProps({ ownProps, state }); + for (let key in data) { + if (!data.hasOwnProperty(key)) continue; + + ownProps[key] = assign({}, defaultReactProps); + if (data[key].ssr === false) continue; // don't run this on the server + + queries.push({ + query: data[key], + component: type.WrappedComponent, + key, + ownProps, + context, + }); + } + + ComponentClass = type.WrappedComponent; + } + + const Component = new ComponentClass(ownProps, context); + + let newContext = context; + if (Component.getChildContext) newContext = assign({}, context, Component.getChildContext()); + + if (!store && ownProps.store) store = ownProps.store; + if (!store && newContext.store) store = newContext.store; + + if (!client && ownProps.client && ownProps.client instanceof ApolloClient) { + client = ownProps.client as ApolloClient; + } + if (!client && newContext.client && newContext.client instanceof ApolloClient) { + client = newContext.client as ApolloClient; + } + + getQueriesFromTree({ + component: getChildFromComponent(Component), + context: newContext, + queries, + }); + } else if (props && props.children) { + Children.forEach(props.children, (child: any) => getQueriesFromTree({ + component: child, + context, + queries, + })); + } + + return { queries, client, store }; +} + +// XXX component Cache +export function getDataFromTree(app, ctx: any = {}): Promise { + + let { client, store, queries } = getQueriesFromTree({ component: app, context: ctx }); + + if (!store && client && !client.store) client.initStore(); + if (!store && client && client.store) store = client.store; + // no client found, nothing to do + if (!client || !store) return Promise.resolve(null); + + // no queries found, nothing to do + if (!queries.length) return Promise.resolve({ store, client, initialState: store.getState() }); + + // run through all queries we can + return processQueries(queries, client) + .then(trees => Promise.all(trees.map(x => { + const { component, ownProps, context } = x; + if (!component) return; + // Traverse wrapped components of resulting queries + // NOTE: sub component queries may fire again, + // but they will just return back existing data + const Element = createElement(component, ownProps) as any; + const child = getChildFromComponent(Element && new Element.type(ownProps, context)); + if (!child) return; + + // traverse children nodes + return getDataFromTree(child, context); + }))) + .then(() => ({ store, client, initialState: store.getState() })); + +} + +export function renderToStringWithData(component) { + return getDataFromTree(component) + .then(({ store, client }) => { + let markup = ReactDOM.renderToString(component); + let initialState = store.getState(); + const key = client.reduxRootKey; + // XXX apollo client requires a lot in the store + // can we make this samller? + for (let queryId in initialState[key].queries) { + let fieldsToNotShip = ['minimizedQuery', 'minimizedQueryString']; + for (let field of fieldsToNotShip) delete initialState[key].queries[queryId][field]; + } + initialState = encodeURI(JSON.stringify(initialState)); + const payload = ``; + markup += payload; + return markup; + }); +} diff --git a/test/client/connect/queries.tsx b/test/client/connect/queries.tsx index 13473e7cbb..be1a9a4f24 100644 --- a/test/client/connect/queries.tsx +++ b/test/client/connect/queries.tsx @@ -233,7 +233,7 @@ describe('queries', () => { } } - let hasFinished; + let finished; @connect({ mapStateToProps, mapQueriesToProps }) class Container extends React.Component { @@ -242,8 +242,8 @@ describe('queries', () => { } componentWillReceiveProps(nextProps) { - if (!nextProps.people.loading && !hasFinished) { - hasFinished = true; + if (!nextProps.people.loading && !finished) { + finished = true; expect(nextProps.ctnr).to.equal(2); done(); } diff --git a/test/server/index.tsx b/test/server/index.tsx index 569d887ad0..bcc98c3a37 100644 --- a/test/server/index.tsx +++ b/test/server/index.tsx @@ -3,25 +3,27 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom/server'; import ApolloClient, { createNetworkInterface } from 'apollo-client'; import { connect, ApolloProvider } from '../../src'; +import { getDataFromTree, renderToStringWithData } from '../../src/server'; import 'isomorphic-fetch'; -// Globally register gql template literal tag import gql from 'graphql-tag'; +import mockNetworkInterface from '../mocks/mockNetworkInterface'; + const { expect } = chai; const client = new ApolloClient({ - networkInterface: createNetworkInterface('https://www.graphqlhub.com/playground') + networkInterface: createNetworkInterface('https://www.graphqlhub.com/playground'), }); describe('SSR', () => { it('should render the expected markup', (done) => { const Element = ({ data }) => { return
{data.loading ? 'loading' : 'loaded'}
; - } + }; const WrappedElement = connect({ - mapQueriesToProps: ({ ownProps }) => ({ + mapQueriesToProps: () => ({ data: { query: gql` query Feed { @@ -29,9 +31,9 @@ describe('SSR', () => { login } } - ` - } - }) + `, + }, + }), })(Element); const component = ( @@ -52,4 +54,342 @@ describe('SSR', () => { done(e); } }); + + describe('`getDataFromTree`', () => { + it('should run through all of the queries that want SSR', (done) => { + const Element = ({ data }) => { + return
{data.loading ? 'loading' : data.currentUser.firstName}
; + }; + + const query = gql` + query App { + currentUser { + firstName + } + } + `; + + const data = { + currentUser: { + firstName: 'James', + }, + }; + + const networkInterface = mockNetworkInterface( + { + request: { query }, + result: { data }, + delay: 50, + } + ); + + const apolloClient = new ApolloClient({ + networkInterface, + }); + + const WrappedElement = connect({ + mapQueriesToProps: () => ({ data: { query } }), + })(Element); + + const app = ( + + + + ); + + getDataFromTree(app) + .then(() => { + const markup = ReactDOM.renderToString(app); + expect(markup).to.match(/James/); + done(); + }) + .catch(console.error) + ; + }); + + it('should run return the initial state for hydration', (done) => { + const Element = ({ data }) => { + return
{data.loading ? 'loading' : data.currentUser.firstName}
; + }; + + const query = gql` + query App { + currentUser { + firstName + } + } + `; + + const data = { + currentUser: { + firstName: 'James', + }, + }; + + const networkInterface = mockNetworkInterface( + { + request: { query }, + result: { data }, + delay: 50, + } + ); + + const apolloClient = new ApolloClient({ + networkInterface, + }); + + const WrappedElement = connect({ + mapQueriesToProps: () => ({ + data: { query }, + }), + })(Element); + + const app = ( + + + + ); + + getDataFromTree(app) + .then(({ initialState }) => { + expect(initialState.apollo.data).to.exist; + expect(initialState.apollo.data['ROOT_QUERY.currentUser']).to.exist; + done(); + }); + }); + it('shouldn\'t run queries if ssr is turned to off', (done) => { + const Element = ({ data }) => { + return
{data.loading ? 'loading' : data.currentUser.firstName}
; + }; + + const query = gql` + query App { + currentUser { + firstName + } + } + `; + + const data = { + currentUser: { + firstName: 'James', + }, + }; + + const networkInterface = mockNetworkInterface( + { + request: { query }, + result: { data }, + delay: 50, + } + ); + + const apolloClient = new ApolloClient({ + networkInterface, + }); + + const WrappedElement = connect({ + mapQueriesToProps: () => ({ + data: { query, ssr: false }, + }), + })(Element); + + const app = ( + + + + ); + + getDataFromTree(app) + .then(({ initialState }) => { + expect(initialState.apollo.data).to.exist; + expect(initialState.apollo.data['ROOT_QUERY.currentUser']).to.not.exist; + done(); + }); + }); + }); + describe('`renderToStringWithData`', () => { + + // XXX break into smaller tests + // XXX mock all queries + it('should work on a non trivial example', function(done) { + this.timeout(10000); + const networkInterface = createNetworkInterface('http://graphql-swapi.parseapp.com/'); + const apolloClient = new ApolloClient({ + networkInterface, + // shouldBatch: true, + }); + + class Film extends React.Component { + render() { + const { data } = this.props; + if (data.loading) return null; + const { film } = data; + return
{film.title}
; + } + }; + + const FilmWithData = connect({ + mapQueriesToProps: ({ ownProps }) => ({ + data: { + query: gql` + query GetFilm($id: ID!) { + film: node(id: $id) { + ... on Film { + title + } + } + } + `, + variables: { id: ownProps.id }, + }, + }), + })(Film); + + class Starship extends React.Component { + render() { + const { data } = this.props; + if (data.loading) return null; + const { ship } = data; + return ( +
+

{ship.name} appeared in the following flims:

+
+
    + {ship.filmConnection.films.map((film, key) => ( +
  • + +
  • + ))} +
+
+ ); + } + }; + + const StarshipWithData = connect({ + mapQueriesToProps: ({ ownProps }) => ({ + data: { + query: gql` + query GetShip($id: ID!) { + ship: node(id: $id) { + ... on Starship { + name + filmConnection { + films { + id + } + } + } + } + } + `, + variables: { id: ownProps.id }, + }, + }), + })(Starship); + + class Element extends React.Component { + render() { + const { data } = this.props; + return ( +
    + {!data.loading && data.allStarships && data.allStarships.starships.map((ship, key) => ( +
  • + +
  • + ))} +
+ ); + } + } + + const AllShipsWithData = connect({ + mapQueriesToProps: () => ({ + data: { + query: gql` + query GetShips { + allStarships(first: 2) { + starships { + id + } + } + } + `, + }, + }), + })(Element); + + class Planet extends React.Component { + render() { + const { data } = this.props; + if (data.loading) return null; + const { planets } = data.allPlanets; + return ( +
+

Planets

+ {planets.map((planet, key) => ( +
{planet.name}
+ ))} +
+ ); + } + } + const AllPlanetsWithData = connect({ + mapQueriesToProps: () => ({ + data: { + query: gql` + query GetPlanets { + allPlanets(first: 1) { + planets{ + name + } + } + } + `, + }, + }), + })(Planet); + + const Foo = () => ( +
+

Foo

+ +
+ ); + + class Bar extends React.Component { + render() { + return ( +
+

Bar

+ +
+ ); + } + } + + const app = ( + +
+ +
+ +
+
+ ); + + renderToStringWithData(app) + .then(markup => { + expect(markup).to.match(/CR90 corvette/); + expect(markup).to.match(/Return of the Jedi/); + expect(markup).to.match(/Return of the Jedi/); + expect(markup).to.match(/Planets/); + expect(markup).to.match(/Tatooine/); + expect(markup).to.match(/__APOLLO_STATE__/); + done(); + }) + .catch(done); + }); + }); }); diff --git a/tslint.json b/tslint.json index f125713739..0f00049367 100644 --- a/tslint.json +++ b/tslint.json @@ -8,14 +8,13 @@ ], "ban": false, "class-name": true, - "curly": true, + "curly": false, "eofline": true, - "forin": true, + "forin": false, "indent": [ true, "spaces" ], - "interface-name": false, "jsdoc-format": true, "label-position": true, "label-undefined": true,