SSR memory leaks

I think that was it! Here is code that doesn’t seem to leak (so far):

import { WebApp } from 'meteor/webapp'
import React from 'react'
import s2s from 'string-to-stream'
import sq from 'streamqueue'
import { StaticRouter } from 'react-router'
import { renderToString } from 'react-dom/server'
import { FastRender } from 'meteor/staringatlights:fast-render'
import { LoadableCaptureProvider, preloadAllLoadables } from 'meteor/npdev:react-loadable'
import { HelmetProvider } from 'react-helmet-async'
import { ServerStyleSheets, ThemeProvider } from '@material-ui/styles'
// import { green, red } from '@material-ui/core/colors'
import App from '/imports/App'
import theme from '/imports/ui/common/theme'
import { DataCaptureProvider } from 'meteor/npdev:collections'

h = React.createElement // eslint-disable-line

preloadAllLoadables().then(() => FastRender.onPageLoad(async sink => {
  const context = {}
  const helmetContext = {}
  let dataHandle = {}
  let loadableHandle = {}

  const sheets = new ServerStyleSheets()

  const app = <ThemeProvider theme={theme}>
    <HelmetProvider context={helmetContext}>
      <LoadableCaptureProvider handle={loadableHandle}>
        <DataCaptureProvider handle={dataHandle}>
          <StaticRouter location={sink.request.url} context={context}>
            <App />
          </StaticRouter>
        </DataCaptureProvider>
      </LoadableCaptureProvider>
    </HelmetProvider>
  </ThemeProvider>

  let html
  try {
    html = renderToString(sheets.collect(app))
  } catch (e) {
    console.error(e)
    WebApp.addHtmlAttributeHook(() => ({
      lang: 'en'
    }))
    return
  }

  let { helmet } = helmetContext
  const meta = helmet.meta.toString()
  meta && sink.appendToHead(meta + '\n')
  const title = helmet.title.toString()
  title && sink.appendToHead(title + '\n')
  const link = helmet.link.toString()
  link && sink.appendToHead(link + '\n')

  WebApp.addHtmlAttributeHook(() => {
    const attrs = Object.assign({
      lang: 'en',
      'xmlns:og': 'http://ogp.me/ns#'
    }, helmet?.htmlAttributes?.toComponent() || {})
    helmet = null
    return attrs
  })
  // :TODO: Figure out how to do helmet.bodyAttributes...

  const css = sheets.toString()
  sink.appendToHead(`<style id="jss-server-side">\n${css}\n</style>`)

  // :HACK: The meteor css bundle should come after the JSS output, so just move it manually
  sink.appendToHead('\n<script id="css-fixer">elm=document.getElementsByClassName("__meteor-css__")[0];elm.parentNode.appendChild(elm);elm=document.getElementById("css-fixer");elm.parentNode.removeChild(elm);delete elm</script>')

  const queuedStreams = sq(
    () => s2s(html),
    () => {
      html = null
      return s2s(loadableHandle.toScriptTag())
    },
    () => {
      loadableHandle = null
      return s2s(dataHandle.toScriptTag())
    },
    () => {
      dataHandle = null
      return s2s('')
    }
  )
  sink.renderIntoElementById('root', queuedStreams)
}))

I am getting some sync errors on hydration, but it’s no longer leaking! I’ll investigate sync errors later.

1 Like

Update, this didn’t solve the memory leak completely, but it did slow it way down! I’ll keep looking through the code for similar places to improve, and report back if I find anything useful to share.

3 Likes

for reference, here is a (somewhat messy) ssr code snipped we use (unaltered and undocummented). It also uses a page cache in mongodb:

import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { FastRender } from 'meteor/staringatlights:fast-render';
import { InjectData } from 'meteor/staringatlights:inject-data';
import { Random } from 'meteor/random';
import { Meteor } from 'meteor/meteor';
import moment from 'moment';
import { renderToString } from 'react-dom/server';
import React from 'react';
import Radium from 'radium';
import { Helmet } from 'react-helmet';
import Loadable from 'react-loadable';
import { PageNotFoundError } from '/imports/api/errors';
import { PageCache } from '/imports/api/collections';

import app from '/imports/modules/app';

InjectData.disableInjection = true;
const RadiumWrapper = Radium(
  class extends React.Component {
    /* eslint react/destructuring-assignment: 0 */
    render() {
      return this.props.children;
    }
  }
);

app.init();

const send404 = sink => {
  sink.setStatusCode(404);
};

const renderTheRoute = ({ sink, Component, props, matchinRoute }) => {
  // we need to keep the random seed accress cached pages
  // as the random seed has only visual implications, that's not a big problem
  // result is that every page with the same random seed looks the same
  const randomSeed = Random.id();
  const modules = [];
  const modulesResolved = [];
  const { url, query: queryParams } = sink.request;
  const { pathname: path } = url;
  const { route, params } = matchinRoute;

  FlowRouter.setCurrent({
    path,
    params,
    route,
    queryParams,
  });

  const appString = renderToString(
    <Loadable.Capture
      report={moduleName => {
        modules.push(moduleName);
      }}
      reportResolved={resolvedModuleName => {
        modulesResolved.push(resolvedModuleName);
      }}
    >
      <RadiumWrapper radiumConfig={{ userAgent: 'all' }}>
        <Component {...props} randomSeed={randomSeed} />
      </RadiumWrapper>
    </Loadable.Capture>
  );

  const helmet = Helmet.renderStatic();
  const headParts = [
    helmet.title.toString(),
    helmet.meta.toString(),
    helmet.link.toString(),
  ];

  // see https://github.com/abecks/meteor-fast-render/issues/23
  FastRender._mergeFrData(sink.request, FastRender.frContext.get().getData());
  const fastRenderData = sink.request.headers._injectPayload;
  const bodyParts = [
    `<script> var __randomSeed = "${randomSeed}";</script>`,
    `<script> var __preloadables__ = ${JSON.stringify(
      modulesResolved
    )};</script>`,
    `<script type="text/inject-data">${InjectData.encode(
      fastRenderData
    )}</script>`,
  ];

  return { bodyParts, headParts, appString };
};

const getCacheKey = sink => `${sink.request.url.path}`;

const sendMatchingRoute = ({ matchinRoute, sink, path, queryParams }) => {
  const requestId = Random.id();
  const { route, params } = matchinRoute;
  if (route.options?.triggersEnter?.length > 0) {
    // this is not totally correct as we should respect triggers stop and redirect
    // we use it only for i18n route, so for us its fine i guess
    route.options.triggersEnter.forEach(trigger =>
      trigger({ ...route, params }, () => null, () => null)
    );
  }

  route.render = function(Component, props) {
    // currently disabled
    const useCache = !process.env.CACHE_DISABLED && !Meteor.userId();
    if (useCache) {
      const cacheKey = getCacheKey(sink);

      if (
        !PageCache.findOne({
          _id: cacheKey,
          updatedAt: {
            $gte: moment()
              .subtract(5, 'minutes')
              .toDate(),
          },
        })
      ) {
        const doc = {
          _id: cacheKey,
          updatedAt: new Date(),
          ...renderTheRoute({
            Component,
            props,
            sink,
            matchinRoute,
          }),
        };
        console.log('cache update');
        PageCache.upsert(cacheKey, { $set: doc });
      } else {
        console.log('cache hit');
      }
      const { headParts, bodyParts, appString } = PageCache.findOne(cacheKey);

      headParts.forEach(part => sink.appendToHead(part));
      bodyParts.forEach(part => sink.appendToBody(part));
      sink.renderIntoElementById('react-root', appString);
    } else {
      console.log('no cache will be used (logged in)');
      const { headParts, bodyParts, appString } = renderTheRoute({
        Component,
        props,
        sink,
        matchinRoute,
      });
      headParts.forEach(part => sink.appendToHead(part));
      bodyParts.forEach(part => sink.appendToBody(part));
      sink.renderIntoElementById('react-root', appString);
    }
  };

  const data = route.data ? route.data(params, queryParams) : null;

  console.log('================');
  console.log(`render-${requestId}, ${path}`);
  console.time(`rendertime-${requestId}--${path}`);

  try {
    route.action(params, queryParams, data, { sink });
  } catch (e) {
    if (e instanceof PageNotFoundError) {
      send404(sink);
    } else {
      console.error('server render failed', e);
    }
  }
  console.timeEnd(`rendertime-${requestId}--${path}`);
  console.log('================');
};

Loadable.preloadAll().then(() => {
  FastRender.onPageLoad(async sink => {
    const { url, query: queryParams } = sink.request;
    const { pathname: path } = url;

    if (path && path.startsWith('/__')) {
      // skip internal endpoints like __cordova
      // see https://github.com/meteor/meteor/issues/10557
      return;
    }
    const matchinRoute = FlowRouter.matchPath(path);
    if (matchinRoute) {
      sendMatchingRoute({
        matchinRoute,
        sink,
        path,
        queryParams,
      });
    } else {
      send404(sink);
    }
  });
});

1 Like