💤

Next.jsでなんとしてもPostponeしたい!

2024/09/26に公開

Reactには React.postpone() というexperimentalなAPIがある。これはサーバー上で呼び出すとSuspense境界をトリガーし、クライアントサイドレンダリングにスイッチする挙動を実現できるAPIで、一般的なClientOnlyコンポーネントや next/dynamic での ssr: false と近しい効果を得られる。

ただしClientOnlyなどがトップダウン的に配下のコンポーネントツリーをクライアントレンダリングにスイッチするのに対して、postpone APIはボトムアップ、宣言的にそれを行うという重要な違いがある。これはcomposition, colocationなどを踏まえた設計を実現できるかに関わるものである。

ところで postpone はexperimentalなAPIと最初に言ったようにこれはreact@experimentalにしか含まれない。一部ではApp Routerで使用できるという話も見つけられるがこれは当時ReactのRSC実装が安定していなかった頃にApp Routerがreact@experimentalに依存していたからと考えられ、experimentalに依存することがなくなった現行バージョンでは使用できないことを確認した。

ただしnext@canaryで experimental.ppr を有効にしたときは postpone が使える。これはPartial Prerenderingの内部実装で postpone が使われていて、そのためにreact@experimentalに依存しているようである。next@canaryでApp Routerを使用していて、かつPartial Prerenderingをすぐに有効化できる、または既にしている場合にはこれで目的を達成できるのだが、現行バージョンかつPages Routerで、なんとしてもpostponeしたかったので思いつく限りの検証をした。

ちなみに現時点でもSuspense境界内で任意のエラーをthrowすることで同様の挙動を達成できるが、新しく postpone APIが作られたのはやはり通常のエラーと混同されるようなインターフェースでは実用性が低いからだろう。これが最初に実装された Add Postpone API PRでのコメントによれば、エラーからのクライアントサイド移行というよりも、解決しないPromiseを待つことでのレンダリングのスキップという方面のモデル化とされている。
この用法は以前 Suspense-ready useAuth hook for firebase auth でも言及した。
当然ながらエラーのthrowによってサーバーサイドレンダリングをスキップするのはドキュメントにも明確に記述されている有所正しき用法である。
https://react.dev/reference/react/Suspense#providing-a-fallback-for-server-errors-and-client-only-content

<Suspense fallback={<Loading />}>
  <Chat />
</Suspense>

function Chat() {
  if (typeof window === 'undefined') {
    throw Error('Chat should only render on the client.');
  }
  // ...
}

しかしこれには重大な問題があり、ただエラーをthrowするだけなのでフレームワーク、特にNext.jsがError Overlayを出してくることである。
実際にエラーが起きているのか、ただクライアントレンダリングに切り替えるのを意図したエラーなのかは機械的に判別できないので実のところNext.jsは何も悪くないのだが、余計なお世話だと感じてしまう。

そこで、どうにかしてNext.jsでPostponeを実現したいのがこの記事の趣旨である。

[機能しない] Error Boundaryでエラーを補足してNext.jsへの伝搬を阻止する

<Suspense> のdrop-in replacementとしてSuepnseに加えてError Boundaryを仕込みエラーがthrowされた際にはSuspenseの代わりにError Boundaryでfallbackを表示する方法を考えた。しかし仕様によりError Boundaryはサーバーサイドでトリガーされなかった。

Note

Error boundaries do not catch errors for:

Event handlers (learn more)
Asynchronous code (e.g. setTimeout or requestAnimationFrame callbacks)
Server side rendering
Errors thrown in the error boundary itself (rather than its children)
https://legacy.reactjs.org/docs/error-boundaries.html#how-about-event-handlers

本題とは関係ない余談だが、クライアント側でError Boundaryによってエラーがキャッチされてもwindow errorイベントを発火させるためError Overlayが出現するようである。

コード例
import React, { Component, Suspense } from 'react'

export const SuspenseBoundary: React.FC<{
  children: React.ReactNode
  fallback?: React.ReactNode
}> = ({ children, fallback }) => {
  return (
    <Suspense fallback={fallback}>
      <ErrorBoundary fallback={fallback}>
        {children}
      </ErrorBoundary>
    </Suspense>
  )
}

class ErrorBoundary extends Component<{
  children: React.ReactNode
  fallback?: React.ReactNode
}> {
  state = { hasError: false }

  static getDerivedStateFromError() {
    return { hasError: true }
  }

  // componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
  //   console.error(error, errorInfo)
  // }

  render() {
    if (this.state.hasError) {
      return this.props.fallback
    }

    return this.props.children
  }
}

[機能しない] postponeの代わりにBailoutToCSRErrorを使う

Next.jsの内部実装として BailoutToCSR(), BailoutToCSRError が存在する。これはまさにpostpone APIの再現実装と言えるようなものである。

文字通り、サーバーサイドでエラーをthrowしてクライアントサイドにバイパスするためのものである。
エラーをthrowすることで実現されているが、特別に無視されるためError Overlayが出ない。
しかしNext.jsでサーバーレンダリング中に発生したエラーはHTML中にシリアライズされてError Overlayが出されるが、少なくともPages Routerではこのエラーを識別する肝心のdigestがシリアライズされないため機能しない。RSC/App Router用の実装だと思われる。

export function BailoutToCSR({ reason, children }: BailoutToCSRProps) {
  if (typeof window === 'undefined') {
    throw new BailoutToCSRError(reason)
  }

  return children
}
// This has to be a shared module which is shared between client component error boundary and dynamic component
const BAILOUT_TO_CSR = 'BAILOUT_TO_CLIENT_SIDE_RENDERING'

/** An error that should be thrown when we want to bail out to client-side rendering. */
export class BailoutToCSRError extends Error {
  public readonly digest = BAILOUT_TO_CSR

  constructor(public readonly reason: string) {
    super(`Bail out to client-side rendering: ${reason}`)
  }
}

/** Checks if a passed argument is an error that is thrown if we want to bail out to client-side rendering. */
export function isBailoutToCSRError(err: unknown): err is BailoutToCSRError {
  if (typeof err !== 'object' || err === null || !('digest' in err)) {
    return false
  }

  return err.digest === BAILOUT_TO_CSR
}
on-recoverable-error.ts
  // Skip certain custom errors which are not expected to be reported on client
  if (isBailoutToCSRError(err)) return

このような形式でドキュメントにシリアライズされ埋め込まれていた。

<!--$!-->
<template data-msg="Bail out to client-side rendering: client only" data-stck="{{stack}}"></template>
<!--/$-->

Next.jsをパッチする

調査中にエラーを処理している箇所が分かったので、直接パッチすることにした。
幸いにも実装が独立していて短くシンプルだったのでパッチしたとしても比較的安定していると思われる。

エラーハンドリングにおいて前述したisBailoutToCSRErrorを元にエラーを無視する分岐があるため、そのあとにメッセージが POSTPONE: から始めるエラーを無視する実装を追加する。

diff --git a/node_modules/next/dist/client/on-recoverable-error.js b/node_modules/next/dist/client/on-recoverable-error.js
index 535e1bd..a45ed75 100644
--- a/node_modules/next/dist/client/on-recoverable-error.js
+++ b/node_modules/next/dist/client/on-recoverable-error.js
@@ -18,6 +18,7 @@ function onRecoverableError(err) {
     };
     // Skip certain custom errors which are not expected to be reported on client
     if ((0, _bailouttocsr.isBailoutToCSRError)(err)) return;
+    if (err.message.startsWith('POSTPONE:')) return;
     defaultOnRecoverableError(err);
 }
 
diff --git a/node_modules/next/dist/esm/client/on-recoverable-error.js b/node_modules/next/dist/esm/client/on-recoverable-error.js
index 299e9c2..e772ee9 100644
--- a/node_modules/next/dist/esm/client/on-recoverable-error.js
+++ b/node_modules/next/dist/esm/client/on-recoverable-error.js
@@ -8,6 +8,7 @@ export default function onRecoverableError(err) {
     };
     // Skip certain custom errors which are not expected to be reported on client
     if (isBailoutToCSRError(err)) return;
+    if (err.message.startsWith('POSTPONE:')) return;
     defaultOnRecoverableError(err);
 }

そしてこのようにコンポーネントなどでエラーをthrowすることでPostponeが実現できる。

if (typeof window === 'undefined') {
  throw new Error('POSTPONE: client only')
}

ただこれだけではサーバー側でエラーがログに出てしまうのでこちらもパッチする必要がある。特にNext.jsでの実装があるわけではなくReact側の実装から console.error() が呼ばれていたのでこれをパッチする。

if (typeof window === 'undefined') {
  const originalConsoleError = console.error
  if (!(console.error as any)['_patched']) {
    console.error = (...args) => {
      if (args[0]?.message?.startsWith('POSTPONE:')) return
      originalConsoleError(...args)
    }
    ;(console.error as any)['_patched'] = true
  }
}

わざわざ _patched というフラグを持たせているのは、HMRなどによって同じモジュールが何回も実行されることがあるため、それによって再帰的なパッチを避けるためである。

参考

https://github.com/vercel/next.js/discussions/36641
https://github.com/facebook/react/issues/27420
https://github.com/vercel/next.js/issues/53987
https://github.com/reactjs/react.dev/issues/6106

まとめ

いかがでしたか。今後のReactの発展に期待ですね。

現在トリドリではReactチョットデキル仲間を募集しているのでぜひ応募しよう👇

toridori tech blog

Discussion