Suspense for Data Fetch の SSR時のデータローディングを先取りして実装する

この記事は react-apollo-hooks から apollo(graphql) 非依存の部分を理解して抜き出した記事です。


## 追記

動いてる実装は https://github.com/mizchi-sandbox/ssr-data-fetch にあります。

## さらに追記

https://github.com/mizchi/ssr-helpers ライブラリに切り出したので `@mizchi/ssr-heplers` でインストールできます。

```tsx
import { renderAsync, createResource } from "@mizchi/ssr-helpers";

// data fetcher
const resource = createResource(async () => {
  await new Promise(r => setTimeout(r, 1000));
  return { message: "hello" };
});

function App() {
  const data = resource.read();
  return <div>{data.message}</div>;
}

const html = await renderAsync({
  tree: <App />
});
```

## 何がしたいか

next.js の`getInitialProps` のように、SSR時にデータを解決する仕組みを自分で実装します。

## React for Data Fetch

React.lazy と React.Suspense による Component の非同期ロードは React v16.6 でサポートされましたが、将来的に 非同期データロードも Suspense でサポートされる予定です。

現在の実装中のAPI案はこんな感じになります。

```tsx
import { createResource } from 'react-cache';
const resource = createResource(...);

function Greeting() {
  const data = resource.read();
  return <div>{data.message}</div>
}

<Suspense fallback="loading...">
  <Greeting />
</Suspense>
```

内部的には、 キャッシュを持たない時の `resource.read()` は Promise を throw し、その間 Suspense fallback 側のものが表示されます。

ただし、前提として、Facebook の SSR サポートの優先度がものすごく低いです。現在の React は SSR で ErrorBoundary をサポートしておらず、 ErrorBoundary で実装されている Suspense が ReactDOMServer で実行できません。

非同期なデータロードは mid 2019, その SSR は年内に動いたら、みたいなロードマップです。

https://reactjs.org/blog/2018/11/27/react-16-roadmap.html

というわけでこの react-cache を真似た実装を行います。

(これは react-apollo-hooks からそのまま実装しました) 

```tsx:renderAsync.tsx
// Extract from https://github.com/trojanowski/react-apollo-hooks
import React from "react";

const SSRContext = React.createContext<null | SSRManager>(null);

interface SSRManager {
  hasPromises(): boolean;
  register(promise: PromiseLike<any>): void;
  consumeAndAwaitPromises(): Promise<any>;
}

function createSSRManager(): SSRManager {
  const promiseSet = new Set<PromiseLike<any>>();

  return {
    hasPromises: () => promiseSet.size > 0,
    register: promise => {
      promiseSet.add(promise);
    },
    consumeAndAwaitPromises: () => {
      const promises = Array.from(promiseSet);
      promiseSet.clear();
      return Promise.all(promises);
    }
  };
}

interface GetMarkupFromTreeOptions {
  tree: React.ReactNode;
  onBeforeRender?: () => any;
  renderFunction: (tree: React.ReactElement<object>) => string;
}

export function renderAsync({
  tree,
  onBeforeRender,
  renderFunction
}: GetMarkupFromTreeOptions): Promise<string> {
  const ssrManager = createSSRManager();

  function process(): string | Promise<string> {
    try {
      if (onBeforeRender) {
        onBeforeRender();
      }
      const html = renderFunction(
        <SSRContext.Provider value={ssrManager}>{tree}</SSRContext.Provider>
      );

      if (!ssrManager.hasPromises()) {
        return html;
      }
    } catch (e) {
      if (!(e instanceof Promise)) {
        throw e;
      }

      ssrManager.register(e);
    }

    return ssrManager.consumeAndAwaitPromises().then(process);
  }

  return Promise.resolve().then(process);
}
```

(これはあとでライブラリに切り出すかも)

仕組みとしては 内部的に `throw new Promise(...)` されており、この throw された Promise が解決されるまで延々と render し続けます。

パフォーマンスを事前に意識しないと複数回レンダリングのパフォーマンスはかなり悪化する可能性があります。とくに末端に近い Footer のような領域で throw するとほとんどのCPU処理は無駄になります。

これは許容し難いパフォーマンス問題を引き落とすかもしれません。

## createResource の実装

react-cache を参考に実装します。

```tsx
export function createResource<T>(loader: (key: string) => T) {
  const cache = new Map();
  const load = (key: string) =>
    new Promise(async (resolve, _reject) => {
      const data = await loader(key);
      cache.set(key, data);
      resolve(data);
    });
  return {
    async preload(key: string) {
      if (cache.has(key)) {
        return cache.get(key);
      } else {
        return load(key);
      }
    },
    read(key: string) {
      if (cache.has(key)) {
        return cache.get(key);
      } else {
        throw load(key);
      }
    }
  };
}
```

read 時は Suspense 規約に従って、Promise を throw します。

これはほぼ現在の提案通りの実装ですが、本家的にはキャッシュパージの仕様とかどうしようか？みたいな議論があるので、おそらく変わると思われます。

## サーバーサイドでのSSR

(後の最適化のために react-router-config を使って実装しています)

```tsx
import React, { Suspense } from "react";
import ReactDOMServer from "react-dom/server";
import { renderRoutes, matchRoutes, MatchedRoute } from "react-router-config";
import { StaticRouter } from "react-router-dom";

import { renderAsync, createResource } from "./renderAsync";

// data fetcher
const resource = createResource(async (_key: string) => {
  await new Promise(r => setTimeout(r, 1000));
  return { message: "hello" };
});

function Home() {
  return <div>Home</div>;
}

function Foo() {
  const data = resource.read("/foo");
  return (
    <div>
      Foo
      <pre>
        <code>{JSON.stringify(data)}</code>
      </pre>
    </div>
  );
}

const routes = [
  {
    component: Home,
    exact: true,
    path: "/"
  },
  {
    component: Foo,
    exact: true,
    path: "/foo"
  }
];

function App() {
  return <>{renderRoutes(routes)}</>;
}

async function renderHtml(location: "/foo") {
  const renderedHtml = await renderAsync({
    renderFunction: ReactDOMServer.renderToString,
    tree: (
      <StaticRouter location={location}>
        <App />
      </StaticRouter>
    )
  });
  return renderedHtml;
}
```

この renderHtml 関数を実装すると、HTML の文字列を生成します。 具体的な SSR 引き継ぎは略。

## クライアントでの hydrate

```tsx
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";

const root = document.querySelector(".root") as HTMLDivElement;

async function main() {
  ReactDOM.hydrate(
    <BrowserRouter>
      <Suspense fallback="loading">
        <App />
      </Suspense>
    </BrowserRouter>,
    root
  );
}
main();
```

BrowserRouter と Suspense で囲って実行します。これで初回SSR以外のCSRに対応します。


## 先読みでのパフォーマンスを解決する

renderAsync で実装していた onBeforeRender と、 react-router-config を組み合わせて、その画面のルート要素だけ shallow な render を試みてから実際の render に処理を回すことにします。

```tsx
import { renderRoutes, matchRoutes, MatchedRoute } from "react-router-config";
import ShallowRenderer from "react-test-renderer/shallow";
const shallowRenderer = ShallowRenderer.createRenderer();

// ...

function preloadRouteAction(matches: MatchedRoute<any>[]): Promise<any> | null {
  let promises: Promise<any>[] = [];
  matches.forEach(m => {
    const C = m.route.component as React.ComponentType;
    try {
      shallowRenderer.render(<C {...m as any} />);
    } catch (err) {
      if (err instanceof Promise) {
        promises.push(err);
      }
    }
  });
  if (promises.length > 0) {
    return Promise.all(promises);
  }
  return null;
}

async function renderHtml(location: "/foo") {
  let once = false;
  const renderedHtml = await renderAsync({
    renderFunction: ReactDOMServer.renderToString,
    onBeforeRender() {
      // load once
      if (once) {
        return;
      }
      once = true;
      const matches = matchRoutes(routes, "/foo");
      const promise = preloadRouteAction(matches);
      if (promise) {
        throw promise;
      }
    },
    tree: (
      <StaticRouter location={location}>
        <App />
      </StaticRouter>
    )
  });
  return renderedHtml;
}
```

`preloadRouteAction` を実行することで、 ReactRouter の Route 要素は少なくとも render 前に shallowRenderer を行って、非同期処理をかき集めます。

Footer や Header で専用の処理がある場合、この無限に再帰して、巻き戻る処理が多少緩和されるでしょう。できれば0回にしたいところです。

つまりは自分でアプリケーション側に制約を化して自明な箇所をロードしてしまう、という感じです。

## おわり

将来的にいらなくなる予定ですが、来年まで待たないといけないのと、今のセマンティクスのままでも少なくとも2回 render しないといけない、もしくは ErrorBoundary まで遡ってリトライする、といった感じになるのは避けられないです。

なので、自力で変更に追従できる人は、自力で実装する価値は大きいのではないでしょうか。