React の useOpaqueIdentifier について

React の SSR 向けに Opaque ID を生成する useOpaqueIdentifier Hooks について覚書です。
この記事で書かれているのは、実験的な Hooks です。仕様が変わるかもしれません。
デモはこちら

ID参照と Hydration の問題

マークアップでは、ある要素が別の要素を参照する時、label要素の for 属性や aria-labelledby などの ARIA 属性を用います。その属性には、参照する要素の ID を指定する必要があり、ID を持った要素が DOM 上になければなりません。

html
<!-- id が inputFoo の要素を参照している label -->
<label for="inputFoo">Foo</label>

<!-- id に inputFoo を持つ要素が DOM にないと label はエラー -->
<input id="inputFoo" type="text" />

今回は、そのようなものを React コンポーネントで実装する時のお話です。
inputlabel で囲めばいいんですが、ARIA を例にするより伝わりそうなのでこれで…)

InputField.tsx
import * as React from 'react'

type Props = {
  label: string
  handleChangeText: (e: React.ChangeEvent<HTMLInputElement>) => void
}

export const InputSample: React.FC<Props> = props => {
  return (
    <>
      <label htmlFor="inputSample">{props.label}</label>
      <input id="inputSample" name="inputSample" type="text" />
    </>
  )
}

上記の InputSample が 2 つあると、inputSampleの ID も 2 つ文書に存在することになります。
ページ内のIDはユニークでなければならないため、 ID の重複を防ぐ必要があります。

簡単なものとして、UID を Props なりで渡し、プレフィックス的に用いる方法があります。
しかし、ID 参照のための ID を管理する、悲しみの歴史を繰り返すことになります。

pages/index.tsx
{/* どうして uid を渡さないといけないんですか? */}
<InputSample uid={'input1'} />

コンポーネント側で生成する時の懸念

ID 参照のための UID を利用者が設定するのではなく、いっそのこと自動で設定されるのはどうでしょう。
コンポーネントが UID が生成してくれれば、それに越したことはありません。

しかし、SSR では、この UID の生成方法について懸念がありました。
サーバー側で生成された UID と、Hydration 時の UID が一致しないケースが考えられるからです。
Hydration では、属性の値が一致しない時に修正される保証はないので、ID 参照に依存するコンポーネントを作成するには無視できない問題です。

例えば、コンポーネント内部で uuid を生成した時の挙動を見てみましょう。
下記では uuid を生成して、htmlForid 属性にそれぞれ結び付けています。

InputField.tsx
/* ----------省略---------- */
export const InputSample: React.FC<Props> = props => {
  const uid = React.useMemo(() => uuid.v4(), [])
  return (
    <>
      <label htmlFor={uid}>{props.label}</label>
      <input id={uid} name="inputSample" type="text" />
    </>
  )
}

しかし、サーバー/クライアントで uid の戻り値が一致しないため、警告が表示されます。

サーバー側で生成されるUIDと、クライアント側で生成されるUIDが異なるので、開発者メニューで警告が出る

では、下記のようなインクリメントの場合ではどうなるでしょうか。
クライアントがカウントをリセットしても、サーバーではカウントされ続けるので、結局は不一致が生じます。

InputField.tsx
import * as React from 'react'

let count = 0 // カウント

export const InputSample: React.FC<Props> = props => {
  const uid = `uid_${count++}` // リロードでずれる

  return (
    <>
      <label htmlFor={uid}>{props.label}</label>
      <input id={uid} name="inputSample" type="text" />
    </>
  )
}

downshift では、インクリメントの問題に対してコンポーネントにidを渡す方法で対処していますが、これは結局、最初の項目で挙げたような UID 管理の問題です。
コンポーネントの使用者を混乱させ、 Issue が立ってしまうかもしれません。

react-uid は、 Context API から ID を流し込む手法を取り入れています。
専用の Consumer で囲めば、サーバー/クライアント間で一貫した UID が生成されます。
しかし、結局この方法も、開発者が UID を生成するための Wrapper で囲むことを強いられてしまいます。

Server-side_friendly_UID(react-uid_sample)
import {UIDReset, UIDConsumer} from 'react-uid';

<UIDReset>
    <UIDConsumer>
        {(id,uid) => (
          <Fragment>
            <input id={id} />
            <label htmlFor={id} />
            data.map( item => <li key={uid(item)}>{item}</li>)
          </Fragment> 
        )}
    </UIDConsumer>
</UIDReset>

このような背景もあってか「React 側で UID 生成できればいいよね」問題が以前から議論されていたようです。
https://github.com/facebook/react/issues/5867

そこから派生して RFC も出ていました。
https://github.com/reactjs/rfcs/pull/32

そして、useOpaqueIdentifier の PR がマージされました。
https://github.com/facebook/react/pull/17322

useOpaqueIdentifier とは

useOpaqueIdentifierは、Hydration 時の不一致を起こさない UID を生成する Hooks です。
現在、この Hooks は experimental なので、使用する場合は experimental build をインストールします。

shell
yarn add react@experimental react-dom@experimental

使用方法はシンプルで、React.unstable_useOpaqueIdentifier()を呼びだすだけです。

foo.tsx
const Foo: React.FC = (props) => {
// @ts-ignore
const uid = React.unstable_useOpaqueIdentifier()

return <p id={uid}>props.children</p>
}

id 属性に入っている uid の値を確認すると、r:0という文字列が入っていることがわかります。
0の部分は 36 進数で表され、コンポーネントが追加されたり、Route が切り替わるたびに更新されます。
そして、リロードすると連番が両方とも 0 に戻るので、先に挙げたカウンターの不一致も防げます。

何をしてるの: 文字列の生成

挙動を見ると、r:0から始まる一貫した UID を生成する Hooks のように思えます。
しかし、この Hooks の役割は、 Hydration 時に属性を更新し、再レンダリングを試みることで、実際に生成される ID 自体は、サーバーとクライアントで別です。

まず、サーバー側では R: を含む何らかのプレフィックスと、インクリメントされた UID の結果を返します。
プレフィックスがあるのは、複数の SSR が混在することを想定しての仕様だと思われます。

packages/react-dom/src/server/ReactPartialRendererHooks.js
function useOpaqueIdentifier(): OpaqueIDType {
  return (
    (currentPartialRenderer.identifierPrefix || '') +
    'R:' +
    (currentPartialRenderer.uniqueID++).toString(36)
  );
}

クライアント側では、似たような処理が ReactDOMmakeClientId にあります。
やっていることはサーバー側と同じですが、プレフィックスは小文字の r: のみで、サーバーの出力とは異なることがわかります。

packages/react-dom/src/client/ReactDOMHostConfig.js
let clientId: number = 0;
export function makeClientId(): OpaqueIDType {
  return 'r:' + (clientId++).toString(36);
}

つまり生成時点では、r:でなく R: 形式の文字列が ID や ARIA 属性に指定されています。
ブラウザの設定で JavaScript を無効化すると確認できます。

サーバー/クライアント間のプレフィックスはあえて合わせていないようです。
SSR と CSR が混在するケースで、ID の衝突を防ぐためのようです。

何をしてるの: OpaqueHydratingObject と Hooks

先ほどは UID の文字列を生成する処理を見ましたが、実際にはサーバーとクライアントでそれぞれ別の ID を生成していることがわかりました。
次は、 Hydration 時に生成される OpaqueHydratingObject を見てから、 Hooks の処理を調べます。

まず、 Hydration 時には、単なる UID の文字列ではなく、OpaqueHydratingObjectなるものを生成します。
このオブジェクトは React の要素の 1 つであることを示す $$typeof Symbol を持ち、toStringvalueOf をオーバーライドしています。
Object の toStringvalueOf は、文字列へ変換される時などに呼び出されるものですが、OpaqueHydratingObjectでは代わりに attemptToReadValue が発火する仕掛けとなっています。

packages/react-dom/src/client/ReactDOMHostConfig.js
export function makeOpaqueHydratingObject(
  attemptToReadValue: () => void,
): OpaqueIDType {
  return {
    $$typeof: REACT_OPAQUE_ID_TYPE,
    toString: attemptToReadValue,
    valueOf: attemptToReadValue,
  };
}

次は Hooks FibermountOpaqueIdentifier です。
長いので、ちょっとずつ見ていきます。

まず最初の makeId は、r:0などの文字列を生成する makeClientId 関数です。
Hydration でない時は、この文字列がそのまま mountState(id) されて、 return されます。
mountState(id)React.useState(id)と同じ役割です。

ReactFiberHooks.new.js
// ここで生成
const makeId = __DEV__
  ? makeClientIdInDEV.bind(
      null,
      warnOnOpaqueIdentifierAccessInDEV.bind(null, currentlyRenderingFiber),
    )
  : makeClientId;
    
if (getIsHydrating()) {
  /* --------省略-------- */
} else {
  // `makeClientId` をセットして return する
  const id = makeId();
  mountState(id);
  return id;
}

Hydration 時はどうなるのでしょうか。
まず readValue なる関数式が出てくるので、その中身を見ていきます。

ReactFiberHooks.new.js
if (getIsHydrating()) {
  let didUpgrade = false;
  const fiber = currentlyRenderingFiber;
  const readValue = () => {
    if (!didUpgrade) {
      // Only upgrade once. This works even inside the render phase because
      // the update is added to a shared queue, which outlasts the
      // in-progress render.
      didUpgrade = true;
      if (__DEV__) {
      isUpdatingOpaqueValueInRenderPhase = true;
      setId(makeId());
      isUpdatingOpaqueValueInRenderPhase = false;
      warnOnOpaqueIdentifierAccessInDEV(fiber);
      } else {
        setId(makeId());
      }
    }
    // throw する
    invariant(
      false,
      'The object passed back from useOpaqueIdentifier is meant to be ' +
      'passed through to attributes only. Do not read the value directly.',
    );
  };
  /* 続く */

invariant は次のセクションで説明しますが throw Error で、 不正な Hooks の使用を防ぐためにあります。
setId(makeId())についても後述します。

その後、話題の OpaqueHydratingObject を生成します。引数に渡すのは readValue です。

ReactFiberHooks.new.js
  /* 上のコードの続き */

  // OpaqueHydratingObject
  const id = makeOpaqueHydratingObject(readValue);

  // setState
  const setId = mountState(id)[1];
  
  // Legacy Mode なら useEffect のような操作をして setID を実行
  if ((currentlyRenderingFiber.mode & BlockingMode) === NoMode) {
    currentlyRenderingFiber.effectTag |= UpdateEffect | PassiveEffect;
    pushEffect(
      HookHasEffect | HookPassive,
      () => {
        setId(makeId());
      },
      undefined,
      null,
    );
  }
  return id;
}

setIdreadValue でも出てきたものですが、mountStateの dispatch 、要するに useState の setState にあたるものです。
OpaqueHydratingObject を id として return します。

オマケで return 前のビット演算ですが、レンダリングが BlockingMode (Concurrent Modeへの移行向けモード)以上の新しい mode でない場合は Legacy Mode とみなし、useEffect相当の操作を行うようです。

なんで OpaqueHydratingObject なの

長々と書きましたが、やることは UID の文字列か OpaqueHydratingObject をセットするだけです。
なぜ Hydration 時は文字列でなく、OpaqueHydratingObjectなのでしょうか…。

ひとつは、Hydration 中の ID を、文字列ではなく Opaque ポインタのように扱う狙いがあるのだと思われます。
OpaqueHydratingObject は、バリデーション時にサーバーから渡される ID さえも上書きしていて、DEV 環境で Hydration 不一致の警告を出さない理由にもなっています。
本当に必要な ID は再レンダリング後のものなので、readValueなどで直接参照するまでは隠蔽するのだと思います。

この方法は id 属性の値に意味を持たせる必要がないからできることです。
連番の情報も必要だと、生成順が保証されないこの方法は厳しそうです。
例えば Concurrent Mode では複数のコンポーネントが Suspend されたり、ネストされたコンポーネントがさらに Suspend されていることも考えられます。
Hydration も様々で、部分的な Hydration もあれば、将来的には Progressive Hydration もあります。
今回のケースでは、完璧な ID を生成する魔法を考えるより、ID の生成と再レンダリング処理を押し込めて、随時置き換えさせる試みの方が、現実的に思えます。(思えてきました、個人差があります)

もうひとつは、この Hooks が「ID 参照」の問題を解決することにユースケースを絞っているからです。
あくまでアクセシビリティ対応のためにあり、sessionID の生成などに使うのを意図していません。
実際にこの Hooks は、属性の値を設定する以外の用途で使うことを禁じています。
例えば先の項目で触れた invariant は、JSX 内で値が不正に読み込まれていれば、readValue経由で throw されるでしょう。

packages/react-dom/src/client/ReactDOMHostConfig.js
invariant(
  false,
  'The object passed back from useOpaqueIdentifier is meant to be ' +
  'passed through to attributes only. Do not read the value directly.',
);

そうでなくとも makeClientId には DEV 環境限定でデバッグ用の関数が入っており、開発者が文字列化させようとすると警告が出るので、警告さえ読まれれば、間違った用途で使われることはありません…。

packages/react-dom/src/client/ReactDOMHostConfig.js
export function makeClientIdInDEV(warnOnAccessInDEV: () => void): OpaqueIDType {
  const id = 'r:' + (clientId++).toString(36);
  return {
    // 勝手に文字列にしたら怒られる
    toString() {
      warnOnAccessInDEV();
      return id;
    },
    valueOf() {
      warnOnAccessInDEV();
      return id;
    },
  };
}

もうひとつですが、オブジェクトの toString() を実行させて間接的に throw させ、再レンダリングを狙うような実装も見られました。ReactDOMComponentdiffProperties では、nextPropがこの $$typeof かを調べ、toString()を発火させています。
Concurrent Mode向けの処理で、ID を強制的に更新させる狙いがあるとは思うのですが、本当は違うのかもしれません、なかなか発火しないので…。

packages/react-dom/src/client/ReactDOMComponent.jsより
else if (
  typeof nextProp === 'object' &&
  nextProp !== null &&
  nextProp.$$typeof === REACT_OPAQUE_ID_TYPE
) {
  // If we encounter useOpaqueReference's opaque object, this means we are hydrating.
  // In this case, call the opaque object's toString function which generates a new client
  // ID so client and server IDs match and throws to rerender.
  nextProp.toString();
}

感想

何もわかりませんでしたが便利そうです。

useOpaqueIdentifierが ID 参照に関する Hooks というのは、説明しないとわからないレベルです。
最初は useUniqueId という名前でしたが、それぞれ異なる場所から Reference する意味が強く、 useOpaqueReference という名前に変わりました。しかし今度は ref オブジェクトとして扱う印象を与えてしまうので、現在の useOpaqueIdentifier という名前になったようです。
それでもユースケースを的確に表せているとは言い難く、さらに変わってもおかしくなさそうな。

アクセシビリティの闇でもあるこの問題に、ライブラリ側が解決に取り組むのはなかなか斬新で、React のアクセシビリティ対応への手厚さと、現時点でもだいぶ実用的な Hooks を開発されているコアメンバーの腕力の高さを感じました。
もちろん不安定なところもあり、これからは IDREFs(ARIA 属性に複数の ID を指定する)への対応などもあるようなので、もうしばらく追い続けようと思います。

参考

React Hooks の useState がどういう原理で実現されてるのかさっぱりわからなかったので調べてみた - Subterranean Flower Blog