🙈

【👊暴力】Next.jsでレンダリング時にわかるNotFoundをくそ雑に対応する

2022/12/08に公開

レンダリング時にわかるNot Foundとは

この記事では、以下のような状況下でそのページがNot Foundであることがわかったときの対応の仕方を解説します!

  • Reduxなどを使っていて、getServerSidePropsを完全に使いこなしている感じではない
    • getServerSidePropsの段階で404を返すべきかどうか判断できない実装をしている
  • 動的ルーティングされるページで、レンダリング時に対象のデータがないことが初めてわかる
pages/items/[itemId].tsx
export default function ItemShow() {
  const { query } = useRouter()
  const item = useSelector(state => state.items[query.itemId])
  
  if (item == null) {
    // アッ❗❗❗ 該当するアイテムはありませんでした❗❗ ゴシュウショウ❗❗
  }
  
  return <>お求めのデータはあります!!!!!</>
}

このコードでitem == nullのときに404 Not Foundを返したいですが、Next.jsではgetInitialPropsやgetServerSidePropsの際にしかステータスコードを設定できないので、レンダリングに入ってしまったあなたは脳死で200 Found(大嘘)と言う事しかできません!

うそつき❗おかあさんにお前のサイトのGoogle検索結果を見られて泣かれろ❗❗

Not Foundのときにレンダリングを中断する

なのでNot Foundであることがわかったらまずはレンダリングを中断しましょう。
カンの良い方はお気づきかと思いますが、throwします。

pages/items/[itemId].tsx
  if (item == null) {
    // アッ❗❗❗ 該当するアイテムはありませんでした❗❗ ゴシュウショウ❗❗
    throw new NotFoundError('あなたが喉からアシカが出るほど欲しがり、その面影を追ってたどり着いたこのデータは、もうこの世にありません。あいつが食べてしまいました。')
  }

NotFoundErrorは以下のような例外クラスを適当に作っておきましょう

RoutingErrors.ts
export class RouteError extends Error {
  constructor(
    public message: string,
    // 例外クラスにstatusCodeをもたせておく
    public readonly statusCode: number = 500
  ) {
    super(message);
  }
}

// 404 Not Found
export class NotFoundError extends RouteError {
  constructor(message: string) {
    super(message, 404);
  }
}

投げたエラーをCustom Error Pageでハンドリングする

ページから投げられたNot FoundエラーをCustom Error Pageで受け取ってハンドリングします。
ただ、Next.jsの挙動上かなりハッキーなコードが必要です、しんどいねぇぇえええ〜〜〜〜?

pages/_error.tsx
import { RouteError } from '@/iitokoro/RoutingErrors.ts';

type Props = {
  __errorInSSR?: true;
  statusCode: number;
  message?: string;
};

export default function ErrorPage({ statusCode, message }: Props) {
  return (
    <YourErrorPage>
      {statusCode} {message}
    </YourErrorPage>
  );
}

ErrorPage.getInitialProps = async (ctx): Promise<Props> => {
  const { res, err } = ctx;

  if (typeof window !== 'undefined') {
    const nextData = JSON.parse(document.getElementById('__NEXT_DATA__')!.innerHTML);

    // SSR時にErrorページがレンダリングされる前、CSRでこのgetInitialPropsがもう一度コールされ
    // ctx.errorに渡ってくるエラー情報が、Next.jsによってラップされた500エラーになってしまうので
    // SSR時に返されたpagePropsを取りにいって返すようにする

    // クライアントサイドでの実行時エラーが発生したときはinitialProps.__vhErrorInSSR != trueになるので
    // ctx.errorに入っている実行時のエラー情報を使う
    if (nextData.props.pageProps.initialProps?.__errorInSSR) {
      // ここらへんのプロパティはnext-redux-wrapperとか使ってると変わりそうなのでよしなにしてください
      return nextData.props.pageProps.initialProps;
    }

    return { statusCode: err?.statusCode ?? 500, message: err?.message };
  }

  if (res && err instanceof RouteError) {
    res.statusCode = err.statusCode;

    return {
      __errorInSSR: true,
      statusCode: err.statusCode,
      message: err.message,
    };
  }

  return {
    __errorInSSR: true,
    statusCode: 500,
  };
};

解説

ErrorPage.getInitialPropsでは第一引数のctx、そのうちのerrプロパティにthrowされたエラーオブジェクトが渡されてきます。 つまりNotFoundErrorのインスタンスが来ているという想定です。

まずはシンプルにerr.statusCodeをステータスコードに設定してみましょう。

ErrorPage.getInitialProps = async (ctx): Promise<Props> => {
  const { res, err } = ctx

  // getInitialPropsはクライアントサイドでも実行されるので、resがnullなこともある
  if (res && err instanceof RouteError) {
    // アプリが意図的に投げたRouteErrorクラスだったらstatusCodeを設定する
    res.statusCode = err.statusCode;
    return { statusCode: err.statusCode, message: err.message }
  }
  
  // RouteErrorじゃなかったらガチのエラーであるでしょう
  return { statusCode: 500, message: '[あーしがアプリを壊しました]' }
}

おわりです、よかったね。 そんなわけないだろ❗❗❗❗❗

getInitialPropsはマウント時にも実行される、便利にラップされたerrと一緒に。

ErrorPage.getInitialPropsは、どうやらクライアントにマウントされた際にもう一度実行されるようで、
その際にctx.errに渡ってくるオブジェクトは👇のようなNext.jsが独自にラップしたエラーオブジェクトになってしまいます。

{ statusCode: 500, ...なんかもう1プロパティくらい }

statusCode自体は404になるのですが、ユーザーに対する画面上のエラー表示がInternal Server Errorのときのヤツになってしまいます。赦せねぇよオイ

なので、CSRでもう一度レンダリングされる際は、SSR時に返されたオリジナルのinitialPropsを取得する必要があります。しかしそんなことを出来る方法はNextには用意されてないので、暴力を振るい以下のような処理を追加します。

  // CSR時
  if (typeof window !== 'undefined') {
    // DOM上に残っているSSRでhydrateされた状態を取得してくる
    const nextData = window.__NEXT_DATA__;
    return nextData.props.pageProps;
  }

これで晴れてCSR時にもSSR時と同等の情報で「そんなページはないんだよ」とユーザーに伝えられます。

おわりです、よかったね。 そんなわけないだろ❗❗❗❗❗

クライアントサイドでの実行時エラーに対応する

なんとここまでの対応では、ブラウザ上で実行時エラーが発生したときにも、SSRされた時点のエラー状態が表示されてしまいます。一生な!!!! クライアントサイドの実行時エラーにも対応しましょう

先程のコードに対して、👇みたいなノリの「SSR時のエラーではない時は新しくctx.errに来たエラーオブジェクトを使う」という対応を入れます

 
  // CSR時
  if (typeof window !== 'undefined') {
    const nextData = window.__NEXT_DATA__;
    const isSSR時の初回エラー = /* TODO */
    
    if (isSSR時の初回エラー) {
      return nextData.props.pageProps;
    }
    
    return {
      statusCode: 500,
      message: `[あなたがアプリを壊したんだ!!!] ${ctx.err.message}`
    }
  }

isSSR時の初回エラーの判定をどうするかというところですが、SSR時にエラーをハンドルした時にprops.__errorInSSR = true にするようにします。

  if (res && err instanceof RouteError) {
    res.statusCode = err.statusCode;

    return {
      __errorInSSR: true,
      statusCode: err.statusCode,
      message: err.message,
    };
  }

  return {
    __errorInSSR: true,
    statusCode: 500,
  };

こうすることで一番最初に上げたコードになり、SSR/CSRともにちゃんとエラー対応できて、throw new NotFoundError()するだけで404が返せるエラーページができます。

おわりです、よかったね。
大雑把に作ったから、細かい実装はあとからよしなに読み替えてね。<敬具 />

はなくら組代表 花倉法華賦雅 より

Discussion