Skip to content
This repository has been archived by the owner on Apr 13, 2023. It is now read-only.

Refactor ssr #313

Merged
merged 3 commits into from
Nov 13, 2016
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@
"jest": {
"preset": "jest-react-native",
"testEnvironment": "jsdom",
"scriptPreprocessor": "preprocessor.js",
"transform": {
".*": "preprocessor.js"
},
"moduleFileExtensions": [
"ts",
"tsx",
Expand Down Expand Up @@ -87,16 +89,17 @@
"jest": "^16.1.0-alpha.691b0e22",
"jest-react-native": "^16.1.0-alpha.691b0e22",
"jsdom": "^8.3.1",
"lodash": "^4.16.6",
"minimist": "^1.2.0",
"mobx": "^2.4.2",
"mobx-react": "^3.5.4",
"pretty-bytes": "^3.0.1",
"react": "15.4.0-rc.4",
"react-addons-test-utils": "15.4.0-rc.4",
"react-dom": "15.4.0-rc.4",
"react": "15.3.2",
"react-addons-test-utils": "15.3.2",
"react-dom": "15.3.2",
"react-native": "^0.36.0",
"react-redux": "^4.4.5",
"react-test-renderer": "15.4.0-rc.4",
"react-test-renderer": "15.3.2",
"redux": "^3.5.2",
"redux-form": "^6.0.5",
"redux-immutable": "^3.0.7",
Expand Down
181 changes: 100 additions & 81 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Children } from 'react';
import * as ReactDOM from 'react-dom/server';
import ApolloClient from 'apollo-client';
import assign = require('object-assign');
import flatten = require('lodash.flatten');


declare interface Context {
Expand All @@ -13,107 +12,127 @@ declare interface Context {
}

declare interface QueryTreeArgument {
component: any;
queries?: any[];
context?: Context;
rootElement: any;
rootContext?: Context;
}

export function getPropsFromChild(child) {
const { props, type } = child;
let ownProps = assign({}, props);
if (type && type.defaultProps) ownProps = assign({}, type.defaultProps, props);
return ownProps;
declare interface QueryResult {
query: Promise<any>;
element: any;
context: any;
}

export function getChildFromComponent(component) {
// See if this is a class, or stateless function
if (component && component.render) return component.render();
return component;
}

let contextStore = {};
function getQueriesFromTree(
{ component, context = {}, queries = []}: QueryTreeArgument, fetch: boolean = true
// 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 it's child elements
export function walkTree(
element: any,
context: any,
visitor: (element: any, context: any) => boolean | void
) {
contextStore = assign({}, contextStore, context);
if (!component) return;

// 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 Component = new ComponentClass(ownProps, context);
try {
Component.props = ownProps;
Component.context = context;
Component.setState = (newState: any) => {
Component.state = assign({}, Component.state, newState);
// console.log(element)
const shouldContinue = visitor(element, context);

if (shouldContinue === false) {
return;
}

const Component = element.type;
// a stateless functional component or a class
if (typeof Component === 'function') {
const props = assign({}, Component.defaultProps, element.props);
let childContext = context;
let child;

// 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);

// 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 well only support setState in
// componentWillMount, which happens *before* render).
instance.setState = (newState) => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asking out of curiosity, why is it necessary to call setState? I don't really see any relevant usages of setState within the react-apollo codebase

Feel free not to answer if it's complicated 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't call set state, but it's totally possible that any component we are "rendering" will rely on calling in it componentWillMount. I think there were some issues about this (and that's why there's a test for it).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhh, I see. That makes sense, I believe.

I imagine another option might be to run Component.flushState() or whatever it's called, just before Component.componentWillMount() ? But I don't necessarily see that as more elegant.

Again, thanks for taking the time to explain!

instance.state = assign({}, instance.state, newState);
};
} catch (e) {} // tslint:disable-line
if (Component.componentWillMount) Component.componentWillMount();

let newContext = context;
if (Component.getChildContext) newContext = assign({}, context, Component.getChildContext());
// this is a poor man's version of
// https://github.com/facebook/react/blob/master/src/renderers/shared/stack/reconciler/ReactCompositeComponent.js#L181
if (instance.componentWillMount) {
instance.componentWillMount();
}


if (instance.getChildContext) {
childContext = assign({}, context, instance.getChildContext());
}

// see if there is a fetch data method
if (typeof type.fetchData === 'function' && fetch) {
const query = type.fetchData(ownProps, newContext);
if (query) queries.push({ query, component });
child = instance.render();
} else { // just a stateless functional
child = Component(props, context);
}

getQueriesFromTree({
component: getChildFromComponent(Component),
context: newContext,
queries,
});
} else if (props && props.children) {
Children.forEach(props.children, (child: any) => getQueriesFromTree({
component: child,
context,
queries,
}));
walkTree(child, childContext, visitor);

} else { // a basic string or dom element, just get children
if (element.props && element.props.children) {
Children.forEach(element.props.children, (child: any) => {
walkTree(child, context, visitor);
});
}
}
}

function getQueriesFromTree(
{ rootElement, rootContext = {} }: QueryTreeArgument, fetchRoot: boolean = true
): QueryResult[] {
const queries = [];

walkTree(rootElement, rootContext, (element, context) => {
const Component = element.type || element;

const skipRoot = !fetchRoot && (element === rootElement);
if (typeof Component.fetchData === 'function' && !skipRoot) {
const props = assign({}, Component.defaultProps, element.props);
const query = Component.fetchData(props, context);
if (query) {
queries.push({ query, element, context });

// Tell walkTree to not recurse inside this component; we will
// wait for the query to execute before attempting it.
return false;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, asking out of curiosity, no need to respond (I just find this to be a really interesting project):

Won't this prevent queries on child components from executing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, but it will eventually get rendered when the query is ready. The assumption is that you have something like

@graphql(...)
const X = ({ loading }) => {
  if (loading) {
    // A. there's no nested calls to `graphql` inside this render tree
    return <Loading/>;
  } else {
    // B. there could be anything in here
  }
}

We don't bother rendering the A. subtree as we are going to await the query setup by graphql and then render X with the results (and loading: false). There are probably pathological cases you can think of where this is inefficient, but I think it seems a reasonable assumption and is "eventually consistent" in any case.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, is Component.fetchData recursive then? What if the B subtree has a @graphql(...) of its own?

Thanks very much for taking the time to explain!

Copy link
Contributor Author

@tmeasday tmeasday Nov 7, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getQueriesFromTree is recursive, so B's @graphqls will get picked up after X's query is loaded.

We have to wait for X's query though, as any sub-queries may rely on the results.

Thanks very much for taking the time to explain!

No problem! Happy to talk about it while I still remember ;)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhh, of course. Somehow I missed the fact that there are two separate recursions happening: one to find all top-level graphql nodes, and one to find/execute their child queries once the first have completed... although, of course, you had clearly stated that.

We have to wait for X's query though, as any sub-queries may rely on the results.

Of course. Presumably in the future some introspection could check whether this is true, and batch the queries together if not.

Hah, well I'm glad to get your thoughts on the public record while we can then! 😄

}
}
});

return { queries, context: contextStore };
return queries;
}

// XXX component Cache
export function getDataFromTree(app, ctx: any = {}, fetch: boolean = true): Promise<any> {
export function getDataFromTree(rootElement, rootContext: any = {}, fetchRoot: boolean = true): Promise<void> {

// reset for next loop
contextStore = {};
let { context, queries } = getQueriesFromTree({ component: app, context: ctx }, fetch);
// reset for next loop
contextStore = {};
let queries = getQueriesFromTree({ rootElement, rootContext }, fetchRoot);

// no queries found, nothing to do
if (!queries.length) return Promise.resolve(context);

const mappedQueries = flatten(queries).map(y => y.query.then(x => y));
// run through all queries we can
return Promise.all(mappedQueries)
.then(trees => Promise.all(trees.filter(x => !!x).map((x: any) => {
return getDataFromTree(x.component, context, false); // don't rerun `fetchData'
})))
.then(() => (context));

if (!queries.length) return Promise.resolve();

// wait on each query that we found, re-rendering the subtree when it's done
const mappedQueries = queries.map(({ query, element, context }) => {
// we've just grabbed the query for element, so don't try and get it again
return query.then(_ => getDataFromTree(element, context, false));
});
return Promise.all(mappedQueries).then(_ => null);
}

export function renderToStringWithData(component) {
return getDataFromTree(component)
.then(({ client }) => {
let markup = ReactDOM.renderToString(component);
let apolloState = client.queryManager.getApolloState();

for (let queryId in apolloState.queries) {
let fieldsToNotShip = ['minimizedQuery', 'minimizedQueryString'];
for (let field of fieldsToNotShip) delete apolloState.queries[queryId][field];
}
.then(() => ReactDOM.renderToString(component));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, what a massive cleanup!

Are you planning to keep initialState as a return value?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, see point 1. in the original PR comment; it was a bit of a hack IMO

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, I see...

the user will have access to an ApolloClient anyway, so can't they just get the store themselves

Yes, that sounds much better anyway 😄

}

// it's OK, because apolloState is nested somewhere in globalState
return { markup, initialState: client.store.getState() };
});
export function cleanupApolloState(apolloState) {
for (let queryId in apolloState.queries) {
let fieldsToNotShip = ['minimizedQuery', 'minimizedQueryString'];
for (let field of fieldsToNotShip) delete apolloState.queries[queryId][field];
}
}
Loading