LOST IN BLUE

2025/01/08

CloudFlare Pages環境のRemixアプリのVite・React Router v7への移行

年末年始の季節にまたブログで盆栽をしていました。
今回は、Remix Viteへの移行とReact Router v7への移行、Streaming & Suspenseによるファーストビューの高速化を行いました。

CloudFlare Pagesで動かしているものの移行例はあまり無かったので、簡単に自分が思う要点や補足を書いておこうと思います。

Remix Viteへの移行

Remix v2.7からViteがStableになりました。
公式ドキュメントなどもViteを前提として書かれていたりするので、移行することにしました。

基本的に、公式ドキュメントに従ってやるだけです。
要点やつまづきポイント、自分が以前入れていた対応の変更について書いておきます。

ビルド出力周りについて

CloudFlare PagesでRemixを使っている場合、remix.config.jsではserverserverBuildPathなどの設定がありましたが、不要になりました。
serverBuildPathでCloudFlare Pages Functionsのためのファイルを出力していたと思いますが、Viteの場合は公式ドキュメントCreate a catch-all route for Remixにあるファイルをfunctions/以下に用意してあげるだけでよくなります。
これが原因かは分かりませんが、wrangler pages deploy ./build/clientだけではデプロイに失敗するようになったので、wrangler.tomlpages_build_output_dir = "./build/client"の追記も行いました。

AppLoadContext周りについて

これまで、remix.env.d.tsにd1やR2へアクセスするインターフェイスを定義していましたが、Vite Remixではload-context.tsを使うようになりました。
ちなみに後述しますが、React Router v7ではまた別のファイルを使うのがおすすめになっています。

また、このブログではCacheのためにAppLoadContextを拡張してEventContextを渡していましたが、Vite RemixからはCloudFlare環境でのAppLoadContextの定義を提供してくれるようになります。
また、wrangler typegenを実行することで、そのプロジェクトに対応するEnvの.d.tsを自動で生成することができ、なおかつそれを読み込んでくれるようになっています。

そして、上記の方法で用意したAppLoadContextにはExecutionContextであるcontext.cloudflare.ctxがあります。
これを使ってwaitUntilを使ったキャッシュ操作を行うように変更しました。

Cacheについて

CloudFlare Workers環境では、globalにあるcachesを用いてキャッシュ機構を利用することができます
しかしながら、これはNode.jsの環境では異なるものなので、Viteではcachesが無いというエラーが出てしまいます。

この辺りを吸収するのがgetPlatformProxyを用いたcloudflareDevProxyVitePlugin (React Router v7ではcloudflareDevProxy)です。

AppLoadContextについて前項の対応を行うと、context.cloudflarecachesプロパティが追加されるので、これを使うようにすれば良いと思います。

const cache = loadContext.cloudflare.caches.default
const value = await cache.match("cache-key")
loadContext.cloudflare.ctx.waitUntil(cache.put("cache-key", value))

必要なPolyfillの追加

remix.config.jsからvite.config.tsへの移行に伴い、remix.config.jsであったbrowserNodeBuiltinsPolyfillserverNodeBuiltinsPolyfillの設定項目が無くなっています。
Classic Remix Compilerの場合は、browserNodeBuiltinsPolyfillserverNodeBuiltinsPolyfillの設定を読んで、必要なPolyfillを自動で追加しておいてくれました。
Viteでは、自前でPolyfillなどを設定し、動くようにしてあげる必要があります。

このサイトでは、ブラウザーとサーバー (CloudFlare Workers) の両方でpathを使っています。
サーバーの方については、nodejs_compatフラグをwrangler.tomlに追加してあげればCloudFlare Workersでpathを使えるようになっていました。
この際、compatibility_datenodejs_compat_v2がNode.js互換性のデフォルトとなる2024-09-23以降に設定しておくと良いでしょう。

ブラウザーのほうは少し一手間必要で、以下のissueのコメントの方法を取りました。
path-browserifyをインストールした上で、それを使わせる簡単なViteプラグインをvite.config.tsに直接書いてあげることで、ブラウザーにpathを用意することが出来ます。

React Router v7への移行

以下の公式のRemixからの移行ガイドや、記事を参考に進めれば問題ないです。
codemodを使っても、書き換わっていない箇所があるので、ちゃんと動くか確認しておきましょう。

以下の項目は、補足やこのサイトで必要だった対応についてです。

installGlobals()は不要に

Viteへの移行の際に対応した、vite.config.tsinstallGlobals()を呼び出すのは必要無くなりました。
消しましょう😭

entry.server.tsxのimportの置き換え

import type { AppLoadContext, EntryContext } from '@remix-run/cloudflare'
import type { AppLoadContext, EntryContext } from 'react-router'
に書き換える必要があります。

AppLoadContext周り

以下の手順の通り、AppLoadContextなどの型定義はapp/env.tsに書くのがおすすめとなったようです。
そのままでもたぶん動きますが、Vite対応で追加したload-context.tsは不要になりました😭

load-context.tsと同様にapp/env.tsを書きました。
またEnvについてはwrangler typegenで生成されるd.tsを自動で読んでくれますので、ここで定義する必要はありません。

import { type PlatformProxy } from 'wrangler'

type Cloudflare = Omit<PlatformProxy<Env>, 'dispose'>

declare module 'react-router' {
  interface AppLoadContext {
    cloudflare: Cloudflare
  }
}

export {}

LoaderFunctionArgsRoute.LoaderArgsへ置き換え

公式の移行ガイドにある手順の通り、React Router v7からは各ルートの型を生成するようになっています。

RemixスタイルのRoute設定 (flatRoutes)でも使えるので、今後はRoute.LoaderArgsを使うようにしましょう。
上記のドキュメントの通り、useLoaderDataも今後は不要で、Componentのpropsとしてloader関数の返り値を用いることができるようになります。
以下のコードは上記のドキュメントの例に日本語でコメントを追加したものです。

import type { Route } from "./+types/product";
// 生成されたルートの型

export function loader({ params }: Route.LoaderArgs) {
  //                     params: { id: string }の型が付いている
  return { planet: `world #${params.id}` };
}

export default function Component({
  loaderData, // loader関数の返り値である{ planet: string }の型が付いている
}: Route.ComponentProps) {
  return <h1>Hello, {loaderData.planet}!</h1>;
}

Resource Routesでのjson()の置き換え

Remixのフラグv3_singleFetchで有効にできるsingle-fetchのときから、loaderの返り値をjson()を使って返す必要は無くなりました。
しかし、Resource Routesの場合は通常のcurlなどからのアクセスするユースケースもあるため、従来どおりJSON Responseを返す必要がある場面も想定されます。

対応自体は簡単で、公式ドキュメントにあるとおり、json()Response.json()にするだけです。

meta()で型推論が効かないのでアノテーション

ドキュメントに書いてある例のように、引数無しのloader関数の場合は問題なく型推論が効くのですが、loader関数としてRoute.LoaderArgsを取るようなものを定義していると、なぜかmeta関数の引数に型推論が効かなくなります。
しょうがないのでasで一旦なんとかしました。

export const loader = async ({ context, params }: Route.LoaderArgs) => {
  const name = await context.cloudflare.env.NAME

  return {
    name: result as string,
  }
}

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  // dataがunknownになるので型アサーション
  const { name } = data as { name: string }

  return [
    { title: name },
  ]
}

prerenderを使うにあたっての注意

React Router v7では、SSGであるPre-Rendering機能が提供されています。
静的なページについてはprerenderの対象とすることで、より効率的なWebサイトにできるでしょう。

しかしながら、ビルドを行うNode.jsとエッジ環境でインポートされるファイルが異なることに起因する、下記の記事のようなエラーが報告されています。
各環境での対処法も合わせて以下の記事で紹介されていますので、確認しておきましょう。
(なんかPre-Rendering使うようにしたら理由がわからないエラーが出るなぁで頭を抱えていたので本当に助かりました)

ちなみにこのサイトでは完全な静的なページがなかった(CloudFlareの環境変数をrootで読んでるなど)ので、結局Pre-Renderingは使えていません😭

Error: Script startup exceeded CPU time limit.でデプロイに失敗

React Router v7に移行したのが原因かは分かりませんが、1度だけ上記のCPU時間超過でデプロイに失敗しました。
再度デプロイ実行したところ成功したので、偶然かもしれませんがflakyなのは怖いですね。

React 19へのアップデート & useによるStreaming & Suspense

React 19に上げたので、useを使ったStreaming & Suspenseを試してみました。
このブログのトップページにおいて、記事一覧の部分についてはデータが取得でき次第描画するようにしました。

必ずしもファーストビューに必要でないデータについては、loader関数の返り値をPromiseにすることで、そのデータを使った描画については後からにでき、ファーストビューの高速化が可能です。
やり方については上記の公式ドキュメントを参照すれば簡単なので、ここではやる中でつまづいた点について書いておきます。

Hydrationはサーバーでの描写とクライアントでの描写が一致する必要がある

今回、このようなブログの記事でもトップページと同様にSuspenseを入れようとしましたが、以下のようなエラーが出てしまいました。

Uncaught Error: Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used: ...

これは、サーバーとクライアントでのレンダリング結果が一致しない場合に発生するエラーです。
このブログの記事では、上記のようなリンクカードについてはサーバー側でOGP情報を取得することでCORSを回避しており、クライアント側で実行しようとするとCORSで失敗してしまいます。
考えてみれば当然ですが、描画を遅延させたコンポーネントについてはクライアントで描画が行われます。サーバーでもSusponse内のコンポーネントを描画する理由は謎ですが、今回のようにサーバーでしか描画が成功しないようなコンポーネントが含まれている場合はStreaming & Suspenseを使うことはできません。

このようなエラーはSSRするようなライブラリではよくある問題のようです。
そして、対処法としてクライアント側のみでレンダリングされるようにするという方法がよく採られるようです。
今回のケースはクライアントで正しくレンダリングできないコンポーネントが原因という点で、前述の解決策も取れないというなかなか珍しいケースかもしれません。

動作確認において、404でcssやjsが読み込まれない

wrangler pages dev ./build/clientによってローカルで動作確認を行うときに、なぜかcssやjsが読み込まれないという問題が発生しました。
すべてのパスで発生するという訳ではなく、発生するエントリと発生しないエントリがありました。

開発者ツールで確認してみると、古いjsファイルやcssファイルを読みに行こうとしており、<script type="module" async>の中にあるversionの値もビルドしたものと異なっていました。
はじめはReact RouterやViteを疑っていましたが、react-router devでは問題が無いことから、開発者ツールで詳細にNetworkでヘッダーを確認してみると、このシステムで入れているキャッシュ機構が機能してしまっていることが原因であることが分かりました。

このサイトではレンダリングの結果をキャッシュしており、アクセスされたパスに一致するキャッシュがあればそれを返すようにしているのですが、ビルドしたファイルが変わった場合は古いファイルを返してしまうことになるため、上記のような現象が起きます。

手動で.wrangler/state/v3/cache/defaultを削除することで解決しましたが、以下のissueの通りwrangler pages devはキャッシュを自動でクリアする仕様となっているようです。

これまで起きていなかった問題であるため、何らかの原因でwrangler pages devのキャッシュクリア機構が動かなくなってしまったのかもしれません。
一旦ビルドし直すたびにキャッシュを削除することで解決はしているものの、調査を行って原因を探りたいところです。

React 19にあげるとremarkRehyperehypeKatexで型エラーが出る

markdownを描画するためにunifiedとその周辺プラグインを使っているのですが、なぜか@types/react@types/react-dom^19.0.0に上げると、以下のようなコードでtypecheckが通らなくなりました。

unified()
  .use(remarkParse)
  .use(remarkMath)
  .use(remarkRehype)
  .use(rehypeKatex)
  .use(rehype2react)

型エラーとしては以下のissueのようなerror TS2769: No overload matches this call. Overload 1 of 3, ...です。

上記issueではバージョンアップなどで解決しているのですが、今回はなぜかReact 19の型定義を入れると発生するのでなかなか不思議です。

現在は一旦以下のように型アサーションを入れることで回避していますが、原因を探りたい & 解決したいところです。

unified()
  .use(remarkParse)
  .use(remarkMath)
  .use(remarkRehype as unknown as Plugin)
  .use(rehypeKatex as unknown as Plugin)
  .use(rehype2react)

まとめ

Remix Viteへの移行、RemixからReact Router v7への移行、Streaming & Suspenseの利用について主にハマったところを書いてみました。

Viteへの移行はserverserverBuildPathなどの面倒なところが無くなったり、より手厚いCloudFlareとのインテグレーションを意識されており、単純にViteを利用できる以上のメリットがあるように感じました。

React Router v7への移行については、loader関数周りの型サポートやstableなsingle_fetchなど良い点もあります。しかし、React Server Componentsへの対応のためRemixを再度ゼロから書き直しているということもあり、Reverb (RSC対応版のコードネーム) でまた大きなコード変更が必要になりそうなので、Reverbが出るまでRemixのまま使うのでも良いかもしれません。

Streaming & Suspenseについては、今回は完全に使ってみたかったのであまり必要ないけど入れたような感じでした。ユースケースをしっかりと認識して使っていく必要がある機能だとやはり感じました。最近のReactはuseOptimisticなどユーザー体験の向上に寄与する機能が多く、Next.jsも含めて""Webアプリケーション""向けの方向性を感じますね。