Rebounder Tech Blog

Written by the people who actually run these systems in production.

cqw Fails Silently When the DOM Around It Changes

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

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

結論

CSS container-query units (cqw) are silently disabled, with no error, once the referenced container's width can no longer be determined uniquely because the surrounding DOM structure changed.

The short version

When an element is scaled with CSS container-query units (cqw), and a change to the surrounding DOM leaves the referenced container’s width no longer uniquely determined, cqw breaks silently with no error. The element that should have been scaled goes into the layout at full size, and whatever overflows is simply clipped. The fix was to measure the container’s real width with ResizeObserver and pass an explicit width to the scaled component, ending the dependence on cqw.

What it looks like

The “edit board” tab of the teacher-facing class editor has a live preview with the same layout as the real signage (a 50-inch classroom TV, 16:9, fixed at 1280×720). The preview is shrunk with transform: scale(), and with no width given, the default scale was calc(100cqw / 1280) — a calculation based on cqw (container query width), with the enclosing .frame carrying container-type: inline-size so it tracked the frame’s width.

On 2026-06-16 a report came in that this board preview was cut off on the right (the ad area) and along the bottom. It was a layout break with the build passing and the tests passing.

Why

An immediately preceding PR (#967) had removed the board wrapper (.boardLayer) enclosing the preview, changing the structure so that the scaling component (ScaledSignageBoard, whose .frame carries container-type: inline-size) sat directly under an element called .canvas. That structural change left the width of the container .frame references (its parent) context-dependent and no longer uniquely determined, and the cqw calculation collapsed.

cqw is a unit resolved from the measured width of a container-query ancestor, and placed in a situation where the referenced width itself cannot be settled, the browser raises no exception and proceeds with the broken value. As a result --sb-scale (calc(100cqw / 1280)) stopped returning the correct scale, .stage (the stage fixed at 1280×720) went into .frame’s 16:9 box at nearly full size unscaled, and the right and bottom that overflow: hidden on the frame could not contain were clipped.

The component, WysiwygBoardEditor.tsx, carries this comment from the fix.

// Auto-adjust the preview board's scale to the container width. The board is fixed at
// 1280×720 and shrunk with transform:scale, but CSS container-query (cqw) is context-dependent
// and can stop working, causing an incident where it entered the frame at full size and the
// right and bottom were cut off (surfaced by the board wrapper change after #967). So measure
// the frame's real width with ResizeObserver and pass an explicit width to `ScaledSignageBoard`
// to fit 16:9 deterministically (no cqw dependence). It also tracks window/layout changes.

The awkward part is that a break of this kind is neither a build error nor a test failure. The CSS stays syntactically valid to the end, and the state of “not working” simply appears, quietly, in the rendered result. Review of a PR that changes DOM structure easily misses that the change affects container-query resolution in a different CSS file somewhere else.

Fixing it

We stopped tracking the container width with cqw and moved to a deterministic approach: read the frame’s measured width directly with ResizeObserver and pass an explicit width to the scaling component.

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();
  // Where ResizeObserver is unavailable (jsdom tests etc.), stop after one measurement
  // (avoids a throw). In a real browser it tracks resize/layout changes and re-adjusts the scale.
  if (typeof ResizeObserver === "undefined") {
    return;
  }
  const ro = new ResizeObserver(measure);
  ro.observe(el);
  return () => ro.disconnect();
}, []);

The measured boardWidth is passed straight to the component’s width prop.

<div ref={canvasRef} className={styles.canvas}>
  {/* Pass the frame's real width as an explicit width, fitting 16:9 reliably with no cqw
      dependence (clears the right/bottom clipping). */}
  {boardWidth != null ? (
    <ScaledSignageBoard
      payload={previewPayload}
      width={boardWidth}
      editRegions={{ active, onRegion: focusRegion }}
    />
  ) : null}
</div>

Given a width, ScaledSignageBoard overrides --sb-scale inline with a JavaScript number, width / 1280, rather than the cqw expression (the component already had that path; all that changed was the caller’s decision to pass width). That cuts the scale calculation loose from the premise “container queries resolve correctly under any DOM structure”.

One more point: because ResizeObserver measures asynchronously, the board shifts for an instant between the first render and boardWidth being settled. To prevent that, the frame’s CSS gained aspect-ratio: 16 / 9, reserving an area at the correct ratio before the width measurement completes.

/* The preview (board) frame. Shows the board's 16:9 as-is, floated with a shadow.
   Region clicks are handled by the real sections inside the board. */
.canvas {
  width: 100%;
  max-width: 1100px;
  margin: 0 auto;
  /* The board is 16:9. Reserve the ratio on the frame side too, preventing the layout shift
   * between measuring the width and rendering at an explicit width (the inner
   * ScaledSignageBoard enters at the same ratio and real width, so it fits exactly). */
  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);
}

In environments without ResizeObserver (jsdom and similar), it guards with the single initial measurement and skips starting the observer, avoiding an exception. The fix is closed to the editor’s preview display; neither the real signage TVs nor the board scaling component itself was touched.

Preventing a repeat

The fix commit’s message records that this defect is not an isolated incident but shares a root with a defect elsewhere (the signage listing view) arising from the same use of cqw. Anywhere cqw is used as the primary means of deciding layout has to be held on the premise that a layout break can occur in any PR that changes DOM structure. This fix moved the editor’s preview onto a deterministic approach — ResizeObserver plus an explicit width — making that path structurally robust against changes in the surrounding DOM.

よくある質問

Q1Why does cqw stop working when .frame's width is undetermined?

cqw references 1% of the measured width of an ancestor carrying container-type: inline-size. Put .frame somewhere its width cannot be uniquely decided — inside a grid, say — and the referenced measurement becomes indeterminate, so the cqw result collapses. CSS does not error.

Q2How did you notice cqw had stopped working?

A teacher reported how it looked: the board preview's right and bottom edges were cut off. The build and tests still passed and only the layout was broken, with no console warning. A failed container-query resolution is not a JS exception, so nobody could detect it until someone looked.

Q3How is passing an explicit width via ResizeObserver different from cqw?

cqw is indirect: the value exists only once the browser resolves the container's context. ResizeObserver reads the element's measured width (clientWidth) directly and passes it as the width prop, so the scale calculation no longer depends on container resolution.

確認した環境

  • Next.js 16.2.6 / React 19.2.6
  • Found and fixed the same day on a production-equivalent teacher screen, 2026-06-16 (#970)

この記事の根拠

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

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