Open6

Next.jsでrewritesを使うと全く関係ないページで再レンダリングが発生する

ピン留めされたアイテム
catnosecatnose

【まとめ】Next.jsのrewritesは気軽に使わない方が良いかも

  • 一つでもrewrites設定が書かれているとクライアントサイド含め色々な処理が発生する
  • rewrites設定が一つでも書かれていると、rewritesのパターンにマッチしないページであっても再レンダリングが発生することがある
  • JSのバンドルサイズが大きくなる可能性あり(next@v11.0.2 でrewriteをひとつ記述しnext buildしたところmain.jsが約4KB増えた)
catnosecatnose

Next.js v11.1.2で確認

next.config.jsでrewritesを指定すると、一部のページで初回読み込み時に再レンダリングが発生することが分かった。

  rewrites() {
    return {
      afterFiles: [
        // ここで設定する値に関わらず問題が発生する
        {
          source: '/anypath',
          destination: '/anypath/foo',
        },
      ],
    };
  },

例えば_app.tsxコンポーネントの中でconsole.log("rendered")とか書いて、ブラウザをリロードするとrenderedが2回出力される。rewrites設定にマッチしない場合であってもページも再レンダリングされてしまうケースがある。

rewrites設定を空にするとレンダリングは1度だけになる。

Issueなどなどを探してもこの問題についての記述がまったく見つからなかったので調査内容をメモっていく。

catnosecatnose

該当箇所を見つけた

https://github.com/ijjk/next.js/blob/5566195ca3d278e5d59d288210f7c44838943ea5/packages/next/client/index.tsx#L187-L233

next/client/index.tsx
    // We need to replace the router state if:
    // - the page was (auto) exported and has a query string or search (hash)
    // - it was auto exported and is a dynamic route (to provide params)
    // - if it is a client-side skeleton (fallback render)
    if (
      router.isSsr &&
      // We don't update for 404 requests as this can modify
      // the asPath unexpectedly e.g. adding basePath when
      // it wasn't originally present
      page !== '/404' &&
      !(
        page === '/_error' &&
        hydrateProps &&
        hydrateProps.pageProps &&
        hydrateProps.pageProps.statusCode === 404
      ) &&
      (isFallback ||
        (data.nextExport &&
          (isDynamicRoute(router.pathname) ||
            location.search ||
            process.env.__NEXT_HAS_REWRITES)) ||
        (hydrateProps &&
          hydrateProps.__N_SSG &&
          (location.search || process.env.__NEXT_HAS_REWRITES)))
    ) {
      // update query on mount for exported pages
      router.replace(
        router.pathname +
          '?' +
          String(
            querystring.assign(
              querystring.urlQueryToSearchParams(router.query),
              new URLSearchParams(location.search)
            )
          ),
        asPath,
        ...

rewritesが指定されていると、このrouter.replace()が不要な場合にまで発火してしまっているのだと思われる。

問題が発生する原因となったPRはこちら。この変更により「rewrite時にクエリパラメータが適切に取得できない」という別の問題が解消されている。
https://github.com/vercel/next.js/pull/24189

catnosecatnose

再レンダリングが発生するときには次の条件がtrueになってしまっていることが分かった。

 (data.nextExport &&
          (isDynamicRoute(router.pathname) ||
            location.search ||
            process.env.__NEXT_HAS_REWRITES))
  • data.nextExportgetInitialPropsgetStaticPropsgetServerSidePropsなどを持たないページでtrueになる。
  • process.env.__NEXT_HAS_REWRITESは何らかのrewrites設定を書くとtrueになる

つまりrewritesを書くとget**Propsを持たないページすべてが再レンダリングされることになる。

catnosecatnose

思いのほか解決が難しそうな問題だった

Next.jsのコードを眺めていて分かってきたこと

  • Next.jsでは一つでもrewrites設定が書かれていると、グローバルに参照されるprocess.env.__NEXT_HAS_REWRITEStrueとなる(ちなみにクライアントサイドだと別名の変数になっている)
  • この値がtrueだと後続の処理でリライトに関わる処理が呼ばれる
    • 上述のrouter.replace()もその一つ(URLクエリパラメータを適切に取得するために必要)
    • もしかすると他にもこのようなワークアラウンド的な処理が書かれている or 今後書かれるかもしれない
    • 一つでもrewrites設定が書かれていると、余計な処理が入る or 余計なJSファイルが読み込まれる可能性がある
  • CDNにキャッシュされる静的なページでは、ページデータがCDNから直接配信されるため、オリジンサーバーでリライトの処理を噛ませることができない → クライアント側でリライトの処理を行うしかない
    • 動的ルーティングのページもリライトの対象とすることが可能であるため、リライトのパスごとにあらかじめキャッシュを作るようなことは難しい

Next.jsリポジトリにIssueをあげようと思ったが、構造的に解決が難しそうであることが分かったため見送ることにした。おそらくNext.jsの開発チームの人たちもこの問題は認識しているのではないかな。

rewriteを一つでも書くとbundle sizeが大きくなる

next build + next startで動かしてみると、やっぱりbundle sizeに影響があった。

↓ rewrites設定を一つだけ書いた場合(どのページにもマッチしないrewrites設定にした)
rewriteを一つだけ書いた場合

↓ rewrites設定を消した場合(それ以外は全く同じ)
rewriteなし

v11.0.2では約4KB程の違いが出た。

Vercelにデプロイした場合にはもしかすると違う結果になるかも