fix(gallery): give the Grid layout a lazy-loading pre-load band (#1287)

Grid was the only layout passing `lazy` without an `inViewRootMargin`, so
PhotoCard ran its observer at the IntersectionObserver default of `0px`
with `threshold: 0.1`. A tile could not begin loading until a tenth of it
was already on screen — there was no lead at all.

The gallery owner's account of the symptom is that defect's exact shape:
spinning the scroll wheel outran loading by roughly 50 images, then it
caught up. Outrun-then-recover is what a zero-width pre-load band looks
like from a chair.

This is the one thing in that investigation that does not rest on the
reporter's instrumented runs, which they have since withdrawn after
finding their automation harness ran in a hidden pane — `innerHeight: 0`,
so nothing could intersect and no tile could ever load. The missing
margin is visible in the source regardless.

Percent, not vh. `rootMargin` accepts only px and percentages, and a `vh`
value throws SyntaxError at construction, which would have taken down
every Grid gallery. Verified in Chrome:

  '100% 0px'  → accepted
  '100px 0px' → accepted
  '100vh 0px' → SyntaxError: rootMargin must be specified in pixels or percent

A percentage resolves against the root's own box, so 100% is one viewport
height of lead in each direction — viewport-relative, which a fixed 100px
like Justified's is not. A phone and a 4K desktop scroll past very
different amounts of grid per gesture.

Deliberately NOT included: a sweep for cards left un-loaded after
scrolling settles. That was aimed at permanent loss from `triggerOnce`,
and the owner's observation that tiles do come back on desktop argues
against it. Complexity chasing a symptom nobody has reproduced outside a
broken harness.

Three guard tests, including one on the unit, since the failure mode of
getting that wrong is a gallery that does not render at all.
This commit is contained in:
Paul Nothaft
2026-09-04 22:44:48 +02:00
parent be8d79e9c4
commit b1e5287351
2 changed files with 77 additions and 0 deletions
@@ -84,6 +84,24 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
onToggleSelect={onToggleSelect} onToggleSelect={onToggleSelect}
className={`photo-card relative group cursor-pointer aspect-square ${animationClass}`} className={`photo-card relative group cursor-pointer aspect-square ${animationClass}`}
lazy lazy
/*
* Pre-load band (#1287). Grid was the only lazy layout passing no
* `inViewRootMargin`, so PhotoCard ran the observer at the
* IntersectionObserver default of 0px with threshold 0.1 — a tile could
* not begin loading until a tenth of it was already on screen. The
* gallery owner's description of the symptom is that exact shape:
* spinning the wheel outran loading by ~50 images, then it caught up.
*
* Viewport-relative rather than a fixed 100px like Justified: a phone
* and a 4K desktop scroll past very different amounts of grid per
* gesture, and a band tuned to one is wrong for the other.
*
* `%`, not `vh` — rootMargin only accepts px and percentages, and an
* IntersectionObserver constructed with a vh value throws. A percentage
* resolves against the root's own box, so 100% is one viewport height
* of lead in each direction, which is what vh would have meant.
*/
inViewRootMargin="100% 0px"
fadeInWhenVisible={animationType === 'fade'} fadeInWhenVisible={animationType === 'fade'}
skeletonClassName="skeleton aspect-square w-full rounded-lg" skeletonClassName="skeleton aspect-square w-full rounded-lg"
imageProps={{ imageProps={{
@@ -0,0 +1,59 @@
/**
* Grid's lazy-loading pre-load band (#1287).
*
* Grid was the only layout passing `lazy` without an `inViewRootMargin`, so
* PhotoCard ran its observer at the IntersectionObserver default of `0px`
* with `threshold: 0.1` — a tile could not begin loading until a tenth of it
* was already on screen. The gallery owner described exactly that: spinning
* the scroll wheel outran loading by ~50 images before it caught up.
*
* The unit matters as much as the value. `rootMargin` accepts only px and
* percentages; an IntersectionObserver constructed with a `vh` value throws
* SyntaxError, which would have broken every Grid gallery outright. Verified
* in Chrome:
*
* '100% 0px' → accepted
* '100px 0px' → accepted
* '100vh 0px' → SyntaxError: rootMargin must be specified in pixels or percent
*
* jsdom has no IntersectionObserver, so this asserts against the source
* rather than constructing one.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
const layouts = resolve(__dirname, '..');
const read = (f: string) => readFileSync(resolve(layouts, f), 'utf8');
/** Only px and % are legal rootMargin units. */
const LEGAL_ROOT_MARGIN = /^(-?\d+(px|%)|0)(\s+(-?\d+(px|%)|0)){0,3}$/;
describe('grid lazy pre-load band', () => {
it('Grid passes an inViewRootMargin', () => {
expect(read('GridGalleryLayout.tsx')).toMatch(/inViewRootMargin=/);
});
it('every inViewRootMargin in every layout uses a legal unit', () => {
// A vh value throws at IntersectionObserver construction and takes the
// whole gallery down with it, so this guards the unit, not just presence.
for (const file of ['GridGalleryLayout.tsx', 'JustifiedGalleryLayout.tsx']) {
const src = read(file);
for (const [, value] of src.matchAll(/inViewRootMargin="([^"]+)"/g)) {
expect(value, `${file}: "${value}"`).toMatch(LEGAL_ROOT_MARGIN);
}
}
});
it('every layout that lazy-renders also declares a pre-load band', () => {
// The defect was Grid being lazy with no margin. Any future layout that
// opts into `lazy` and forgets the margin reintroduces it.
for (const file of ['GridGalleryLayout.tsx', 'JustifiedGalleryLayout.tsx']) {
const src = read(file);
const isLazy = /^\s*lazy\s*$/m.test(src) || /\slazy=\{?true/.test(src);
if (!isLazy) continue;
expect(src, `${file} is lazy but declares no inViewRootMargin`)
.toMatch(/inViewRootMargin=/);
}
});
});