💿

Streaming APIに対応したRemix 1.11のリリースノートを流し読む

2023/01/19に公開1

Remix の v1.11 が出ました。適当に感想を書きます。

https://github.com/remix-run/remix/releases/tag/remix%401.11.0

クライアント側で完了を待つ defer サポート

React 18 には Streaming という機能があるんですってね。

https://zenn.dev/shinbey/articles/8acce14b8b60e3

「ファーストビューの表示には間に合わなくてもいいから、ちょっと遅れてでも表示したい」みたいなデータを、サーバー側で用意しつつ、クライアント側では <Suspense> で待っててもらい、データの用意が出来次第サーバーからクライアントに流し込んで表示、みたいなユースケースで役立つ技術だと思っています。しらんけど。

何ができるようになるかというと、 loader() 内で「別にこれ初回表示に間に合わなくてもいいな」というデータを、次のように Promise が完了しないまま return できます。その際、いつもの json() ではなく defer() で囲む必要があります( JSON.stringify(promise) になっちゃうのも気持ち悪いので納得感はある)。

import { defer } from "@remix-run/node";

export async function loader({ request }) {
  // こっちはawaitしているので、取得済みの実データが変数に入る
  let productInfo = await getProductInfo(request);
  // こっちはPromiseの完了を待たずに、Promiseオブジェクトのまま変数に入れている
  let productReviewsPromise = getProductReviews(request);

  // Promise状態のデータをクライアントに流し込むためのおまじない
  return defer({
    productInfo, // loader()の実行完了までにデータが出揃うので、ファーストビューのHTMLに埋め込まれるし、useLoaderData()からは実データが出てくる
    productReviewsPromise, // この時点でデータが取得できていないので、HTMLには埋め込まれないし、useLoaderData()からもPromiseオブジェクトが出てくる
  });
}

で、 defer 経由でコンポーネントに渡された Promise は、 useLoaderData() で取り出すと、やはり Promise のままになっています。この Promise の結果を <Suspense> で待って UI に表示するのが次の例です。

import { Await } from "@remix-run/react";

function ProductRoute() {
  let {
    // loader()時点でデータ取得が終わっているので、この時点で必ずデータが存在する
    productInfo,
    // Promiseのまま届くので、初期表示のこの時点ではまだデータがないかもしれない
    productReviewsPromise,
  } = useLoaderData();

  return (
    <div>
      <h1>{productInfo.name}</h1>
      <p>{productInfo.description}</p>
      <BuyNowButton productId={productInfo.id} />
      <hr />
      <h2>Reviews</h2>
      {/* productReviewsPromiseの完了を待つためのSuspense */}
      <React.Suspense fallback={<p>Loading reviews...</p>}>
        {/* RemixのAwaitコンポーネントを使うと、データ取得の完了を待ってからUIを表示できる */}
        <Await resolve={productReviewsPromise} errorElement={<ReviewsError />}>
          {(productReviews) =>
            productReviews.map((review) => (
              <div key={review.id}>
                <h3>{review.title}</h3>
                <p>{review.body}</p>
              </div>
            ))
          }
        </Await>
      </React.Suspense>
    </div>
  );
}

// Promiseのエラーハンドリング
function ReviewsError() {
  let error = useAsyncError(); // エラー内容を取り出す
  return <p>There was an error loading reviews: {error.message}</p>;
}

この場面で、Suspense をハンドリングする役割を担うのが、Remix の <Await> コンポーネントです。Promise の完了を待って、成功すれば children に結果データを渡して表示しますし、失敗すれば errorElement 属性に設定したコンポーネントでエラー表示を行います。

まあまあ直感的に見えるので、忘れそうになりますが、これは「 getProductReviews(request) が発行したデータ取得処理の Promise がブラウザに渡されてきて Suspense で完了を待てる機能」ではないはずです。サーバー側で生成した Promise は内部でサーバー側のマシンリソースを抱えている非同期処理の管理オブジェクトなので、そもそもシリアライズして他所に送ることもできません。

ざっくりこんな感じの役割分担をしてそうだなと思いました。

  • サーバー側: getProductReviews(request) が発行した Promise の完了を待って、クライアントに結果を通知する( defer の責務)
  • ブラウザ側:getProductReviews(request) のことは全く知らないが、後でサーバーから Streaming API 経由で何らかのデータが送り込まれてくるらしいので待ってる

ブラウザ側はあくまでも、 loader() と似たような形で後追いで送られてくるデータを Promise で待っているだけで、 getProductReviews(request) 由来の Promise を持っているわけではない、という感じですかね。

まあソースコードや公式ドキュメント読んだら詳しいところもわかると思うのですが、僕が読むのは面倒なので気になった方は読んでみてください(僕が間違ったことを言っている可能性もあります)。

まあでも、直感的には「 getProductReviews(request) が発行したデータ取得処理の Promise がブラウザに渡されてきて Suspense で完了を待てる機能」に見えるので、書き味は良さそうですよね。上記のことを意識せずに乱暴なコードを書くと破綻しそうですが、意識して書く分には便利に使えそう。

React Router からの移植機能

ちなみにこの機能、React Router で先行実装されていた defer<Await> の Remix 移植版だったりします。

https://zenn.dev/azukiazusa/articles/react-router-delay-loader

React Router では処理系を跨がない(ブラウザ内で全て完結する)ため、実装しやすかったのでしょうね。React Router をインターフェースの叩き台として利用して、ある程度固まったところで、処理系をまたぐ(Node.js の処理完了をブラウザで待つ)Remix 版の実装に入ったのだと思います。

面白い機能だったので、待ってました!という感じです。

CSS のバンドルをサポート(unstable)

これまだよくわかってないんですけど、 import { cssBundleHref } from "@remix-run/css-bundle";cssBundleHreflinks に登録しておくといい感じになるらしい。

でもサンプルを見ても cssBundleHreflinks に登録してないみたいで「よくわからんな?」という顔になっています。

Remix は UI ライブラリ対応がちょっと雑め(tailwind 以外は大なり小なり問題が起きがち)なので、こっち方向の改善はどんどん進めていってほしいです。

Remix v2 の route 定義(Flat Routes)の先行公開

v2 からは、新しい形式の route 定義方法である、Flat Routes が使えるようになります。あくまでも追加仕様なので、従来のディレクトリを掘り下げるやり方も継続して使えます。

細かいところは ↓ の RFC を見てほしいです。

https://github.com/remix-run/remix/discussions/4482

ざっくりと言うと、

  • routes/auth/github/callback.tsx

みたいに書いていた route を、

  • routes/auth/github.callback.tsx

のように、ドット区切りで書いても、どちらも /auth/github/callback という URL を表現する定義になります。

つまり、

  • routes/
    • auth/
      • github/
        • callback.tsx
      • twitter/
        • callback.tsx
      • google/
        • callback.tsx

のように書いていた階層を、

  • routes/
    • auth/
      • github.callback.tsx
      • twitter.callback.tsx
      • google.callback.tsx

と書き換えることができます。たぶん(実験してない)。

URL の設計にソースコードの階層が引きずられることで、見通しが悪くなることがありましたが、これでスッキリしますね。

サンプルでたまに見かけてた

ちょっと前にどこかのサンプルコードで上記のようなファイル名を見かけていたのですが、Flat Routes だったんですねえ。手元で同じファイル名を試しても動かなかったので、不思議に思っていました。

まとめ

defer も Flat Routes も、開発体験をまあまあ良くしてくれそうなので、楽しみですね。

株式会社モニクル

Discussion

na2hirona2hiro

CSS のバンドリングについては、これまで生の(あるいはユーザが自力で生成した) CSS を import して links に渡すしかなかったのが、各種のバンドリング手法に対応してそのバンドル結果が cssBundleHref に渡されるということのようです。それを生のCSS同様に links に渡すようリリースノートには書いてありますが、個々のバンドリング技術については書いてなく確かに docs を読まないと掴みづらかったですね。

これまで route ごとのスタイルを書くだけなら足りていましたが、 CSS と紐付いたコンポーネントなどを扱いやすくなりそうで、私も今後の改善に期待しています。