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

Server-side rendering: Create an API for async data fetching #54

Closed
vovacodes opened this issue May 16, 2016 · 10 comments
Closed

Server-side rendering: Create an API for async data fetching #54

vovacodes opened this issue May 16, 2016 · 10 comments
Assignees
Labels

Comments

@vovacodes
Copy link
Contributor

Our goals on server are:

  • fetch all data required for current React tree
  • save data to store
  • do initial render to string with data resolved (ReactDOM.renderToString())
  • send DOM string along with the serialized store state to a client

The main problem here is that, if we have several connected components in a tree, we have to use some sort of waterfall fetching - child component cannot fetch its data until all its parents fetch their queries because there might be different components rendered depending on the props received from the queries. That means, we cannot statically analyze the entire tree in advance and fetch all data in one run, which could negatively affect performance.

Need to investigate how relay addresses the same issue and maybe come up with our own solution here.

@jbaxleyiii
Copy link
Contributor

@wizardzloy I'm thinking this will be my focus late this week / next week. I've got a ton of thoughts on it + react-router integration.

Just curious, what does your stack look like? React / React Router / Meteor?

@jbaxleyiii jbaxleyiii self-assigned this May 16, 2016
@vovacodes
Copy link
Contributor Author

It's react + react-router + react-apollo. So far the code looks like this:

const express = require('express');
const React = require('react');
const ReactDomServer = require('react-dom/server');
const { createMemoryHistory, match, RouterContext } = require('react-router');
const ApolloClient = require('apollo-client').default;
const { ApolloProvider } = require('react-apollo');

const App = require('./components/App').default;
const { getRoutes } = require('./routes');

const app = express();
const mockGraphQLNetworkInterface = {
  query: () => Promise.resolve({ data: { list: [ 'apple', 'orange' ] }, errors: null })
};
const apolloClient = new ApolloClient({
  networkInterface: mockGraphQLNetworkInterface
});

// Server-Side-Rendering
app.use((req, res) => {

  const history = createMemoryHistory(req.path);
  const routes = getRoutes();

  // Find the correct route
  match({ history, routes, location: req.url }, (err, redirectLocation, renderProps) => {
    if (err) {
      return res.status(500).send(err.message);
    }
    if (redirectLocation) {
      return res.redirect(302, redirectLocation.pathname + redirectLocation.search);
    }
    if (!renderProps) {
      return res.status(404).send('Not found');
    }

    const app = (
        <ApolloProvider client={apolloClient} >
          <RouterContext {...renderProps}/>
        </ApolloProvider>
    );

    const html = ReactDomServer.renderToString(app);

    res.send(html);
  });
});

app.listen(8484, 'localhost', (err) => {
  if (err) {
    console.log(err);
    return;
  }
  console.log('listening on http://127.0.0.1:8484')
});

@jbaxleyiii Could you, please, share your ideas regarding this topic?

@jbaxleyiii
Copy link
Contributor

jbaxleyiii commented May 16, 2016

Okay this is going to be a brain dump:

Possible SSR / rehydration methods

Static fetchData on router level component (how we are currently doing it)

// component
import { Component } from 'react';
import { connect } from 'react-apollo';

class Category extends Component {
  static fetchData = (props, location) => {
     return props.query({
        query: gql`
          query getCategory($categoryId: Int!) {
            category(id: $categoryId) {
              name
              color
            }
          }
        `,
        variables: {
          categoryId: 5,
        },
     });
  }

  render() {
    // markup
  }
}

function mapQueriesToProps({ ownProps, state }) {
  return {
    category: {
      query: gql`
        query getCategory($categoryId: Int!) {
          category(id: $categoryId) {
            name
            color
          }
        }
      `,
      variables: {
        categoryId: 5,
      },
      forceFetch: false,
      returnPartialData: true,
    },
  };
};

const CategoryWithData = connect({
  mapQueriesToProps,
})(Category);


// from router tree
<Route path="categories" component={CategoryWithData}/>

// our router addon which populates the store with data
// this is called during render, but before rendering the app to a string
function fetchComponentData(renderProps, reduxStore) {
  const componentsWithFetch = renderProps.components
    // Weed out 'undefined' routes.
    .filter(component => !!component)
    // Only look at components with a static fetchData() method
    .filter(component => component.fetchData);

  if (!componentsWithFetch.length) {
    return;
  }

  // Call the fetchData() methods, which lets the component dispatch possibly
  // asynchronous actions, and collect the promises.
  const promises = componentsWithFetch
    .map(component => component.fetchData(reduxStore.getState, reduxStore.dispatch, renderProps));

  // Wait until all promises have been resolved.
  Promise.awaitAll(promises);
}

Issues with above:

  • only hits router level components
  • duplicated code between static method and connect function

Router based queries (from apollostack/react-router-apollo draft)

import React from 'react';
import { render } from 'react-dom';
import { Link, browserHistory } from 'react-router';
import { Router, Route, applyRouterMiddleware } from 'react-router';
import apolloMiddleware from 'react-router-apollo';
import ApolloClient from 'apollo-client';

const client = new ApolloClient();

const App = React.createClass({/*...*/})
const About = React.createClass({/*...*/})
// etc.

const Users = React.createClass({
  render() {
    return (
      <div>
        <h1>Users</h1>
        <div className="master">
          <ul>
            {/* use Link to route around the app */}
            {this.state.users.map(user => (
              <li key={user.id}><Link to={`/user/${user.id}`}>{user.name}</Link></li>
            ))}
          </ul>
        </div>
        <div className="detail">
          {this.props.children}
        </div>
      </div>
    )
  }
})

const User = React.createClass({
  render() {
    return (
      <div>
        <h2>{this.props.currentUser.user.name}</h2>
        {/* etc. */}
      </div>
    )
  }
})


const userQueries = ({ location, params }) => ({
  currentUser: {
    query: `
      query getUserData ($id: ID!) {
        user(id: $id) {
          emails {
            address
            verified
          }
          name
        }
      }
    `,
    variables: {
      id: params.userId,
    },
  }
})

const userMutations = ({ location, params }) => ({
  addProfileView: (date) => {
    mutation: `
      mutation addProfileView($route: String!, $id: ID!, date: $String!) {
        addProfileView(route: $route, id: $id, date: $date)
      }
    `,
    variables: {
      route: location.pathname,
      id: params.userId,
      date,
    },
  },
});

// Declarative route configuration (could also load this config lazily
// instead, all you really need is a single root route, you don't need to
// colocate the entire config).
render((
  <Router
    history={browserHistory}
    client={client}
    render={applyRouterMiddleware(apolloMiddleware)}
  >
    <Route path="/" component={App}>
      <Route path="about" component={About}/>
      <Route path="users" component={Users}>
        <Route
          path="/user/:userId"
          component={User}
          queries={userQueries}
          mutations={userMutations}
          onEnter={function(nextState, replace, callback){
            const now = new Date();
            this.mutations.addProfileView(now);
          }}
        />
      </Route>
      <Route path="*" component={NoMatch}/>
    </Route>
  </Router>
), document.body)

Issues with above:

  • removes colocation of queries to components
  • still only gets router level data

Tree analyzation + react-router integration

import { Component } from 'react';
import { connect } from 'react-apollo';

class Category extends Component {
  render() {
    // markup
  }
}

function mapQueriesToProps({ ownProps, state }) {
  return {
    category: {
      query: gql`
        query getCategory($categoryId: Int!) {
          category(id: $categoryId) {
            name
            color
          }
        }
      `,
      variables: {
        categoryId: 5,
      },
      forceFetch: false,
      returnPartialData: true,
      ssr: true // tell the client to block during SSR
    },
  };
};

const CategoryWithData = connect({
  mapQueriesToProps,
})(Category);

// router

import { apolloSSRMiddleware } from "react-router-apollo";

render((
  <Router
    history={browserHistory}
    client={client}
    render={applyRouterMiddleware(apolloSSRMiddleware)}
  >
    <Route path="/" component={App}>
      <Route path="about" component={About}/>
      <Route path="users" component={Users}>
        <Route path="/user/:userId"  component={User} />
      </Route>
      <Route path="*" component={NoMatch}/>
    </Route>
  </Router>
), document.body)

Issues with above:

  • possibly none!

Best of all worlds could be combining tree analyzation with the react-router middleware

Other thoughts

Being able to combine queries into one big request would be awesome. Some data in the tree may require props from parent data from queries. If this is the case, we would need to expose a way to tell the tree to get all of the data from this query before working your way down the tree further. Something along the lines of blockRender: true or something like that applied to connect would be helpful.

We will also need to ship a way to add the data as a string (we base64 encode it currently) to a script tag, then parse it on the client side before starting the client. This ships all of the redux store over to the client. I've already added a way to pass initialState to the apollo-client before it starts up.

Regardless of how we do this, it will have to be a client and server supported system. Since the apollo-client is passed into the react tree, we either have to provide a non-react tree based way of doing it, OR do something like this:

// application code
import { ApolloProvider, getData } from "react-apollo"
import ApolloClient from "apollo-client"

const app = (
<ApolloProvider 
  createClient={(initialState) => {
    return new ApolloClient({
      initialState,
    })
  }}
>
  <App />
</ApolloProvider>
)


// server code
html = ReactDOMServer.renderToString(getData(app));

// alternatively, if a user just wants SSH (server side hydration)
const data = getData(app);

// add to initial markup somehow
<script apollo-data>/* base64 encoded data */</script>

// Ideally `getData` would take an argument that would allow you to specify the serialization of your data
// then within ApolloProvider on the client you could de-serialization how you want

If we did the above, on the server the initialState would be empty (you could do a sync request to pull an initial state from cache too) and the client would be generated and the store executed before the render. Then renderToString would be called on a tree that can sync get all of the data because it has already been requested. It would inject a <script apollo-data>/* base64 encoded data */</script> into the html that the ApolloProvider would read when it is running createClient on the client side.

alternatively, createClient could take a promise so async cache lookup would be easier


SO, there are quite a few ways we can do it. I'm sure there are more / better ways than even what I have listed.

My overall goal though is this:

  • colocate data (its the GraphQL and React way)
  • allow for cascading tree queries which can (but don't have to) block
  • auto add data to static html
  • auto read data from html on DOM startup
  • not change the API of react-apollo so transitioning to SSR would be easy
  • allow for people who want SSH (server side hydration) but not SSR
  • have the surface area of your app be minimally affected (see the last code block)
  • make caching trivial to integrate

Thoughts?

cc @wizardzloy @stubailo @johnthepink @helfer @gauravtiwari

@vovacodes
Copy link
Contributor Author

regarding serialization of data, why not to simply use:

// server
window.__APOLLO_STATE__ = JSON.serialize(store.getState())

// client
const client = new ApolloClient({
  initialState: window.__APOLLO_STATE__
})

I believe everybody uses this way of serialization when it comes to passing data from server to a client in a markup.

@jbaxleyiii
Copy link
Contributor

We can probably make that the default. We obfuscate our data for SEO reasons so having that option is nice

@gauravtiwari
Copy link

@jbaxleyiii, I guess colocation of data is very important, because that would make the client pluggable with almost all front-end frameworks or libraries and it will also help in caching. One of thing that's quite unique with apollo client is that it can work with any framework or library, which is very important and USP.

@jbaxleyiii you already mentioned all points that are necessary to make SSR painless. If we make the data store fully interoperable and independent then it would be easy to transact with store from any framework or library, in the end it's all Javascript.

How about we just set the result object straightaway with props? Because, props are already available during runtime, on server. Instead of passing JSON blobs, how about we share the instance of apollo client on server and client and then set the store accordingly.

For ex: on server or client if, data is already provided through some kinda of prop.

<div data-props='{"key": "value"}'></div>

and we have access to this prop on client or server.

// somewhere on server to be globally available or on client
const PostClient = new ApolloClient({ initialState: props }); //just set the data to result object
or 
PostClient.setInitialState(props)

window.PostComponent = PostComponent; // some react component
window.PostClient = PostClient; // globally available
window.serverRenderComponent({ component: PostComponent, props: props, client: PostClient });

and then during rendering:

import ReactDOMServer from 'react-dom/server';
import createReactElement from './createReactElement';

export default function serverRenderComponent(options) {
  const { component, props, client } = options;
  const reactElement = createReactElement(component, props);

 htmlResult = ReactDOMServer.renderToStaticMarkup(
    <ApolloProvider client={PostClient} children={reactElement} />
  );
  return htmlResult;
}

And then we can access same PostClient on client as it's globally set and use it on client side mounting of the component. Props could be accessed, for example on rails: https://github.com/reactjs/react-rails/blob/master/lib/react/rails/component_mount.rb#L25

or in express:

https://github.com/mjackson/react-stdio/blob/master/server.js#L33

@gauravtiwari
Copy link

@jbaxleyiii Another example, here a redux store is exposed to window or globalobject as available, https://github.com/shakacode/react_on_rails/blob/master/node_package/src/StoreRegistry.js and then used on both client and server. Same could be done with instantiated client object.

https://github.com/shakacode/react_on_rails/blob/master/spec/dummy/client/app/startup/serverRegistration.jsx#L40 (registration to window object)

@jbaxleyiii
Copy link
Contributor

Okay so it sounds like we can tackle this in multiple steps:

  • allow per component data prefillling (typically useful for non js backends)
  • allow registering a query to be SSR and react-apollo doing everything needed to render

Per Component Prefilling

This should be pretty simple overall because it is primarily a client side action. Given a prop (detailed below), react-apollo's connect should dispatch that data to the store on mount. It can pass the data through to the child component as well so both the client and the store have the correct data when the app is instansiated.

// given query object
const mapQueriesToProps = () => ({
  category: {
    query: gql`
      query getCategory($categoryId: Int!) {
         category(id: $categoryId) {
           name
           color
         }
       }
    `,
    variables: {
      categoryId: 5,
    },
    ssr: true // tell the client to block during SSR
  },
})

// passed props
const data = {
  category: {
     category: {
       name: "sample",
       color: "blue"
     }
  }
}

Props as stored in the DOM

<div data-ssr-props='{ "category": { "category": { "name": "sample", "color": "blue" } } }'></div>

Minimial SSR API

....still in development / thought

@vovacodes
Copy link
Contributor Author

vovacodes commented May 23, 2016

https://github.com/iamdustan/tiny-react-renderer a nice explanation of how to start building your own custom React renderer. In case, we would need to create one for SSR async query resolving

@jbaxleyiii
Copy link
Contributor

First round of this is shipped in #83. If anyone can try this in an app and file bugs that would be great!

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

No branches or pull requests

4 participants