preact-render-to-string icon indicating copy to clipboard operation
preact-render-to-string copied to clipboard

Improve async handling

Open BerndWessels opened this issue 7 years ago • 23 comments

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.

BerndWessels avatar Mar 22 '17 19:03 BerndWessels

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).

developit avatar Mar 23 '17 15:03 developit

@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();
  });
}

BerndWessels avatar Mar 23 '17 18:03 BerndWessels

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 avatar Mar 23 '17 21:03 developit

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

developit avatar Mar 23 '17 22:03 developit

@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?

BerndWessels avatar Mar 23 '17 22:03 BerndWessels

~~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 avatar Mar 24 '17 01:03 developit

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

developit avatar Mar 24 '17 01:03 developit

@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 avatar Mar 24 '17 01:03 BerndWessels

@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?

BerndWessels avatar Mar 24 '17 03:03 BerndWessels

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 :(

developit avatar Mar 24 '17 03:03 developit

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

BerndWessels avatar Mar 24 '17 03:03 BerndWessels

this is cool

ezekielchentnik avatar Mar 24 '17 03:03 ezekielchentnik

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();
  });
});

BerndWessels avatar Mar 26 '17 19:03 BerndWessels

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

developit avatar Mar 27 '17 00:03 developit

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

The complete repo is here.

BerndWessels avatar Mar 27 '17 00:03 BerndWessels

@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

Stanback avatar Mar 31 '17 22:03 Stanback

@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()

developit avatar Apr 03 '17 00:04 developit

@developit Good suggestion 👍 I've updated my gist

Stanback avatar Apr 03 '17 01:04 Stanback

@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.

BerndWessels avatar Apr 03 '17 01:04 BerndWessels

@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 https://github.com/developit/undom/issues/7

Stanback avatar Apr 03 '17 04:04 Stanback

@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.

developit avatar Apr 03 '17 12:04 developit

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.

Satyam avatar Apr 04 '17 15:04 Satyam

@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.

Stanback avatar Apr 04 '17 23:04 Stanback