From b1e5287351b43a347957e0b7a32d4c81d00ba11b Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 4 Sep 2026 22:44:48 +0200 Subject: [PATCH] fix(gallery): give the Grid layout a lazy-loading pre-load band (#1287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../gallery/layouts/GridGalleryLayout.tsx | 18 ++++++ .../layouts/__tests__/gridLazyMargin.test.ts | 59 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 frontend/src/components/gallery/layouts/__tests__/gridLazyMargin.test.ts diff --git a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx index 544154ca..c1df4fe9 100644 --- a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx @@ -84,6 +84,24 @@ const GridPhoto: React.FC = ({ onToggleSelect={onToggleSelect} className={`photo-card relative group cursor-pointer aspect-square ${animationClass}`} 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'} skeletonClassName="skeleton aspect-square w-full rounded-lg" imageProps={{ diff --git a/frontend/src/components/gallery/layouts/__tests__/gridLazyMargin.test.ts b/frontend/src/components/gallery/layouts/__tests__/gridLazyMargin.test.ts new file mode 100644 index 00000000..13fc77c2 --- /dev/null +++ b/frontend/src/components/gallery/layouts/__tests__/gridLazyMargin.test.ts @@ -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=/); + } + }); +});