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 (
+
+ );
+ }
+ }
+
+ 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,