🤯

propsを1つ増やしただけなのにレスポンスサイズが5倍に増えてしまった

に公開

ある日、Vercelのダッシュボードを見ると以前はだいたい100KB前後だったページのレスポンスサイズが500KB前後に増えてしまっていました。
原因はサーバコンポーネントからクライアントコンポーネントに渡すpropsが巨大だったことでした。
SCからCCに渡したpropsはRSCペイロードという形で初回レスポンスのHTMLに含まれるためです。

分かってしまえば単純なことなのですが、当時Next.jsのバージョンアップなどの改修も同時に行なっていたために原因調査に手こずりました。

そんなわけで、レスポンスサイズが5倍になったときの調査に役に立つかもしれない(?)RSCペイロードの覗き方を簡潔にお伝えします。

RSCペイロードを覗く

Next.jsでRSCペイロードがどう扱われているのか、実際に覗いてみます。
以下の3つのファイルを用意して確かめます。

app/page.tsx
import ClientComponent from "./client-component";
import ServerComponent from "./server-component";

export default function Page() {
  return (
    <main>
      <ServerComponent />
      <ClientComponent />
    </main>
  );
}
app/server-component.tsx
export default function ServerComponent() {
  return <div>Hi there! I'm a Server Component!</div>;
}

app/client-component.tsx
"use client";

export default function ClientComponent() {
  return <div>Hi there! I'm a Client Component!</div>;
}

このファイルを置いた状態でNext.jsのサーバーを起動してトップページにアクセスします。
レスポンスのHTMLを眺めるとself.__next_f.pushというスクリプトを含んだscriptタグが含まれているのが分かります。
その中から1つ取り出してみました。

<script>
    self.__next_f.push([1, "28:D\"$29\"\n2d:D\"$2e\"\n2d:[\"$\",\"div\",null,{\"children\":\"Hi there! I'm a Server Component!\"},\"$2e\",\"$2f\",1]\n28:[\"$\",\"main\",null,{\"children\":[\"$2d\",[\"$\",\"$L31\",null,{},\"$29\",\"$30\",1]]},\"$29\",\"$2c\",1]\n36:D\"$37\"\n39:D\"$3a\"\n39:[\"$\",\"$L3c\",null,{\"promise\":\"$@3d\"},\"$3a\",\"$3b\",1]\n3f:D\"$40\"\n3f:null\n41:D\"$42\"\n45:D\"$46\"\n41:[[\"$\",\"$L44\",null,{\"children\":\"$L45\"},\"$42\",\"$43\",1],null]\n47:D\"$48\"\n4b:D\"$4c\"\n50:D\"$51\"\n4b:[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$4f\",null,{\"fallback\":null,\"children\":\"$L50\"},\"$4c\",\"$4e\",1]},\"$4c\",\"$4d\",1]\n47:[\"$\",\"$L4a\",null,{\"children\":\"$4b\"},\"$48\",\"$49\",1]\n52:[]\n"])
</script>

このスクリプトの文字列の部分がペイロードになっています。
このままでは見づらいですね。改行コードが多く含まれてるので改行を反映してフォーマットしてみます。

28:D"$29"
2d:D"$2e"
2d:["$","div",null,{"children":"Hi there! I'm a Server Component!"},"$2e","$2f",1]
28:["$","main",null,{"children":["$2d",["$","$L31",null,{},"$29","$30",1]]},"$29","$2c",1]
36:D"$37"
39:D"$3a"
39:["$","$L3c",null,{"promise":"$@3d"},"$3a","$3b",1]
3f:D"$40"
3f:null
41:D"$42"
45:D"$46"
41:[["$","$L44",null,{"children":"$L45"},"$42","$43",1],null]
47:D"$48"
4b:D"$4c"
50:D"$51"
4b:["$","div",null,{"hidden":true,"children":["$","$4f",null,{"fallback":null,"children":"$L50"},"$4c","$4e",1]},"$4c","$4d",1]
47:["$","$L4a",null,{"children":"$4b"},"$48","$49",1]
52:[]

よく分からないフォーマットですが、注目すべきは3行目と4行目です。

2d:["$","div",null,{"children":"Hi there! I'm a Server Component!"},"$2e","$2f",1]
28:["$","main",null,{"children":["$2d",["$","$L31",null,{},"$29","$30",1]]},"$29","$2c",1]

2dから始まる行はchildrenHi there! I'm a Server Component!とあるのでServerComponentのレンダリング結果のように見えます。
28から始まる行は"main"とあるのでPageのレンダリング結果のように見えます。childrenの配列の最初の要素が"$2d"となっていて上記のServerComponentを参照してそうな雰囲気もあります。

続けてapp/page.tsxを以下のように変えて再度ペイロードを覗いてみます。

app/page.tsx
import ClientComponent from "./client-component";
import ServerComponent from "./server-component";

export default function Page() {
  return (
    <main>
      <ServerComponent
        key="server"
        string="string"
        number={0}
        boolean={true}
        null={null}
        array={[1, "string", false]}
        object={{ key: "value" }}
      />
      <ClientComponent
        key="client"
        string="string"
        number={0}
        boolean={true}
        null={null}
        array={[1, "string", false]}
        object={{ key: "value" }}
      />
    </main>
  );
}

関連する箇所を抜粋したペイロードは以下のように変わります。

2d:["$","div","server",{"children":"Hi there! I'm a Server Component!"},"$2e","$2f",1]
28:["$","main",null,{"children":["$2d",["$","$L31","client",{"string":"string","number":0,"boolean":true,"_null":null,"array":[1,"string",false],"object":{"key":"value"}},"$29","$30",1]]},"$29","$2c",1]

2dから始まる行にはkeyに渡した"server"という文字列が含まれるようになりました。後ろのprops部分は変わっておらずServerComponentに渡したpropsは残っていないことが分かります。
これを踏まえると配列の2,3,4番目の要素が順番にコンポーネント名、key、propsとなっていそうです。
これはreact/jsx-runtimejsx関数に渡す引数と(順番は違いますが)同じ内容になっています。

28から始まる行を見てみます。
"client"という文字列が出てくるようになりました。ここがClientComponentっぽいです。
そして"client"という文字列の後ろを見てみるとClientComponentに渡したpropsがそのままここに出てきています!

SCからCCに渡したpropsはそのままレスポンスに含まれるということが分かりました。
つまり以下のようにSCからCCのpropsに巨大な値を渡してしまうと...?

import ClientComponent from "./client-component";

export default function Page() {
  return (
    <main>
      <ClientComponent
        superDuperLargeArray={Array.from({ length: 10000 ** 2 }, () =>
          Math.random()
        )}
      />
    </main>
  );
}

まとめ

RSCの登場でサーバーとクライアントの境界が曖昧になってきました。
今書いてるコードがサーバーだけで実行されるのか、クライアントでも実行されるのかを意識していないと思わぬ事故に繋がります。
今回起きたのは転送量の肥大化なので最悪でも請求額が膨れ上がるだけで済みますが(済みはしない)意図せず機密情報が漏れると取り返しのつかないことになります。taintObjectReferencetaintUniqueValueのようなAPIも実験的機能として利用できますが、結局これらの機能も正しく使わなければ意味がありません。

RSCペイロードを見ればCCに渡してるデータが分かるので、拡張機能などで大きすぎるpropsや機密情報の漏洩を検知して警告を出せそうではあります。
誰か作ってくれないかなぁ...

関連サイト

  • How to Optimize RSC Payload Size
    • RSCペイロードを削減する方法について書かれたVercelのガイドラインです。
  • RSC Parser
    • RSCペイロードをパースして依存関係などを見ることができます。
chot Inc. tech blog

Discussion