Next.jsでuseGLTFのエラーハンドリングをしたい

pmndrs/dreiのuseGLTFに、
存在しないファイルのURLを使用すると、
Next.jsのエラー画面が表示されてしまう。

# Reactフックはtry/catchで囲んではいけない

ReactフックであるuseGLTFをtry/catchで囲むと、
エラーはキャッチできるものの、
Reactフックはtry/catchで囲んではいけないため、
Next.jsアプリをビルドしようとするとエラーが発生するためできない。

# 原因

useGLTFは、
react-three-fiberでuseLoaderに、
Three.jsの[GLTFLoader](https://threejs.org/docs/#examples/en/loaders/GLTFLoader)を渡している。
GLTFLoaderなどのThree.jsのLoaderはコールバック関数を使う。

useLoaderを調べてみると、
（ReactのSuspenceとは異なる）[suspend-react](https://github.com/pmndrs/suspend-react)というライブラリを使って、
Promiseを介してLoaderとデータをやり取りしている。

Next.jsでPromiseを使うと、
rejectに代入されたエラーがNext.jsに伝わってしまい、
エラー画面が表示される。

# 対処1

Next.jsはビルドとdev環境で動作が異なり、
dev環境ではエラーログが画面に表示され、
ビルドではエラーが発生するとエラー画面になる。

dev環境のエラーログは閉じることができるが、
ビルド環境のエラー画面が表示されると何もできなくなる。

suspend-reactのREADMEには「エラー状態は親レベルで処理される」（"error-states are handled at the parental level"）と記されている。

そのため、suspend-reactに依存するuseGLTFなどのフックは次のような使い方となる:

```jsx
import * as React from "react";
import { useGLTF } from "@react-three/drei";
import { ErrorBoundary } from "react-error-boundary";

export default function Model() {
  // 意図的に存在しないURLを指定する
  const gltf = useGLTF("https://raw.githubusercontent.com/Densyakun/assets/main/.gltf");

  return <>
    Resolved
  </>;
}

export default function App() {
  return (
    <ErrorBoundary fallback={<>Rejected</>}>
      <React.Suspense fallback={<>Loading...</>}>
        <Model />
      </React.Suspense>
    </ErrorBoundary>
  )
}
```

親レベルでSuspenseを使っており、
ロード中は別のコンポーネントを表示している。
[r3fのチュートリアル](https://docs.pmnd.rs/react-three-fiber/tutorials/loading-models)でもSuspenseを使っており、
Suspenseがないとロード中はCanvasごと表示されなくなる。

[react-error-boundary](https://github.com/bvaughn/react-error-boundary)を使うと、
子コンポーネントでエラーが発生したときに、
Suspenceのように代わりのコンポーネントに置き換え、
アプリ全体が使えなくなることを避けることができる。

この方法では、dev環境ではエラーログが表示されるが、
ビルド環境のエラー画面は表示されなくなる。

# 対処2

直接suspendをtry/catchで囲む場合、ビルドすることができる。

```tsx
import * as React from "react";
import { suspend } from "suspend-react";
import { GLTF, GLTFLoader } from "three-stdlib";

export default function Model({ path }: { path: string }) {
  try {
    const gltf = suspend(async () => {
      return new Promise<GLTF>((resolve, reject) => {
        const loader = new GLTFLoader();

        loader.load(path,
          function (gltf) {
            resolve(gltf);
          },
          undefined,
          function (error) {
            reject(error);
          }
        );
      });
    }, [path]);

    return <>
      Resolved
    </>;
  } catch {
    return <>
      Rejected!
    </>;
  }
}

export default function App() {
  return (
    <React.Suspense fallback={<>Loading...</>}>
      <Model path="https://raw.githubusercontent.com/Densyakun/assets/main/.gltf" />
    </React.Suspense>
  )
}
```

対処1と異なり、エラーがNext.jsに伝わらないようにすることができる。
ただし、useLoaderを使わないため、キャッシュされない。
そのため、useLoaderも[useSWR](https://swr.vercel.app/ja)のようにエラーも取得できると良い。

# 対処？3

Next.jsではPromiseがRejectedされたときに、エラーが理由となる場合、エラー画面を表示するため、

loader.loadのonErrorでrejectの引数にエラーではなく、文字列を入れることで、
Next.jsのエラー画面が表示されるのを防ぐことができる。

```js
loader.load(path,
  function (gltf) {
    resolve(gltf);
  },
  undefined,
  function (error) {
    reject("test");
  }
);
```

また、loader.loadのonErrorがundefinedの場合は、console.errorでエラーが表示される。

# 参考

- [react-three-fiber#3181](https://github.com/pmndrs/react-three-fiber/issues/3181)
- [react-three-fiber#1881](https://github.com/pmndrs/react-three-fiber/discussions/1881)