mizdra's blog

ぽよぐらみんぐ

react-relay の useFragment で本来取得できるはずの field が欠損する現象について

react-relay を使っていると、稀に「本来取得できるはずの field が欠けた状態のオブジェクトが useFragment から返される」現象に遭遇することがあります。

例えば、以下のようなエントリーのタイトルと本文を表示するエントリーページがあったと仮定します。デフォルトではタイトルのみが表示されていて、「本文を表示」ボタンを押すと、GraphQL API から本文が fetch されて表示されます。

import {PreloadedQuery, usePreloadedQuery, graphql, useFragment, useLazyLoadQuery} from 'react-relay';
import { useLocation, AppInternalError, AppNotFoundError } from '../util';
import { type BlogTopPageQuery } from './__generated__/EntryPageQuery.graphql';
import { type EntryHeader_entry$key } from './__generated__/EntryHeader_entry.graphql';

import { type EntryBodyQuery } from './__generated__/EntryBodyQuery.graphql';

export function EntryPage () {
  const { query: { entryId } } = useLocation(); // entryId === 'entry:1'
  if (typeof entryId !== 'string') throw new AppInternalError('entryId(${entryId}) is not string');

  const { entry } = useLazyLoadQuery<BlogTopPageQuery>(graphql`
    query BlogTopPageQuery($entryId: ID!) {
      entry(id: $entryId) {
       ...EntryHeader_entry
      }
    }
  `, { entryId });
  if (entry === null) throw new AppNotFoundError(`Entry(${entryId}) not found`);

  return (
    <article>
      <EntryHeader entry={entry} />
      <EntryBody entryId={entryId} />
    </article>
  );
}

type EntryHeaderProps = {
  entry: EntryHeader_entry$key,
};
function EntryHeader(props: EntryHeaderProps) {
  const data = useFragment(graphql`
    fragment EntryHeader_entry on Entry {
      title
    }
  `, props.entry);
  return <h2>{data.title.toUpperCase()}</h2>;
}

const EntryBodyQuery = graphql`
  query EntryBodyQuery(entryId: ID!) {
    entry(id: $entryId) {
      body
    }
  }
`;
type EntryBodyProps = {
  entryId: string,
};
function EntryBody(props: EntryBodyProps) {
  const [queryReference, loadQuery] = useQueryLoader<EntryBodyQuery>(EntryBodyQuery);

  if (queryReference === null) {
    return <div><button onClick={() => loadQuery({ entryId: props.entryId })}>本文を表示</button></div>
  }
  return (
    <div>
      <Suspense fallback="Loading...">
        <EntryBodyInner queryReference={queryReference} />
      </Suspense>
    </div>
  );
}

type EntryBodyInnerProps = {
  queryReference: PreloadedQuery<EntryBodyQuery>,
};
function EntryBodyInner(props: EntryBodyInnerProps) {
  const data = usePreloadedQuery<EntryBodyQuery>(EntryBodyQuery, props.queryReference);
  return <div>{data.body}</div>;
}

このページは、全部で 2 回クエリが fetch される機会があります。

  1. ページにアクセスした直後に fetch される BlogTopPageQuery
    • 例: entry(id: "entry:1") { title }
  2. 「本文を表示」を押した時に fetch される EntryBodyQuery
    • 例: entry(id: "entry:1") { body }

しかしこの EntryBodyQuery の fetch が行われると、その直後に EntryHeader コンポーネント内で Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase') という実行時エラーが発生します。

  • TODO: あとでサンプルアプリケーションの URL を貼る

なぜ実行時エラーが起きるのか

この現象は、1 のクエリと 2 のクエリを実行した時に、Relay Client 内の id: entry:1 の node のキャッシュと <EntryHeader> 内で実行している useFragment の戻り値が、以下のように変化することに起因してます。

  1. BlogTopPageQuery が fetch される
    • cache: id: "entry:1", data: { title: "2022年のおみくじは" }
    • <EntryHeader>useFragment の戻り値: { title: "2022年のおみくじは" }
  2. EntryBodyQuery が fetch される
    • cache: id: "entry:1", data: { body: "大吉でした" }
    • <EntryHeader>useFragment の戻り値: { }
  3. <EntryHeader> にて data.title === undefined となり、Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase') が出る

なんと後続のクエリで同一 id の node を fetch すると、前のクエリで fetch したフィールドの情報を吹き飛ばしてしまうんですね。マジかよ。

バグではなく仕様

公式の Issue を漁ってみたところ、以下でこの現象について議論されているようでした。

github.com

Relay の開発チームからのコメントも寄せられているのですが、それによるとこの現象は開発チームの間で「missing data」と呼ばれていて、「バグではなく仕様である」そうです。マジかよ。

長々と書いてありますが、簡単に主張をまとめると以下のようになります。

  • クライアントサイドのキャッシュを扱う際に、以下の3つの要素が求められる
    • データの完全性 (各コンポーネントが要求したすべてのデータを持っているか)
    • 一貫性 (すべてのコンポーネントが一貫した世界観を表現しているか)
    • パフォーマンス (適切な量のデータでこれらの性質を達成できるか)
  • ただしキャッシュの性質上、3つのうち最大2つまでしか同時に達成できない
  • missing data 現象においては、一貫性とパフォーマンスを優先し、完全性を諦める方針を取ってる

完全性を優先して、一貫性かパフォーマンスのどちらかを諦めた場合どうなるかを想像すると、より理解しやすいかなと思います。例えば完全性とパフォーマンスを取ることととし、キャッシュをマージする実装にした場合、途中でデータの更新が起きた際に一貫性が失われることになります。

  1. BlogTopPageQuery が fetch される
    • cache: id: "entry:1", data: { title: "2022年のおみくじは" }
    • <EntryHeader>useFragment の戻り値: { title: "2022年のおみくじは" }
  2. ユーザがエントリーを以下のように更新した
    • {title: "2023年のおみくじは", body: "小吉でした" }
  3. EntryBodyQuery が fetch される
    • cache: id: "entry:1", data: { title: "2022年のおみくじは", body: "小吉でした" } (注: 前のキャッシュとマージしてる)
    • <EntryHeader>useFragment の戻り値: { title: "2022年のおみくじは" }
  4. ページの表示が「2022年のおみくじは / 小吉でした」になる
    • 2023 年は大吉であるはずなので、データの不整合が発生してる (一貫性の喪失)

逆に完全性と一貫性を取ろうと思うと、常にエントリーが更新されていないか fetch し続ける必要があり、パフォーマンスの問題が発生します。

どちらも Relay としては許容できないということで、一貫性とパフォーマンスを優先し、完全性の欠如 (missing data 現象) を許容する選択を取っている訳です。

完全性の欠如に対する緩和策

とはいえ、突然実行時エラーが起きるようでは困ります。その緩和策として、Relay 開発チームは「後続のクエリに、前のクエリの fragment を含めて fetch せよ」と主張してます *1。つまり、EntryBodyQueryEntryHeader_entry を含めろと言っている訳です。

const EntryBodyQuery = graphql`
  query EntryBodyQuery(entryId: ID!) {
    entry(id: $entryId) {
      body
      ...EntryHeader_entry
    }
  }
`;

こうすれば、EntryBodyQuery を fetch した際に、BlogTopPageQuery の分の field もfetch され、ユーザレベルで完全性が担保できるようになります。

  1. BlogTopPageQuery が fetch される
    • cache: id: "entry:1", data: { title: "2022年のおみくじは" }
    • <EntryHeader>useFragment の戻り値: { title: "2022年のおみくじは" }
  2. ユーザがエントリーを以下のように更新した
    • {title: "2023年のおみくじは", body: "小吉でした" }
  3. EntryBodyQuery が fetch される
    • cache: id: "entry:1", data: { title: "2023年のおみくじは", body: "小吉でした" } (注: title field も fetch されてる)
    • <EntryHeader>useFragment の戻り値: { title: "2023年のおみくじは" }
  4. ページの表示が「2023年のおみくじは / 小吉でした」になる
    • 一貫性も完全性もある状態になってる

感想

気持ちはわかるものの、いきなり Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase') とだけ言われても何が起きているのか分からないのが微妙な感じがします。「missing data 現象が発生しました。後続のクエリに前のクエリの fragment を含めてください。」みたいな丁寧なエラーメッセージを出すくらいはやってほしいかな...

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.