CloudFlare Pages環境のRemixアプリのVite・React Router v7への移行
年末年始の季節にまたブログで盆栽をしていました。
今回は、Remix Viteへの移行とReact Router v7への移行、Streaming & Suspenseによるファーストビューの高速化を行いました。
CloudFlare Pagesで動かしているものの移行例はあまり無かったので、簡単に自分が思う要点や補足を書いておこうと思います。
Remix v2.7からViteがStableになりました。
公式ドキュメントなどもViteを前提として書かれていたりするので、移行することにしました。
基本的に、公式ドキュメントに従ってやるだけです。
要点やつまづきポイント、自分が以前入れていた対応の変更について書いておきます。
CloudFlare PagesでRemixを使っている場合、remix.config.js
ではserver
やserverBuildPath
などの設定がありましたが、不要になりました。
serverBuildPath
でCloudFlare Pages Functionsのためのファイルを出力していたと思いますが、Viteの場合は公式ドキュメントのCreate a catch-all route for Remix
にあるファイルをfunctions/
以下に用意してあげるだけでよくなります。
これが原因かは分かりませんが、wrangler pages deploy ./build/client
だけではデプロイに失敗するようになったので、wrangler.toml
へpages_build_output_dir = "./build/client"
の追記も行いました。
これまで、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
を使ったキャッシュ操作を行うように変更しました。
CloudFlare Workers環境では、globalにあるcaches
を用いてキャッシュ機構を利用することができます。
しかしながら、これはNode.jsの環境では異なるものなので、Viteではcaches
が無いというエラーが出てしまいます。
この辺りを吸収するのがgetPlatformProxy
を用いたcloudflareDevProxyVitePlugin
(React Router v7ではcloudflareDevProxy
)です。
AppLoadContextについて前項の対応を行うと、context.cloudflare
にcaches
プロパティが追加されるので、これを使うようにすれば良いと思います。
const cache = loadContext.cloudflare.caches.default
const value = await cache.match("cache-key")
loadContext.cloudflare.ctx.waitUntil(cache.put("cache-key", value))
remix.config.js
からvite.config.ts
への移行に伴い、remix.config.js
であったbrowserNodeBuiltinsPolyfill
とserverNodeBuiltinsPolyfill
の設定項目が無くなっています。
Classic Remix Compilerの場合は、browserNodeBuiltinsPolyfill
とserverNodeBuiltinsPolyfill
の設定を読んで、必要なPolyfillを自動で追加しておいてくれました。
Viteでは、自前でPolyfillなどを設定し、動くようにしてあげる必要があります。
このサイトでは、ブラウザーとサーバー (CloudFlare Workers) の両方でpath
を使っています。
サーバーの方については、nodejs_compat
フラグをwrangler.tomlに追加してあげればCloudFlare Workersでpath
を使えるようになっていました。
この際、compatibility_date
をnodejs_compat_v2
がNode.js互換性のデフォルトとなる2024-09-23
以降に設定しておくと良いでしょう。
ブラウザーのほうは少し一手間必要で、以下のissueのコメントの方法を取りました。
path-browserify
をインストールした上で、それを使わせる簡単なViteプラグインをvite.config.ts
に直接書いてあげることで、ブラウザーにpath
を用意することが出来ます。
以下の公式のRemixからの移行ガイドや、記事を参考に進めれば問題ないです。
codemodを使っても、書き換わっていない箇所があるので、ちゃんと動くか確認しておきましょう。
以下の項目は、補足やこのサイトで必要だった対応についてです。
installGlobals()
は不要にViteへの移行の際に対応した、vite.config.ts
でinstallGlobals()
を呼び出すのは必要無くなりました。
消しましょう😭
import type { AppLoadContext, EntryContext } from '@remix-run/cloudflare'
を
import type { AppLoadContext, EntryContext } from 'react-router'
に書き換える必要があります。
以下の手順の通り、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 {}
LoaderFunctionArgs
をRoute.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>;
}
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なのは怖いですね。
use
によるStreaming & SuspenseReact 19に上げたので、use
を使ったStreaming & Suspenseを試してみました。
このブログのトップページにおいて、記事一覧の部分についてはデータが取得でき次第描画するようにしました。
必ずしもファーストビューに必要でないデータについては、loader関数の返り値をPromiseにすることで、そのデータを使った描画については後からにでき、ファーストビューの高速化が可能です。
やり方については上記の公式ドキュメントを参照すれば簡単なので、ここではやる中でつまづいた点について書いておきます。
今回、このようなブログの記事でもトップページと同様に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するようなライブラリではよくある問題のようです。
そして、対処法としてクライアント側のみでレンダリングされるようにするという方法がよく採られるようです。
今回のケースはクライアントで正しくレンダリングできないコンポーネントが原因という点で、前述の解決策も取れないというなかなか珍しいケースかもしれません。
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
のキャッシュクリア機構が動かなくなってしまったのかもしれません。
一旦ビルドし直すたびにキャッシュを削除することで解決はしているものの、調査を行って原因を探りたいところです。
remarkRehype
とrehypeKatex
で型エラーが出る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への移行はserver
やserverBuildPath
などの面倒なところが無くなったり、より手厚いCloudFlareとのインテグレーションを意識されており、単純にViteを利用できる以上のメリットがあるように感じました。
React Router v7への移行については、loader関数周りの型サポートやstableなsingle_fetchなど良い点もあります。しかし、React Server Componentsへの対応のためRemixを再度ゼロから書き直しているということもあり、Reverb (RSC対応版のコードネーム) でまた大きなコード変更が必要になりそうなので、Reverbが出るまでRemixのまま使うのでも良いかもしれません。
Streaming & Suspenseについては、今回は完全に使ってみたかったのであまり必要ないけど入れたような感じでした。ユースケースをしっかりと認識して使っていく必要がある機能だとやはり感じました。最近のReactはuseOptimistic
などユーザー体験の向上に寄与する機能が多く、Next.jsも含めて""Webアプリケーション""向けの方向性を感じますね。