0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

react-grid-layoutでブラウザが無限ループ死した話。ライブラリの罠と壊れたローカルデータを救う自己修復の設計

0
Last updated at Posted at 2026-09-21

新しいウィジェットを画面に1つ追加した瞬間、ブラウザの描画が真っ白になり、PCのファンがけたたましい音を立てて回り始める。数秒後、Chromeが「ページが応答しません」という警告ダイアログを出した。開発者ツールのコンソールを開こうとしても、キー入力を一切受け付けない。

image.png

自作していた新しいタブ拡張機能「ZenithTab」のテスト中に起きたこの現象は、単なる一時的なクラッシュでは終わらなかった。

一度このフリーズを踏むと、タブを再読み込みしても、ブラウザ自体を再起動しても、新しいタブを開いた瞬間に再びブラウザが固まる。手元のローカルストレージに不正なデータが保存されてしまい、拡張機能を一度アンインストールしてストレージを丸ごと吹き飛ばすまで、二度と画面が開けない状態に陥ってしまった(Issue #20)。

原因は、ダッシュボード構築で広く使われているライブラリ react-grid-layout の内部処理と、安易に信じた「よくある実装パターン」の組み合わせにあった。


定石とされていた y: Infinity の落とし穴

ZenithTabは、時計、天気、RSSリーダー、タスク管理などのウィジェットをグリッド上に自由に配置できるダッシュボードだ。グリッドのドラッグ&ドロップやリサイズには、Reactエコシステムで定番の react-grid-layout を採用している。

image.png

ユーザーが「ウィジェットを追加」ボタンを押したとき、既存のレイアウトを崩さずにグリッドの最下部へ自動配置したい。この要件を実装する際、ライブラリのサンプルコードや技術記事で頻繁に紹介されているのが、次の指定方法だ。

// 修正前の addWidget(src/store/useDashboardStore.ts)
const newLayout: Layout = {
  i: id,
  x: 0,
  y: Infinity, // Place at bottom
  w: size.w,
  h: size.h,
  minW: size.minW,
  minH: size.minH,
};

「y座標に Infinity を渡しておけば、ライブラリ内部のコンパクション(自動整列処理)が空いている一番下の位置を見つけて自動で詰めてくれる」という挙動に頼った書き方だ。実際、react-grid-layout の README にも「y: Infinity を渡すと最下部に置かれる」という趣旨の記述があり、多くのサンプルがこれを踏襲している。

単一の画面幅で少数のアイテムを並べるだけであれば、このコードでも表面上は動いてしまう。しかし ZenithTab では、ウィジェット追加の直後に確実にタブが固まった。


なぜ無限ループが発生したのか

react-grid-layout のソースコード(build/utils.js)を追っていくと、アイテム同士の重なりを解消して上へ詰める compactItem という関数に行き着く。垂直方向のコンパクションは次のような形をしている。

// node_modules/react-grid-layout/build/utils.js(要約)
if (compactV) {
  l.y = Math.min(bottom(compareWith), l.y);
  while (l.y > 0 && !getFirstCollision(compareWith, l)) {
    l.y--;
  }
}

有限の数値が渡されている限り、l.y は 1 ずつ減って必ず 0 に到達するか衝突で止まり、ループを抜ける。ところが l.y や bottom(compareWith) に Infinity が紛れ込むと、計算の前提が崩壊する。

JavaScript では Infinity > 0 は常に true であり、そして Infinity - 1 === Infinity だ。l.y-- を何億回実行しても l.y は Infinity のまま 0 に近づかない。while (l.y > 0) の条件が永遠に真であり続け、JavaScript のメインスレッドがこの while ループから二度と戻ってこなくなる。

ブラウザのイベントループは完全に停止し、画面描画もユーザーの操作受付も遮断される。これが、タブが固まった直接の引き金だった。


一度踏むと二度と起動しなくなる二次災害

単なるランタイムエラーであれば、ブラウザをリロードすれば元の画面に戻れる。本当に厄介だったのは、その不正な状態がローカルストレージへ保存されてしまった点にある。

ZenithTabでは、ユーザーのカスタマイズ内容を即座に反映するため、Zustandのステートが変化するたびに chrome.storage.local(プレビュー環境では localStorage)へ非同期で書き込みを行っている。フリーズする直前に、y: Infinity を含んだレイアウトがそのまま保存された。

ここで、永続化の仕様が追い打ちをかける。
メモリ上では Infinity だった y が、ストレージに書き込まれた瞬間、別の値へ化ける。プレビュー環境(localStorage)では JSON.stringify が Infinity や NaN を null に変換し、{"y": null} として保存される。
​json { "lg": [ { "i": "clock-1", "x": 0, "y": 0, "w": 4, "h": 2 }, { "i": "weather-1", "x": 4, "y": null, "w": 4, "h": 3 } ] } ​
実機の chrome.storage.local はさらに厄介だった。Chrome 内部のシリアライザは Infinity を扱えないため、エラーを吐くこともなく、y プロパティそのものをサイレントに削除して保存してしまう。

image.png

初期ウィジェットには存在するはずの y が、新しく追加したウィジェット(インデックス 8 以降)からは跡形もなく消えている。
null になるか、キーごと消えるかの違いはあれど、どちらの環境でも「有限の数値ではない y」がストレージに固定される点は同じだ。

ユーザーから見れば、「ウィジェットを1個置いただけなのに、二度と開けない壊れた拡張機能」になってしまう。サーバーを持たないクライアント完結型の拡張機能にとって、起動不能は致命傷を意味する。


ライブラリに任せず、自前で有限なY座標を算出する

対策の第一歩は、ライブラリの自動整列に Infinity を渡す処理を即刻廃止することだった。

新しいアイテムを末尾に追加するなら、既存のアイテム群から確実に有限な数値を自前で計算し、安全な座標として渡さなければならない。そこで作成したのが calculateBottomY 関数だ(src/utils/layout.ts)。

export function calculateBottomY(layouts: Layout[]): number {
  if (!Array.isArray(layouts) || layouts.length === 0) return 0;
  return layouts.reduce((maxY, item) => {
    const safeY = toSafeInt(item.y, 0);
    const safeH = Math.max(1, toSafeInt(item.h, 1));
    return Math.max(maxY, safeY + safeH);
  }, 0);
}

この処理では、既存の全アイテムについて「Y座標 + 高さ」を計算し、その中の最大値を割り出す。

ポイントは、参照する item.y や item.h 自体がすでに汚染されている可能性を考慮し、後述する安全化関数 toSafeInt を通している点だ。配列が空であれば 0 を返し、アイテムが存在すれば一番下の要素のすぐ下のY座標を返す。

ウィジェットを追加する際は、ブレークポイント(lg / md / sm / xs / xxs)ごとにこの関数を経由して明示的な数値を渡すように改めた。

// 修正後(src/store/slices/widgetSlice.ts)
const newLayout: Layout = {
  i: id,
  x: 0,
  y: calculateBottomY(layouts.lg || []),
  w: size.w,
  h: size.h,
  minW: size.minW,
  minH: size.minH,
};

const newLayouts: ResponsiveLayouts = {
  lg: [...(layouts.lg || []), newLayout],
  md: [...(layouts.md || []), { ...newLayout, y: calculateBottomY(layouts.md || []), w: Math.min(newLayout.w, 5) }],
  sm: [...(layouts.sm || []), { ...newLayout, y: calculateBottomY(layouts.sm || []), w: 6 }],
  // xs / xxs も同様
};

これによって、react-grid-layout の内部に Infinity が流れ込む経路を完全に塞いだ。


壊れたデータを起動時に治す「自己修復(Self-healing)」の設計

しかし、追加処理を直しただけでは不十分だ。

すでに手元のストレージに壊れたレイアウトを保存してしまっているユーザーは、更新版を受け取っても起動時のフリーズから抜け出せない。さらに、将来的なバージョンアップや設定ファイルのインポート時、外部から不正な座標が入り込むリスクは常に残る。

そこで、ストレージからデータを読み込む境界、書き込む境界、そしてアプリが初期化される箇所のすべてに、不正なデータを自動検知して正常値へ直すサニタイズ層を設けた。

export function toSafeInt(val: unknown, fallback: number = 0): number {
  if (typeof val !== 'number' || !Number.isFinite(val)) {
    return Math.max(0, Math.floor(fallback));
  }
  return Math.max(0, Math.floor(val));
}

export function sanitizeLayout(layout: any): Layout {
  if (!layout || typeof layout !== 'object') {
    return { i: 'widget-unknown', x: 0, y: 0, w: 4, h: 3 };
  }

  const minW = layout.minW !== undefined ? toSafeInt(layout.minW, 1) : undefined;
  const minH = layout.minH !== undefined ? toSafeInt(layout.minH, 1) : undefined;
  const maxW = layout.maxW !== undefined ? toSafeInt(layout.maxW, 12) : undefined;
  const maxH = layout.maxH !== undefined ? toSafeInt(layout.maxH, 100) : undefined;

  const w = Math.max(1, toSafeInt(layout.w, 4));
  const h = Math.max(1, toSafeInt(layout.h, 3));

  return {
    i: typeof layout.i === 'string' && layout.i ? layout.i : 'widget-unknown',
    x: toSafeInt(layout.x, 0),
    y: toSafeInt(layout.y, 0),
    w,
    h,
    ...(minW !== undefined ? { minW } : {}),
    ...(minH !== undefined ? { minH } : {}),
    ...(maxW !== undefined ? { maxW } : {}),
    ...(maxH !== undefined ? { maxH } : {}),
    ...(layout.static !== undefined ? { static: Boolean(layout.static) } : {}),
    ...(layout.isDraggable !== undefined ? { isDraggable: Boolean(layout.isDraggable) } : {}),
    ...(layout.isResizable !== undefined ? { isResizable: Boolean(layout.isResizable) } : {}),
  };
}

処理内容は極めて泥臭い。

受け取った値が数値でない場合(null もここに含まれる)や、NaN、Infinity、負の数であった場合、例外を投げて処理を中断するのではなく、安全な既定値(fallback)へと強制的に丸め込む。

さらに、このサニタイズ処理をレスポンシブなブレークポイント全体(lg, md, sm, xs, xxs)へ適用する sanitizeResponsiveLayouts を通すことで、壊れた座標を含むデータがストレージから読み出されても、メモリ上で瞬時に安全な座標へと置換されるようにした。

export function sanitizeResponsiveLayouts(layouts: any): ResponsiveLayouts {
  const sanitizeList = (list: any): Layout[] => {
    if (!Array.isArray(list)) return [];
    return list.map(sanitizeLayout);
  };

  if (!layouts || typeof layouts !== 'object') {
    return { lg: [], md: [], sm: [], xs: [], xxs: [] };
  }

  return {
    lg: sanitizeList(layouts.lg),
    md: sanitizeList(layouts.md),
    sm: sanitizeList(layouts.sm),
    xs: sanitizeList(layouts.xs),
    xxs: sanitizeList(layouts.xxs),
  };
}

3つの境界すべてにサニタイズを置く

このサニタイズは「どこか1か所」ではなく、データが通過する境界すべてに配置している(src/services/storageService.ts)。

境界 処理
読み込み getLayouts() / getPagesState() がストレージから読んだ直後に sanitizeResponsiveLayouts / sanitizeLayout を通す
書き込み saveLayouts() / savePageData() がストレージへ書く直前にも同じ関数を通す(壊れた値を二度と永続化しない)
初期化 ストアの initialize() がアクティブページのレイアウトを set() する直前にもう一度サニタイズする
インポート エクスポート JSON を取り込む importDashboardData() も同じ関数を通す
// storageService.ts(読み込み側)
async getLayouts(fallback: ResponsiveLayouts = DEFAULT_LAYOUTS): Promise<ResponsiveLayouts> {
  const layouts = await storageGet<ResponsiveLayouts>(STORAGE_KEYS.LAYOUTS, fallback);
  return sanitizeResponsiveLayouts(layouts || fallback);
},

// storageService.ts(書き込み側)
async saveLayouts(layouts: ResponsiveLayouts): Promise<void> {
  await storageSet(STORAGE_KEYS.LAYOUTS, sanitizeResponsiveLayouts(layouts));
},

読み込み時に補正された値はメモリ上で使われ、ユーザーが次に何か操作した時点の保存で(書き込み側のサニタイズを経て)健全な形に上書きされる。起動時にわざわざストレージを書き戻すステップは設けていない——書き込み境界にも同じ防壁があるので、その必要がないからだ。

これにより、過去の不具合で壊れてしまったユーザー環境でも、アップデート後の初回起動時に自動でデータが修復され、何事もなかったかのようにダッシュボードが立ち上がるようになった。

image.png


もう1つのフリーズ:ページ削除と編集モード解除の競合

同じ Issue にはもう1つの再現手順があった。「編集モード中にアクティブなページを削除するとフリーズする」というものだ。

こちらは ストアの更新と他タブ同期の競合 が引き金だった。removePage はアクティブページを削除するときに isEditMode: false へ切り替える。一方、複数タブ間でストレージを同期する useStorageSync フックは「編集モードが終わった瞬間」を購読し、保留していた再読み込み(syncFromStorage())を即座に実行する。ページ削除の非同期なストレージ書き込みが完了する前にこの再読み込みが走ると、削除前後が入り混じった中間状態——そこには先ほどの y: null を含むレイアウトも含まれる——をストアへ読み戻してしまい、同じコンパクションのループへ突入していた。

つまり根は同じで、「ストレージから読んだ値をそのまま信じる」ことが問題だった。読み込み境界のサニタイズを syncFromStorage() の経路(getPagesState())にも効かせたことで、どのタイミングで再読み込みが走っても不正な座標がグリッドに届かなくなり、ページ削除時のフリーズも同時に解消した。同じコミットで、対応するストアのテスト(編集モード中のページ削除)も追加している。


壊れたデータに対する単体テストの記述

この手の防御的ロジックは、異常値のパターンを網羅した単体テストがあって初めて機能する。Vitestで記述したテストコード(tests/unit/utils/layout.test.ts)の一部が以下だ。

describe('layout utils', () => {
  describe('toSafeInt', () => {
    it('正の整数はそのまま返し、小数は切り捨てる', () => {
      expect(toSafeInt(5)).toBe(5);
      expect(toSafeInt(100.7)).toBe(100);
    });

    it('負の数は0へクランプする', () => {
      expect(toSafeInt(-5)).toBe(0);
      expect(toSafeInt(-0.1)).toBe(0);
    });

    it('Infinity, NaN, null, undefined をフォールバック値へ変換する', () => {
      expect(toSafeInt(Infinity)).toBe(0);
      expect(toSafeInt(-Infinity)).toBe(0);
      expect(toSafeInt(NaN)).toBe(0);
      expect(toSafeInt(null)).toBe(0);
      expect(toSafeInt(undefined)).toBe(0);
      expect(toSafeInt(Infinity, 4)).toBe(4);
    });
  });

  describe('sanitizeLayout', () => {
    it('nullやInfinityを含む破損オブジェクトを安全な数値に置換する', () => {
      const corrupt = { i: 'widget-1', x: null, y: Infinity, w: NaN, h: undefined };

      const sanitized = sanitizeLayout(corrupt);
      expect(sanitized.i).toBe('widget-1');
      expect(sanitized.x).toBe(0);
      expect(sanitized.y).toBe(0);
      expect(sanitized.w).toBe(4);
      expect(sanitized.h).toBe(3);
      expect(Number.isFinite(sanitized.y)).toBe(true);
    });
  });
});

ストア側のテストでは「ウィジェットを追加した後のすべてのレイアウト座標が有限であること」「壊れたレイアウトを読み込んでも初期化が完了すること」も検証している。どんな不正値が投げ込まれても例外でプロセスを落とさず、決められた有限整数を返すことをテストで担保する。この網羅性があるからこそ、本番環境での自己修復を信頼できる。


クライアント完結アプリだからこそ求められる防御姿勢

Webアプリケーションの開発では、フロントエンドの入力チェックだけでなく、バックエンドのデータベース側で型や制約を課すのが通例だ。データの不整合が起きても、サーバー側でマイグレーションスクリプトを実行して一括修正できる。

しかし、バックエンドサーバーを持たないブラウザ拡張機能やローカルファーストなツールでは、ユーザーの手元にあるブラウザストレージこそが唯一のデータベースだ。開発者が直接中身を覗くことも、SQLを流して一斉に直すこともできない。

今回のトラブルから得た教訓は2つある。

1つは、外部UIライブラリの「よしなにやってくれる引数」を過信しないこと。ドキュメントに記載された便利なショートカットが、Infinity - 1 === Infinity のような言語仕様と組み合わさったとき、何が起きるかを常に警戒する必要がある。

もう1つは、ローカルストレージとの入出力境界に必ず防御壁を築くことだ。データが破損する可能性を前提に組み、不正な値を検知した瞬間に自律的に正常値へと修復する仕組みを用意しておく。JSON が Infinity を null に変えてしまうように、「メモリ上の値」と「保存された値」は同じとは限らない。

一見すると過剰に思えるほどの泥臭いサニタイズ処理こそが、アップデートを重ねても絶対に壊れないローカルツールの信頼性を支えている。


さいごに:ZenithTabについて

本記事で紹介した設計やトラブルシューティングの知見は、すべて自作の新しいタブChrome拡張機能「ZenithTab(ゼニスタブ)」の開発を通して得られたものです。

ZenithTabは、「ブラウザを開くたびに心地よく、作業に集中できる」をコンセプトにした、完全ローカル完結・プライバシー重視のダッシュボード拡張機能です。

  • 自由なグリッド配置: 時計、天気、カレンダー、RSSリーダー、集中タイマー、習慣トラッカー、メモなど、多彩なウィジェットをグリッド上で自由に配置
  • 安心のローカル完結: 外部サーバーへのデータ送信は一切行わず、すべての設定やメモはブラウザ内に安全に保存
  • 細部へのこだわり: ガラスモーフィズム(すりガラス調UI)、ダイナミック壁紙、軽快なキーボードショートカット、そして万が一の誤操作を防ぐ「元に戻す(Ctrl+Z)」や自己修復機能を完備

Chromeウェブストアで無料公開しています。日々の作業効率化や、技術的なUI/UXの触感のお試しとして、ぜひ気軽に使ってみてください!

ソースコードはGitHubで公開しています。「面白い」「役に立った」と思っていただけたら、GitHubのスター(⭐️) や記事への いいね / ストック をいただけると、開発の大きな励みになります!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?