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=/); + } + }); +});