Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

cqw(container-query)は、DOM構造の変更で黙って効かなくなる

公開 読了時間 約6分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

CSSのcontainer-query単位(cqw)は、参照元コンテナの幅が周辺DOM構造の変更で一意に定まらなくなると、エラーを出さずに黙って無効化される。

結論

CSSのcontainer-query単位(cqw)で要素を縮小していると、周辺のDOM構造が変わって参照元コンテナの幅が一意に定まらなくなったとき、cqwはエラーを出さずに黙って崩れる。 縮小されるはずだった要素は原寸のままレイアウトへ入り、はみ出した分がそのままクリップされる。直し方は、コンテナの実測幅をResizeObserverで計測し、縮小対象のコンポーネントへ明示的なwidthを渡してcqw依存をやめることだった。

症状

教員向けクラスエディタの「盤面を編集」タブに、実機のサイネージ(教室の50インチTV・16:9・1280×720固定)と同一レイアウトのライブプレビューがある。このプレビューはtransform: scale()で縮小して表示するが、width未指定時の既定スケールはcalc(100cqw / 1280)というcqw(container query width)ベースの計算式で、囲みの.framecontainer-type: inline-sizeを張ることで枠幅に追従する作りになっていた。

2026-06-16、この盤面プレビューの右側(広告エリア)と下側が切れて表示されるという報告が入った。ビルドは通り、テストも通っている状態でのレイアウト崩れだった。

原因

直前の別PR(#967)で、プレビューを囲んでいた盤面ラッパ(.boardLayer)が撤去され、.canvasという要素の直下に縮小コンポーネント(ScaledSignageBoard.framecontainer-type: inline-sizeを持つ)が直接置かれる構造に変わっていた。この構造変更によって、.frameが参照するコンテナ(親要素)の幅が文脈依存で一意に定まらなくなり、cqwの計算が崩れた。

cqwはcontainer queryの祖先要素の実測幅をもとに解決される単位で、参照先の幅自体が確定できない状況に置かれると、ブラウザは例外を出さず、崩れた値のままレイアウトを進める。結果として--sb-scalecalc(100cqw / 1280))が正しい縮小率を返さなくなり、.stage(1280×720固定のステージ)が縮小されずほぼ原寸のまま.frameの16:9枠に入り、枠のoverflow: hiddenでは収まりきらない右・下の部分がクリップされた。

コンポーネント側のWysiwygBoardEditor.tsxには、修正時に次のコメントが残されている。

// プレビュー盤面の縮小率をコンテナ幅に合わせて自動調整する。盤面は 1280×720 固定を transform:scale で縮小
// するが、CSS container-query(cqw)は文脈依存で効かない場合があり、原寸のまま枠に入って右・下が切れる事故が
// 起きた(#967 後の盤面ラッパ変更で顕在化)。そこで枠の実幅を ResizeObserver で計測し、`ScaledSignageBoard` に
// 明示 width を渡して決定的に 16:9 に収める(cqw 非依存)。ウィンドウ/レイアウト変化にも追従する。

厄介なのは、この種の崩れがビルドエラーにもテスト失敗にもならないことだ。CSSの構文としては最後まで有効なままで、「効かない」という状態がそのまま静かに描画結果へ現れる。DOM構造を変えるPRのレビューで、その変更が離れた場所にある別のCSSファイルのcontainer-query解決に影響することは見落とされやすい。

直し方

cqwによるコンテナ幅への追従をやめ、ResizeObserverで枠の実測幅を直接取得し、縮小コンポーネントへ明示的なwidthを渡す決定的な方式に変えた。

const canvasRef = useRef<HTMLDivElement>(null);
const [boardWidth, setBoardWidth] = useState<number | null>(null);
useEffect(() => {
  const el = canvasRef.current;
  if (!el) {
    return;
  }
  const measure = () => setBoardWidth(el.clientWidth);
  measure();
  // ResizeObserver 非対応環境(jsdom テスト等)では 1 回の計測のみで打ち切る(throw 回避)。本番ブラウザでは
  // リサイズ/レイアウト変化に追従して縮小率を再調整する。
  if (typeof ResizeObserver === "undefined") {
    return;
  }
  const ro = new ResizeObserver(measure);
  ro.observe(el);
  return () => ro.disconnect();
}, []);

計測したboardWidthは、そのままコンポーネントのwidthプロパティに渡す。

<div ref={canvasRef} className={styles.canvas}>
  {/* 枠の実幅を明示 width で渡し、cqw 非依存で確実に 16:9 へ収める(右・下のクリップ解消)。 */}
  {boardWidth != null ? (
    <ScaledSignageBoard
      payload={previewPayload}
      width={boardWidth}
      editRegions={{ active, onRegion: focusRegion }}
    />
  ) : null}
</div>

widthを受け取ったScaledSignageBoard側は、--sb-scaleをcqwの計算式ではなくwidth / 1280というJavaScript側の数値でインライン上書きする(コンポーネント自体はこの経路をもともと持っていた。今回変えたのは呼び出し側からwidthを渡すかどうかの判断だけ)。これにより、縮小率の計算が「container queryがどのDOM構造でも正しく解決されること」という前提から切り離される。

もう一点、ResizeObserverは計測が非同期のため、初回描画からboardWidthが定まるまでの一瞬、盤面がレイアウトシフトを起こす。これを防ぐため、枠側のCSSにaspect-ratio: 16 / 9を付けて、幅計測が終わる前から正しい比率の領域を確保するようにした。

/* プレビュー(盤面)枠。盤面の 16:9 をそのまま見せ、影で浮かせる。領域クリックは盤面内部の実セクションが担う。 */
.canvas {
  width: 100%;
  max-width: 1100px;
  margin: 0 auto;
  /* 盤面は 16:9。枠側でも比率を確保し、幅計測 → 明示 width 描画までのレイアウトシフトを防ぐ
   * (内側の ScaledSignageBoard も同比率・実幅で入るので枠にぴったり収まる)。 */
  aspect-ratio: 16 / 9;
  border-radius: 12px;
  overflow: hidden;
  box-shadow:
    0 1px 3px rgba(0, 0, 0, 0.12),
    0 8px 24px rgba(0, 0, 0, 0.08);
}

ResizeObserverが使えないテスト環境(jsdom等)では、初回の1回計測だけでガードし、監視の開始自体をスキップして例外を避けている。この修正はエディタのプレビュー表示だけに閉じており、実機のサイネージTVや、盤面の縮小コンポーネント自体には手を入れていない。

再発防止

修正コミットのメッセージには、この不具合が独立した事故ではなく、同じcqwの使い方に起因する別箇所(サイネージの一覧表示側)の不具合と根が同じであることが明記されている。cqwをレイアウトの主たる決定手段として使っている箇所は、DOM構造を変えるすべてのPRでレイアウト崩れが起こりうるという前提を持つ必要がある。今回の修正でエディタのプレビューはResizeObserver+明示widthという決定的な方式に切り替わり、この経路については周辺DOM構造の変更に対して構造的に頑健になった。

よくある質問

Q1cqwはなぜ.frameの幅が定まらないと効かなくなるのですか?

cqwはcontainer-type: inline-sizeを張った祖先要素の実測幅を1%刻みで参照する単位です。.frame自身がgridの中に置かれるなどして幅を一意に決められない文脈に置かれると、参照元の実測値自体が定まらなくなり、cqwの計算結果が崩れます。CSSはこれをエラーにせず、崩れた値のままレイアウトを続けます。

Q2cqwが効かなくなったことに、どうやって気づいたのですか?

教員からの見た目の報告(盤面プレビューの右と下が切れている)がきっかけでした。ビルドもテストも通ったままレイアウトだけが壊れており、コンソールにも警告は出ません。container-queryの解決失敗はJSの例外にならないため、実際に画面を見るまで検出できませんでした。

Q3ResizeObserverで明示widthを渡す方式は、cqwより何が違うのですか?

cqwはブラウザがコンテナの文脈を解決できて初めて値が決まる間接的な仕組みです。ResizeObserverは要素の実測幅(clientWidth)を直接読み取り、その値をコンポーネントのwidthプロパティとしてそのまま渡すため、周辺DOMがどう変わっても縮小率の計算がコンテナ解決に依存しません。

確認した環境

  • Next.js 16.2.6 / React 19.2.6
  • 2026-06-16 に本番相当の教員向け画面で発覚・同日修正(#970)

この記事の根拠

  • TypeScriptファイル 76〜99行目コミット 8aefde1
  • TypeScriptファイル 140〜150行目コミット 8aefde1
  • CSSファイル 18〜26行目コミット 8aefde1

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。