Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add automatic SSR data hydration support #22

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,56 @@ export default withQuery((props) => {
loadingComponent: AnotherLoadingComponent,
})(UserProfile)
```
### Server Side Rendering

One common challenge with server-side rendering is hydrating client. The data used in the view needs to be available when the page loads so that React can hydrate the DOM with no differences. This technique will allow you send all of the necessary data with the initial HTML payload. The code below works with `withQuery` to track and hydrate data automatically. Works with static queries and subscriptions.

On the server:

```jsx harmony
import { onPageLoad } from 'meteor/server-render'
import { SSRDataStore } from 'meteor/cultofcoders:grapher-react'

onPageLoad(async sink => {
const store = new SSRDataStore()

sink.renderIntoElementById(
'root',
renderToString(
store.collectData(<App />)
)
Copy link
Contributor

Choose a reason for hiding this comment

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

propper 4 space indentation please

)

const storeTags = store.getScriptTags()
sink.appendToBody(storeTags)
})
```

On the client:

```jsx harmony
import { DataHydrator } from 'meteor/cultofcoders:grapher-react'

Meteor.startup(async () => {
await DataHydrator.load()
ReactDOM.hydrate(<App />, document.getElementById('root'))
})
```

Use `withQuery` on a component:

```jsx harmony
const SomeLoader = ({ data, isLoading, error }) => {
if (error) {
return <div>{error.reason}</div>
}

return <SomeList items={data} />
}

export default withQuery(
props => {
return GetSome.clone()
},
)(SomeLoader)
```
45 changes: 45 additions & 0 deletions lib/DataHydrator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Promise } from 'meteor/promise'
import { generateQueryId } from './utils.js';

export default {

decodeData(data) {
const decodedEjsonString = decodeURIComponent(data);
if (!decodedEjsonString) return null;

return EJSON.parse(decodedEjsonString);
},

load(optns) {
const defaults = {
Copy link
Contributor

Choose a reason for hiding this comment

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

specify defaults outside the function itself

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, why 3s for self-destructing ? Isn't that unsafe ?

selfDestruct: 3000
}
const options = Object.assign({}, defaults, optns)

return new Promise((resolve, reject) => {
// Retrieve the payload from the DOM
const dom = document.querySelectorAll(
'script[type="text/grapher-data"]',
document
);
const dataString = dom && dom.length > 0 ? dom[0].innerHTML : '';
const data = this.decodeData(dataString) || {};
window.grapherQueryStore = data

// Self destruct the store so that dynamically loaded modules
// do not pull from the store in the future
setTimeout(() => {
window.grapherQueryStore = {};
}, options.selfDestruct)

resolve(data);
});
},

getQueryData(query) {
const id = generateQueryId(query);
const data = window.grapherQueryStore[id]
return data
}

}
39 changes: 39 additions & 0 deletions lib/SSRDataStore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from 'react'
import PropTypes from 'prop-types';
import { EJSON } from 'meteor/ejson';
import { generateQueryId } from './utils.js';
export const SSRDataStoreContext = React.createContext(null);
Copy link
Contributor

@theodorDiaconu theodorDiaconu Apr 18, 2018

Choose a reason for hiding this comment

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

This release is going to break React 15.x, we need to make it optional. We still want to allow latest versions of grapher-react to be used with 15.x.


class DataStore {
storage = {}

add(query, value) {
const key = generateQueryId(query)
this.storage[key] = value
}

getData() {
return this.storage
}
}

export default class SSRDataStore{
Copy link
Contributor

@theodorDiaconu theodorDiaconu Apr 18, 2018

Choose a reason for hiding this comment

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

use consistent Coding standards (space before {)

constructor() {
this.store = new DataStore()
}

collectData(children) {
return <SSRDataStoreContext.Provider value={this.store}>{children}</SSRDataStoreContext.Provider>
}

encodeData(data) {
data = EJSON.stringify(data)
return encodeURIComponent(data)
}

getScriptTags() {
const data = this.store.getData()

return `<script type="text/grapher-data">${this.encodeData(data)}</script>`
Copy link
Contributor

Choose a reason for hiding this comment

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

use text/grapher-data, as a constant variable somewhere. Let's not rely on strings for things that are re-used in multiple places.

}
}
3 changes: 3 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const generateQueryId = function (query) {
return `${query.queryName}::${EJSON.stringify(query.params)}`;
}
52 changes: 39 additions & 13 deletions lib/withReactiveQuery.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {withTracker} from 'meteor/react-meteor-data';
import {ReactiveVar} from 'meteor/reactive-var';
import DataHydrator from './DataHydrator.js';
import { Meteor } from 'meteor/meteor';

/**
* Wraps the query and provides reactive data fetching utility
Expand All @@ -14,24 +16,48 @@ export default function withReactiveContainer(handler, config, QueryComponent) {
return withTracker((props) => {
const query = handler(props);

const subscriptionHandle = query.subscribe({
onStop(err) {
if (err) {
subscriptionError.set(err);
}
},
onReady() {
subscriptionError.set(null);
}
});
let isLoading
let data
let error

const isReady = subscriptionHandle.ready();
// For server-side-rendering, immediately fetch the data
// and save it in the data store for this request

if(Meteor.isServer) {
data = query.fetch();
isLoading = false;
props.dataStore.add(query, data);
} else {
const subscriptionHandle = query.subscribe({
onStop(err) {
if (err) {
subscriptionError.set(err);
}
},
onReady() {
subscriptionError.set(null);
}
});

isLoading = !subscriptionHandle.ready();

const data = query.fetch();
// Check the SSR query store for data
if(Meteor.isClient && window && window.grapherQueryStore) {
const ssrData = DataHydrator.getQueryData(query);
Copy link
Contributor

Choose a reason for hiding this comment

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

I now understand why you are doing the self-destruct thingie. But a healthier approach would be to set a flag once first hydration has been made, and do the check in here. I don't know something like window.grapher.hydrationCompleted = true

And pls use consistent styles if[SPACE](...)

if(ssrData) {
isLoading = false;
data = ssrData;
}
}

if(!data) {
data = query.fetch();
}
}

return {
grapher: {
isLoading: !isReady,
isLoading,
data,
error: subscriptionError,
},
Expand Down
47 changes: 41 additions & 6 deletions lib/withStaticQuery.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,48 @@
import React from 'react';
import getDisplayName from './getDisplayName';
import { Meteor } from 'meteor/meteor';
import DataHydrator from './DataHydrator.js';

export default function withStaticQueryContainer(WrappedComponent) {
/**
* We use it like this so we can have naming inside React Dev Tools
* This is a standard pattern in HOCs
*/
class GrapherStaticQueryContainer extends React.Component {
state = {
isLoading: true,
error: null,
data: [],
};
constructor(props) {
super(props);

this.state = {
isLoading: true,
error: null,
data: [],
};

const { query } = props;

// Check the SSR query store for data
if(Meteor.isClient && window && window.grapherQueryStore) {
const data = DataHydrator.getQueryData(query);
Copy link
Contributor

Choose a reason for hiding this comment

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

create 2 additional methods for this, there's too much code coupled in the constructor.

Copy link
Contributor

Choose a reason for hiding this comment

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

what if the query gets an error ? how is this treated ?

if(data) {
this.state = {
isLoading: false,
data
};

}
}

// For server-side-rendering, immediately fetch the data
// and save it in the data store for this request
if(Meteor.isServer) {
const data = query.fetch();
this.state = {
isLoading: false,
data
};
props.dataStore.add(query, data);
}
}

componentWillReceiveProps(nextProps) {
const {query} = nextProps;
Expand All @@ -20,7 +51,11 @@ export default function withStaticQueryContainer(WrappedComponent) {

componentDidMount() {
const {query, config} = this.props;
this.fetch(query);

// Do not fetch is we already have the data from SSR hydration
if(this.state.isLoading === true) {
this.fetch(query);
}

if (config.pollingMs) {
this.pollingInterval = setInterval(() => {
Expand Down
6 changes: 5 additions & 1 deletion main.client.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,8 @@ export {

export {
default as createQueryContainer
} from './legacy/createQueryContainer.js';
} from './legacy/createQueryContainer.js';

export {
default as DataHydrator
} from './lib/DataHydrator.js';
6 changes: 5 additions & 1 deletion main.server.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,8 @@ export {

export {
default as createQueryContainer
} from './legacy/createQueryContainer.js';
} from './legacy/createQueryContainer.js';

export {
default as SSRDataStore
} from './lib/SSRDataStore.js';
1 change: 1 addition & 0 deletions package.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Package.onUse(function (api) {
'react-meteor-data@0.2.15',
'cultofcoders:grapher@1.2.8_1',
'tmeasday:check-npm-versions@0.2.0',
'ejson'
]);

api.mainModule('main.client.js', 'client');
Expand Down
22 changes: 15 additions & 7 deletions withQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import withReactiveQuery from './lib/withReactiveQuery';
import withQueryContainer from './lib/withQueryContainer';
import withStaticQuery from './lib/withStaticQuery';
import checkOptions from './lib/checkOptions';
import { SSRDataStoreContext } from './lib/SSRDataStore.js'

export default function (handler, _config = {}) {
checkOptions(_config);
Expand All @@ -14,19 +15,26 @@ export default function (handler, _config = {}) {
const queryContainer = withQueryContainer(component);

if (!config.reactive) {
const staticQueryContainer = withStaticQuery(queryContainer);
const StaticQueryContainer = withStaticQuery(queryContainer);

return function (props) {
const query = handler(props);

return React.createElement(staticQueryContainer, {
query,
props,
config
})
return (
<SSRDataStoreContext.Consumer>
Copy link
Contributor

Choose a reason for hiding this comment

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

I fail to understand why you need context, if you can use window as a global variable to do your thing.

window.GRAPHER = { queryStore: {}, hydrationCompleted: bool, dataStore }

{dataStore => <StaticQueryContainer query={query} props={props} config={config} dataStore={dataStore} />}
</SSRDataStoreContext.Consumer>
)
}
} else {
return withReactiveQuery(handler, config, queryContainer);
const ReactiveQueryContainer = withReactiveQuery(handler, config, queryContainer);
return function(props) {
return (
<SSRDataStoreContext.Consumer>
{dataStore => <ReactiveQueryContainer dataStore={dataStore} />}
</SSRDataStoreContext.Consumer>
)
}
}
};
}