Skip to content

ui-core: the gallery renders one tile per row at every width — the ResizeObserver is never attached #159

Description

@JArmandoAnaya

Found by manual testing of the shipped 0.0.1b1 wheel.

What happens

The asset gallery renders one tile per row at every viewport width, so a batch of six 160-px thumbnails occupies a single 160-px column with the rest of the pane empty, inside a 70vh scroll box. Resizing the window does not change it.

The row elements are laid out correctly (flex w-full, 1239 px wide) and each contains exactly one tile.

Reproduction

Ingest any batch, open it at /ui/projects/{projectId}/batches/{batchId}, and read the DOM:

const rows = [...document.querySelectorAll('[data-testid^="gallery-row-"]')];
const scroll = document.querySelector('[data-testid="gallery-scroll"]');
({
  scrollerWidth: scroll.clientWidth,                                        // 895
  tilesPerRow: rows.map(r => r.children.length),                            // [1,1,1,1,1,1]
  expectedColumns: Math.floor((scroll.clientWidth + 12) / (160 + 12)),      // 5
})

Measured at two widths: 1600 px viewport → scroller 1280 px → 7 columns expected, 1 rendered. 1200 px viewport → scroller 895 px → 5 expected, 1 rendered.

Root cause

frontend/ui-core/src/screens/GalleryScreen.tsx:194-212.

useColumns initialises columns to 1 and attaches its ResizeObserver in an effect:

const [columns, setColumns] = useState(1);
const measure = useCallback(() => { ... }, [ref]);
useEffect(() => {
  measure();
  const element = ref.current;
  if (element === null || typeof ResizeObserver === "undefined") return;   // <- returns here
  const observer = new ResizeObserver(measure);
  observer.observe(element);
  return () => observer.disconnect();
}, [ref, measure]);

The scroller that scrollRef points at is rendered inside <Async>'s children render-prop (line 101-115), so it does not exist until the query resolves. On mount, <Async> is showing skeleton rows and ref.current is null — the effect takes the early return, and no observer is ever attached. Both dependencies are stable (ref is a RefObject, measure is a useCallback over [ref]), so the effect never runs again once the real element mounts. columns stays 1 for the life of the screen.

The arithmetic itself is correct — Math.floor((1239 + 12) / (160 + 12)) is 7. Only the measurement never happens.

The row virtualiser is unaffected because useVirtualizer takes getScrollElement: () => scrollRef.current as a callback and re-reads it, which is why rows virtualise correctly (total height 1032 = 6 × 172) while columns never recover. That contrast is the tell.

Why the tests did not catch it

useColumns' own docstring (lines 186-193) says the one-column fallback is "correct-but-slow rather than wrong, in an environment with no observer (jsdom, notably)". The unit tests in frontend/ui-core/src/screens/gallery.test.tsx therefore run in exactly the state the bug produces and pass. Nothing asserts a column count, and no Playwright spec covers this screen.

Suggested fix

Replace the useRef + effect with a callback ref, so measurement happens when the node actually attaches and cannot be missed:

const [element, setElement] = useState<HTMLDivElement | null>(null);
// pass setElement as ref={...}; observe `element` in an effect keyed on it

useVirtualizer's getScrollElement then returns the same state value. An element-keyed effect re-runs on mount and unmount by construction, which is what the current [ref, measure] deps only appear to do.

Acceptance criteria

  1. At a viewport wide enough for N tiles, a row contains N tiles; a Playwright spec asserts this against a real layout, since a jsdom test structurally cannot (getBoundingClientRect returns zeros — the same reason annotator: React adapter — SVG render, zoom/pan, asset image, annotation layer #47 kept its risky logic pure).
  2. Narrowing the window re-flows the grid, proving the observer is attached.
  3. A unit test pins that the fallback is reached only when ResizeObserver is genuinely absent, so the jsdom path stays honest rather than hiding a regression.
  4. Row virtualisation still works: the DOM holds only the visible rows plus overscan.

Scope notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    beta20.0.1-beta.2 — beta defect fixesbugSomething isn't workingfrontendannotator / ui-core / app packages

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions