Skip to content

Add automatic SSR data hydration support #22

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

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
29 changes: 29 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
module.exports = {
extends: [
'standard',
'plugin:meteor/recommended',
'plugin:react/recommended',
'plugin:import/errors',
'plugin:import/warnings',
],
plugins: ['react', 'meteor', 'import'],
settings: {
'import/resolver': {
meteor: {
extensions: ['.js', '.jsx'],
},
},
},
rules: {
// "import/no-duplicates": 0,
// "react/display-name": 0,
'comma-dangle': 0,
'space-before-function-paren': 0,
// "indent": [
// "error",
// "tab"
// ],
semi: 0,
// "meteor/no-session": 0
},
};
5 changes: 5 additions & 0 deletions .prettierrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
trailingComma: 'es5',
singleQuote: true,
tabWidth: 2,
}
9 changes: 9 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"editor.tabSize": 2,
"editor.formatOnSave": true,
"eslint.enable": true,
"eslint.provideLintTask": true,
"eslint.autoFixOnSave": false,
"javascript.validate.enable": false,
"git.ignoreLimitWarning": true
}
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)
```
6 changes: 3 additions & 3 deletions defaults.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export default {
reactive: false,
single: false,
}
reactive: false,
single: false,
};
122 changes: 63 additions & 59 deletions legacy/createQueryContainer.js
Original file line number Diff line number Diff line change
@@ -1,76 +1,80 @@
import React from 'react';
import {createContainer} from 'meteor/react-meteor-data';
import { createContainer } from 'meteor/react-meteor-data';

export default (query, component, options = {}) => {
if (Meteor.isDevelopment) {
console.warn('createQueryContainer() is deprecated, please use withQuery() instead')
}
if (Meteor.isDevelopment) {
console.warn(
'createQueryContainer() is deprecated, please use withQuery() instead'
);
}

if (options.reactive) {
return createContainer((props) => {
if (props.params) {
query.setParams(props.params);
}
if (options.reactive) {
return createContainer(props => {
if (props.params) {
query.setParams(props.params);
}

const handler = query.subscribe();
const handler = query.subscribe();

return {
query,
loading: !handler.ready(),
[options.dataProp]: options.single ? _.first(query.fetch()) : query.fetch(),
...props
}
}, component);
}
return {
query,
loading: !handler.ready(),
[options.dataProp]: options.single
? _.first(query.fetch())
: query.fetch(),
...props,
};
}, component);
}

class MethodQueryComponent extends React.Component {
constructor() {
super();
this.state = {
[options.dataProp]: undefined,
error: undefined,
loading: true
}
}
class MethodQueryComponent extends React.Component {
constructor() {
super();
this.state = {
[options.dataProp]: undefined,
error: undefined,
loading: true,
};
}

componentWillReceiveProps(nextProps) {
this._fetch(nextProps.params);
}
componentWillReceiveProps(nextProps) {
this._fetch(nextProps.params);
}

componentDidMount() {
this._fetch(this.props.params);
}
componentDidMount() {
this._fetch(this.props.params);
}

_fetch(params) {
if (params) {
query.setParams(params);
}
_fetch(params) {
if (params) {
query.setParams(params);
}

query.fetch((error, data) => {
const state = {
error,
[options.dataProp]: options.single ? _.first(data) : data,
loading: false
};
query.fetch((error, data) => {
const state = {
error,
[options.dataProp]: options.single ? _.first(data) : data,
loading: false,
};

this.setState(state);
});
}
this.setState(state);
});
}

render() {
const {state, props} = this;
render() {
const { state, props } = this;

return React.createElement(component, {
query,
...state,
...props
})
}
return React.createElement(component, {
query,
...state,
...props,
});
}
}

MethodQueryComponent.propTypes = {
params: React.PropTypes.object
};
MethodQueryComponent.propTypes = {
params: React.PropTypes.object,
};

return MethodQueryComponent;
}
return MethodQueryComponent;
};
34 changes: 34 additions & 0 deletions lib/DataHydrator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { EJSON } from 'meteor/ejson';
import { generateQueryId, DATASTORE_MIME } from './utils.js';

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

return EJSON.parse(decodedEjsonString);
},

load() {
// Retrieve the payload from the DOM
const dom = document.querySelectorAll(
`script[type="${DATASTORE_MIME}"]`,
document
);
const dataString = dom && dom.length > 0 ? dom[0].innerHTML : '';
const data = this.decodeData(dataString) || {};
window.grapherQueryStore = data;

return data;
},

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

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

class DataStore {
storage = {};

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

getData() {
return this.storage;
}
}

export default class SSRDataStore {
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="${DATASTORE_MIME}">${this.encodeData(data)}</script>`;
}
}
63 changes: 63 additions & 0 deletions lib/User.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Meteor } from 'meteor/meteor';
import { Accounts } from 'meteor/accounts-base';
import hoistNonReactStatic from 'hoist-non-react-statics';
import { withTracker } from 'meteor/react-meteor-data';

export const UserContext = React.createContext(null);

class UserContextProvider extends React.Component {
static propTypes = {
user: PropTypes.object,
children: PropTypes.node,
};

render() {
return (
<UserContext.Provider value={this.props.user}>
{this.props.children}
</UserContext.Provider>
);
}
}

export const User = withTracker(props => {
let user;
if (Meteor.isServer) {
if (props.token) {
user = Meteor.users.findOne(
{
'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(
props.token
),
},
{ reactive: false }
);
}
} else {
user = Meteor.user();
}

return {
user,
};
})(UserContextProvider);

const withUser = function(Component) {
const C = props => {
return (
<UserContext.Consumer>
{value => {
return <Component {...props} user={value} />;
}}
</UserContext.Consumer>
);
};

C.displayName = `withUser(${Component.displayName || Component.name})`;

hoistNonReactStatic(C, Component);
return C;
};
export default withUser;
Loading