ピクセル画像のプレビューでMaximum update depth exceededが発生しました。useEffectの依存配列ではなく、キャッシュ済み画像を補完するref callbackとonLoad内のsetStateが循環していました。
起きていた循環
画像の自然サイズをstateへ保存し、その値から表示倍率を決めています。
<PixelImg
src={src}
onLoad={(event) => {
const img = event.currentTarget;
setDims({ w: img.naturalWidth, h: img.naturalHeight });
}}
/>
PixelImg側には、キャッシュ済み画像で通常のonLoadを取りこぼす場合の補完がありました。
ref={(node) => {
if (node && onLoad && node.complete && node.naturalWidth > 0) {
onLoad({ currentTarget: node } as SyntheticEvent<HTMLImageElement>);
}
}}
ref callbackがonLoadを呼び、setDimsが新しいobjectを作ります。再レンダー後にref callbackが再び呼ばれ、同じサイズでもstate更新が続いていました。
URLごとに補完を1回へ制限する
PixelImgでは、補完済みのcurrentSrcをrefへ記録します。
const firedFor = useRef<string | null>(null);
ref={(node) => {
if (
node &&
onLoad &&
node.complete &&
node.naturalWidth > 0 &&
firedFor.current !== node.currentSrc
) {
firedFor.current = node.currentSrc;
onLoad({ currentTarget: node } as SyntheticEvent<HTMLImageElement>);
}
}}
画像URLが変わればもう一度発火し、同じURLの再レンダーでは止まります。
同じ寸法ならstateを変えない
受け取り側にも防御を入れました。
setDims((previous) =>
previous &&
previous.w === img.naturalWidth &&
previous.h === img.naturalHeight
? previous
: { w: img.naturalWidth, h: img.naturalHeight },
);
値が同じときは以前の参照を返すため、Reactは再レンダーを省けます。発火元と更新先の両方で止めたのは、どちらかの実装が後から変わっても循環しにくくするためです。
画像読み込み周辺の無限ループでは、useEffectだけでなくcallback refも確認対象になります。refはDOM nodeを受け取る場所ですが、その中でstateを変えるとレンダーサイクルへ参加します。特にcompleteを見てイベントを手動発火する実装では、同じリソースへ何度発火したかを記録しておく必要があります。