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

Improve async handling #30

Open
BerndWessels opened this issue Mar 22, 2017 · 23 comments
Open

Improve async handling #30

BerndWessels opened this issue Mar 22, 2017 · 23 comments

Comments

@BerndWessels
Copy link

Hi
It would be good to have an interface like this:

render(
  <Provider store={store}>
    <Root/>
  </Provider>
).then(({html, context, state}) => { /* all done, serve to client now */ }

So the render promise will only resolve once everything has been rendered and all async actions have been finished. This would make it easier to get the redux state too and you wouldn't have to get the redux state artificially before calling render (plus artificially figuring out the route n stuff - all the things that make the server side different from the client side).
Basically run the complete application server side until its stabilized and ready to render.

@developit
Copy link
Member

I agree it would be nice, but it's actually impossible. Preact doesn't (and can't) know anything about side effects like redux or fetches done in componentDidMount(), so it can't know when things are resolved/updated/etc.

That being said, outside the context of preact-render-to-string I think there are ways this could be made easier. Just any solution would have to be implemented in userland since it would rely heavily on specifics like redux or fetch (whatever the source of asynchronicity is).

@BerndWessels
Copy link
Author

@developit I see a lot of isomorphic implementations struggling with this problem. Many seem to make it even more complex with specialized router integrations.

Here is my current take on this. I basically leave everything to the app as if it would be rendered on the client, but expect a signal (action) that indicates that its now ready to be sent to the client side.

The only thing I don't like is the fact that I have to call render twice. I was hoping this might be somehow done in a single call to render.

/**
 * Import dependencies.
 */
import {h} from 'preact';
import render from 'preact-render-to-string';
import {Provider} from 'preact-redux';
import {applyMiddleware, createStore} from 'redux';
import {createEpicMiddleware} from 'redux-observable';
import {StaticRouter} from 'react-router';
import {IntlProvider} from 'react-intl';

/**
 * Import local dependencies.
 */
import Root from './component';
import {rootReducer} from './reducer';
import rootEpic from './epic';
import {ROOT_STATE_READY_TO_RENDER} from './actions';

/**
 * Export the promise factory.
 */
export default function (req) {

  /**
   * Create a new promise for the current request.
   */
  return new Promise((resolve) => {

    /**
     * Injected reducer to process the "ready to render" action.
     */
    let finalRender = true;
    const serverReducer = (state = {}, action) => {
      console.log('action => ', action);
      if (action.type === ROOT_STATE_READY_TO_RENDER && finalRender) {
        finalRender = false;
        resolve(renderRoot());
      }
      return state;
    };

    /**
     * Create the epic middleware.
     */
    const epicMiddleware = createEpicMiddleware(rootEpic);

    /**
     * Create the store.
     */
    let store = createStore(
      rootReducer(serverReducer),
      applyMiddleware(epicMiddleware)
    );

    /**
     * Render the application.
     */
    const renderRoot = () => {
      let context = {};
      let html = render(
        <Provider store={store}>
          <IntlProvider locale="en">
            <StaticRouter location={req.url} context={context}>
              <Root/>
            </StaticRouter>
          </IntlProvider>
        </Provider>
      );
      let state = store.getState();
      delete state._server_;
      delete state.router;
      return {context, html, state};
    };

    /**
     * Initial render.
     * TODO We are rendering twice on the server to trigger all actions and pre-loading exactly like on the client.
     * TODO This could be reduced to a single render once this is resolved https://github.com/ReactTraining/react-router/issues/4407
     */
    renderRoot();
  });
}

@developit
Copy link
Member

Ahh - you might be interested in the more advanced server renderer I've been pondering:

let renderer = createRenderer();

renderer.render(
  <Provider store={store}>
    ...
  </Provider>
);

// later (async) after you get that even letting you know everything is loaded:
let html = renderer.flush();  // renders to string

The main difference here is that this would actually be a full DOM renderer - it needs to mount, so it uses a lightweight DOM implementation. That might open up some really neat opportunities for caching within a tree, and it only serializes to HTML when you call flush().

Does that sound a bit more like what you're looking for?

@developit
Copy link
Member

developit commented Mar 23, 2017

@BerndWessels I've created a JSFiddle demo showing what I described. It's a little less obvious what's going on since it's in a browser context, but this is a server-side renderer that you instantiate, and can call .html() to get a snapshot of the tree at any point in time.

https://jsfiddle.net/developit/mLkwc1u3/

let renderer = createRenderer(<App />);

// as many times as  you want:
setTimeout( () => {
  console.log( renderer.html() );
}, 100);

@BerndWessels
Copy link
Author

BerndWessels commented Mar 23, 2017

@developit Wow that sounds really cool and pretty close to what I was hoping for.

Where does createRenderer come from and is it stable / in sync with preact?

Or lets rephrase this, do you think this is a stable and performant solution for server side rendering?

Is a headless DOM massively more overhead than render-to-string alone?

@developit
Copy link
Member

I'd really like to get some benchmarks going for it, but I'd actually think it might be slightly faster. createRenderer() is just in that fiddle - it's not really much of a library, really just a wrapper around undom. For context, undom is a really simple implementation of the basic DOM APIs Preact relies on - it doesn't have any of the overhead associated with the DOM as we know it, instead it's just simple objects and Arrays.

Alright I just tested some things out via server-side-rendering-comparison and as I suspected - it's even faster than render-to-string! Over 25% faster!

@developit
Copy link
Member

Here's what I used:
https://gist.github.com/developit/6e117d53f4f32b8f1e63bb43f5f6e937

@BerndWessels
Copy link
Author

@developit Awesome !!! Thanks you so much !!!
There needs to be a blog about this since it will make server-side rendering so much simpler and more compatible with the client-side code - basically no need for crazy router hacks and other workarounds anymore.
I'll give it a try right away, thanks again.

@BerndWessels
Copy link
Author

@developit The implementation of serializeHtml is slightly different between the fiddle and the gist. The one in the gist has a bug hits doesn't exist. Does this code actually come from somewhere? Like is there a fully tested version of this function somewhere?

@developit
Copy link
Member

Hi there - use the gist one, it's faster. I was only using hits to track className so instead I changed it to do that explicitly. The gist is the one I benchmarked, it should be quite solid. I will probably turn this into a proper module soon, but it requires some running around to get authorization for that :(

@BerndWessels
Copy link
Author

Great, thank you so much - works fantastic for me now!

@ezekielchentnik
Copy link

this is cool

@BerndWessels
Copy link
Author

Here is how I use all this for my server-side rendering. Basically I run the app exactly like I would on the client, only that on the server I inject an additional reducer that waits for ROOT_STATE_READY_TO_RENDER action and then resolves the promise with the currently rendered version of the app. It's awesome and from what I can tell very fast.

Another thing I love about this is that I can use ConnectedRouter from react-router-redux on the client and StaticRouter from react-router on the server - which allows the rest of the routing code to be identical (like the use of <Route>).

Server:

/**
 * Import dependencies.
 */
import {h} from 'preact';
import createRenderer from './preact-dom-renderer';
import render from 'preact-render-to-string';
import {Provider} from 'preact-redux';
import {applyMiddleware, createStore} from 'redux';
import {createEpicMiddleware} from 'redux-observable';
import {StaticRouter} from 'react-router';
import {addLocaleData, IntlProvider} from 'react-intl';
import {head, split} from 'ramda';

/**
 * Import local dependencies.
 */
import Root from './component';
import {rootReducer} from './reducer';
import rootEpic from './epic';
import {ROOT_STATE_READY_TO_RENDER} from './actions';

import intlDE from 'react-intl/locale-data/de.js';
import intlEN from 'react-intl/locale-data/en.js';
import intlMessagesDE from '../public/assets/translations/de.json';
import intlMessagesEN from '../public/assets/translations/en.json';

/**
 * Export the promise factory.
 */
export default function (req) {

  /**
   * Create a new promise for the current request.
   */
  return new Promise((resolve) => {

    /**
     * Injected reducer to process the "ready to render" action.
     */
    const serverReducer = (state = {}, action) => {
      console.log('action => ', action);
      if (action.type === ROOT_STATE_READY_TO_RENDER) {
        let state = store.getState();
        delete state._server_;
        delete state.router;
        resolve({context: context, html: renderer.html(), state});
      }
      return state;
    };

    /**
     * Load the locale data for the users language.
     */
    const locale = req.query.language ? head(split('-', req.query.language)) : 'de'; // TODO support en-gb fallback to en?
    const locales = {
      de: {data: intlDE, messages: intlMessagesDE},
      en: {data: intlEN, messages: intlMessagesEN}
    };

    // Set the locale data.
    addLocaleData(locales[locale].data);

    /**
     * Create the epic middleware.
     */
    const epicMiddleware = createEpicMiddleware(rootEpic);

    /**
     * Create the store.
     */
    let store = createStore(
      rootReducer(serverReducer),
      applyMiddleware(epicMiddleware)
    );

    /**
     * Now we can run the app on the server.
     * https://github.com/developit/preact-render-to-string/issues/30#issuecomment-288752733
     */
    const renderer = createRenderer();

    // Router context for capturing redirects.
    let context = {};

    // Run the app on the server.
    renderer.render(
      <Provider store={store}>
        <IntlProvider locale={locale} messages={locales[locale].messages}>
          <StaticRouter location={req.url} context={context}>
            <Root/>
          </StaticRouter>
        </IntlProvider>
      </Provider>
    );
  });
}

Client:

/**
 * Import dependencies.
 */
import {h, render} from 'preact';
import {Provider} from 'preact-redux';
import {applyMiddleware, compose, createStore} from 'redux';
import {createEpicMiddleware} from 'redux-observable';
import createHistory from 'history/createBrowserHistory'
import {ConnectedRouter, routerMiddleware} from 'react-router-redux'
import {addLocaleData, IntlProvider} from 'react-intl';
import {head, split} from 'ramda';

/**
 * Import local dependencies.
 */
import rootReducer from './reducer';
import rootEpic from './epic';

import intlDE from 'bundle-loader?lazy!react-intl/locale-data/de.js';
import intlEN from 'bundle-loader?lazy!react-intl/locale-data/en.js';
import intlMessagesDE from 'bundle-loader?lazy!../public/assets/translations/de.json';
import intlMessagesEN from 'bundle-loader?lazy!../public/assets/translations/en.json';

/**
 * Load the locale data for the users language.
 */

function getQueryStringValue (key) {
  return decodeURIComponent(window.location.search.replace(new RegExp("^(?:.*[&\\?]" + encodeURIComponent(key).replace(/[\.\+\*]/g, "\\$&") + "(?:\\=([^&]*))?)?.*$", "i"), "$1"));
}

// Get the users language.
const locale = getQueryStringValue('language') ? head(split('-', getQueryStringValue('language'))) : 'de'; // TODO support en-gb fallback to en?
const locales = {
  de: {data: intlDE, messages: intlMessagesDE},
  en: {data: intlEN, messages: intlMessagesEN}
};
console.log(locale);
// Load the locale data for the users language asynchronously.
locales[locale].data((localeData) => {

  // Set the locale data.
  addLocaleData(localeData);

  // Load the locale messages for the users language asynchronously.
  locales[locale].messages((localeMessages) => {

    /**
     * Create the browser history access.
     */
    const history = createHistory();

    /**
     * Create the epic middleware.
     */
    const epicMiddleware = createEpicMiddleware(rootEpic);

    /**
     * Create the store.
     */
    let store;
    if (process.env.NODE_ENV === 'development') {
      // Development mode with Redux DevTools support enabled.
      const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
        // Prevents Redux DevTools from re-dispatching all previous actions.
        shouldHotReload: false
      }) : compose;
      // Create the redux store.
      store = createStore(
        rootReducer,
        composeEnhancers(applyMiddleware(routerMiddleware(history), epicMiddleware))
      );
    } else {
      // Production mode.
      store = window.__INITIAL_STATE__ ? createStore(
        rootReducer,
        window.__INITIAL_STATE__,
        applyMiddleware(routerMiddleware(history), epicMiddleware)
      ) : createStore(
        rootReducer,
        applyMiddleware(routerMiddleware(history), epicMiddleware)
      );
      // TODO delete initial state for garbage collection?
    }

    /**
     * Render the application.
     */
    let root = document.body.lastElementChild;
    const renderRoot = () => {
      let Root = require('./component').default;
      requestAnimationFrame(() => {
        root = render(
          <Provider store={store}>
            <IntlProvider locale={locale} messages={localeMessages}>
              <ConnectedRouter history={history}>
                <Root/>
              </ConnectedRouter>
            </IntlProvider>
          </Provider>,
          document.body,
          root
        );
      });
    };

    /**
     * Enable hot module reloading in development mode.
     */
    if (process.env.NODE_ENV === 'development') {
      if (module.hot) {
        // Handle updates to the components.
        module.hot.accept('./component', () => {
          console.log('Updated components');
          renderRoot();
        });
        // Handle updates to the reducers.
        module.hot.accept('./reducer', () => {
          console.log('Updated reducers');
          let rootReducer = require('./reducer').default;
          store.replaceReducer(rootReducer);
        });
        // Handle updates to the epics.
        module.hot.accept('./epic', () => {
          console.log('Updated epics');
          let rootEpic = require('./epic').default;
          epicMiddleware.replaceEpic(rootEpic);
        });
      }
    }

    /**
     * Finally render the app.
     */
    renderRoot();
  });
});

@developit
Copy link
Member

@BerndWessels wow, that is an amazingly complete example. Nice work, I am probably going to steal some of the ideas haha

@BerndWessels
Copy link
Author

@developit You are welcome and thank you for your great work.

The complete repo is here.

@Stanback
Copy link

@developit Awesome example rendering with undom, very helpful!

Just a reminder for anyone using this on the server to remove the x-root parent from the undom body after serving out a request - otherwise references will be kept around and you'll end up with leaks. I added a tearDown method to my implementation here https://gist.github.com/Stanback/3bb0b19b299668ce0e08922a8ab6876e

@developit
Copy link
Member

@Stanback good call! I'd actually recommend one slight change there - instead of just removing the element, un-render it via preact so everything gets nicely disconnected:

tearDown: () => render(<nothing />, parent, root).remove()

@Stanback
Copy link

Stanback commented Apr 3, 2017

@developit Good suggestion 👍 I've updated my gist

@BerndWessels
Copy link
Author

@Stanback Have you benchmarked your serializeHtml against @developit 's gist ?
I'm just curious if there's a huge performance or stability difference or why you wrote your own.

@Stanback
Copy link

Stanback commented Apr 3, 2017

@BerndWessels It's basically the same code as he posted in the gist, but with some ES6 syntax. You can see both versions in the undom readme.

I don't think there would be much performance difference - I made some changes since I needed to support void (aka. self-closing) tags (the code in his gist will render a line break as <br></br> which most browsers will interpret as two br tags) as well as innerHTML (via dangerouslySetInnerHTML). I've also added some code for stripping null class="" and reducing <tag attr="true" /> and <tag attr="" /> to just <tag attr>.

I've opened an issue here with regards to innerHTML, hopefully there can be some kind of supported plugin for html serialization in the future developit/undom#7

@developit
Copy link
Member

developit commented Apr 3, 2017

@Stanback Makes sense - I figured you had modified it to more closely match the output of preact-render-to-string, which seems like a good idea. It will be good to codify these into plugins so that we can share them in the undom repo itself, makes everyone's lives easier.

BerndWessels added a commit to BerndWessels/preact-redux-isomorphic that referenced this issue Apr 3, 2017
@Satyam
Copy link

Satyam commented Apr 4, 2017

After my initial suggestion I agree that implementing asynchronous rendering on the client is not feasible. Thus, I switched to doing it on the server side, based on the same principle, have a lifecycle method returning a Promise.

I added a asyncRender method. I added the code on a branch on my fork of this module. I did a sample page containing a more detailed explanation. I also added a full test suite based on the original test suite. It runs all the original tests, plus the async versions of the same tests, plus a few extra ones (all except for one, which is pointed out in the readme).

The readme explains it in more detail, it would be pointless to copy it here.

@Stanback
Copy link

Stanback commented Apr 4, 2017

@Satyam Looks great, I haven't had a chance to test but I'm planning to soon. I'm already using componentWillMount() for data fetching with redux actions which return Promises, so this ought to fit in nicely. Also curious to see how render performance compares to using undom.

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

No branches or pull requests

5 participants