Merge pull request #1295 from PicPeak/fix/post-merge-followups
fix(gallery): image-loading follow-ups — pre-load band, decode release, sanitizer dedup
This commit is contained in:
@@ -78,14 +78,16 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
// Draw image to canvas when canvas rendering is enabled
|
||||
// Returns whether the pixels actually made it onto the canvas, so the
|
||||
// caller knows if the source image is still needed (#1287).
|
||||
const drawToCanvas = useCallback(() => {
|
||||
if (!useCanvasRendering || !canvasRef.current || !imageRef.current) return;
|
||||
if (!useCanvasRendering || !canvasRef.current || !imageRef.current) return false;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const img = imageRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx || !img.complete || img.naturalWidth === 0) return;
|
||||
if (!ctx || !img.complete || img.naturalWidth === 0) return false;
|
||||
|
||||
// Set canvas dimensions to match image
|
||||
canvas.width = img.naturalWidth;
|
||||
@@ -95,6 +97,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
setCanvasReady(true);
|
||||
return true;
|
||||
}, [useCanvasRendering]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -251,7 +254,26 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
drawToCanvas();
|
||||
const drawn = drawToCanvas();
|
||||
// Once drawImage has copied the pixels into the canvas the source
|
||||
// decode is dead weight, so drop it here rather than at unmount. The
|
||||
// grid is not virtualised — a 546-photo event mounts 546 of these and
|
||||
// none of them unmount while the gallery is open — so a cleanup-only
|
||||
// release never actually runs for the case it was meant to fix
|
||||
// (#1287). Nothing redraws from `imageRef` afterwards: drawToCanvas
|
||||
// has this one caller.
|
||||
if (drawn) {
|
||||
// Handlers off BEFORE the src goes. Measured in Chromium and WebKit:
|
||||
// neither fires `error` when the attribute is removed after a
|
||||
// successful load, so this is not fixing an observed bug — but if any
|
||||
// engine ever did, `onerror` would set canvasFailed, swap the canvas
|
||||
// for a plain <img>, and decode the image a second time, which is the
|
||||
// exact opposite of what this release is for. The ordering is free.
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
imageRef.current = null;
|
||||
img.removeAttribute('src');
|
||||
}
|
||||
onLoad?.();
|
||||
};
|
||||
|
||||
@@ -266,6 +288,17 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
return () => {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
// Fallback release for the paths the onload handler above cannot
|
||||
// cover: the draw failed, or the source changed / the component
|
||||
// unmounted before onload ever fired. `imageRef` is what drawToCanvas
|
||||
// reads and it was never cleared, so a detached Image — and the decode
|
||||
// behind it — stayed pinned by a live JS reference. A decoded <img> in
|
||||
// the document is evictable under memory pressure; one held by a ref
|
||||
// is not.
|
||||
if (imageRef.current === img) {
|
||||
imageRef.current = null;
|
||||
}
|
||||
img.removeAttribute('src');
|
||||
};
|
||||
}, [imageSrc, useCanvasRendering, drawToCanvas, onLoad]);
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Canvas-mode memory release (#1287).
|
||||
*
|
||||
* In canvas mode the component keeps a detached `Image` in `imageRef` so
|
||||
* `drawToCanvas` can read it. The effect cleanup nulled `onload`/`onerror`
|
||||
* but never cleared that ref, so the Image — and the decoded bitmap behind
|
||||
* it — stayed pinned by a live JS reference for the component's lifetime.
|
||||
*
|
||||
* That is not academic at gallery scale. The photo grid is NOT virtualised:
|
||||
* a 546-photo event mounts 546 of these and none ever unmount, so nothing was
|
||||
* ever released. A decoded <img> in the document is evictable under memory
|
||||
* pressure; one held by a ref is not.
|
||||
*/
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('../../../utils/galleryAuthStorage', () => ({
|
||||
getActiveGallerySlug: () => 'demo',
|
||||
getGalleryToken: () => 'token',
|
||||
inferGallerySlugFromLocation: () => 'demo',
|
||||
resolveSlugFromRequestUrl: () => 'demo',
|
||||
}));
|
||||
vi.mock('../../../utils/url', () => ({ buildResourceUrl: (u: string) => `http://localhost${u}` }));
|
||||
|
||||
import { AuthenticatedImage } from '../AuthenticatedImage';
|
||||
|
||||
/** Every Image the component constructs, so the test can inspect them. */
|
||||
const created: HTMLImageElement[] = [];
|
||||
let createObjectURL: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURL: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
created.length = 0;
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: true,
|
||||
blob: async () => new Blob(['x'], { type: 'image/png' }),
|
||||
})));
|
||||
// Patch the two methods rather than replacing URL — spreading the
|
||||
// constructor loses its prototype and breaks every `new URL(...)`.
|
||||
// Unique per call: the canvas effect keys off `imageSrc`, so a constant
|
||||
// URL would make a src change look like no change at all.
|
||||
let n = 0;
|
||||
createObjectURL = vi.fn(() => `blob:mock-url-${++n}`);
|
||||
revokeObjectURL = vi.fn();
|
||||
URL.createObjectURL = createObjectURL as unknown as typeof URL.createObjectURL;
|
||||
URL.revokeObjectURL = revokeObjectURL as unknown as typeof URL.revokeObjectURL;
|
||||
|
||||
const RealImage = globalThis.Image;
|
||||
vi.stubGlobal('Image', class extends RealImage {
|
||||
constructor() {
|
||||
super();
|
||||
created.push(this as unknown as HTMLImageElement);
|
||||
// jsdom leaves these at 0/false for a blob: src, which makes
|
||||
// drawToCanvas bail before it draws. Present a decoded image so the
|
||||
// draw path is reachable.
|
||||
Object.defineProperty(this, 'complete', { get: () => true });
|
||||
Object.defineProperty(this, 'naturalWidth', { get: () => 10 });
|
||||
Object.defineProperty(this, 'naturalHeight', { get: () => 10 });
|
||||
// jsdom never fires load for a blob: src, so drive it manually.
|
||||
setTimeout(() => this.onload?.(new Event('load')), 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('AuthenticatedImage canvas mode', () => {
|
||||
it('releases the decoded image as soon as it is drawn, without waiting for unmount', async () => {
|
||||
// The case this whole change exists for. Every other test here asserts
|
||||
// release on unmount or src change — neither of which happens to a grid
|
||||
// tile, because the grid is not virtualised and the tiles stay mounted
|
||||
// for as long as the gallery is open. Once drawImage has copied the
|
||||
// pixels the source decode is dead weight and must go immediately.
|
||||
const ctx = { drawImage: vi.fn() };
|
||||
const getContext = vi
|
||||
.spyOn(HTMLCanvasElement.prototype, 'getContext')
|
||||
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D);
|
||||
|
||||
render(
|
||||
<AuthenticatedImage src="/api/gallery/demo/thumbnail/1" alt="t" useCanvasRendering />
|
||||
);
|
||||
|
||||
await waitFor(() => expect(created.length).toBeGreaterThan(0));
|
||||
const img = created[0];
|
||||
|
||||
await waitFor(() => expect(ctx.drawImage).toHaveBeenCalled());
|
||||
// Still mounted, still the same src — and already released.
|
||||
await waitFor(() => expect(img.getAttribute('src')).toBeNull());
|
||||
|
||||
getContext.mockRestore();
|
||||
});
|
||||
|
||||
it('releases the decoded image on unmount', async () => {
|
||||
const { unmount } = render(
|
||||
<AuthenticatedImage src="/api/gallery/demo/thumbnail/1" alt="t" useCanvasRendering />
|
||||
);
|
||||
|
||||
await waitFor(() => expect(created.length).toBeGreaterThan(0));
|
||||
const img = created[0];
|
||||
|
||||
unmount();
|
||||
|
||||
// The src is dropped so the browser can reclaim the decode without
|
||||
// waiting for GC, and the handlers are detached.
|
||||
expect(img.getAttribute('src')).toBeNull();
|
||||
expect(img.onload).toBeNull();
|
||||
expect(img.onerror).toBeNull();
|
||||
});
|
||||
|
||||
it('revokes the blob URL on unmount', async () => {
|
||||
const { unmount } = render(
|
||||
<AuthenticatedImage src="/api/gallery/demo/thumbnail/1" alt="t" useCanvasRendering />
|
||||
);
|
||||
|
||||
await waitFor(() => expect(created.length).toBeGreaterThan(0));
|
||||
unmount();
|
||||
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url-1');
|
||||
});
|
||||
|
||||
it('releases the previous image when the src changes', async () => {
|
||||
// A recycled tile (a layout reusing a component instance for a different
|
||||
// photo) must not accumulate one pinned decode per photo it has shown.
|
||||
const { rerender } = render(
|
||||
<AuthenticatedImage src="/api/gallery/demo/thumbnail/1" alt="t" useCanvasRendering />
|
||||
);
|
||||
await waitFor(() => expect(created.length).toBe(1));
|
||||
const first = created[0];
|
||||
|
||||
rerender(<AuthenticatedImage src="/api/gallery/demo/thumbnail/2" alt="t" useCanvasRendering />);
|
||||
await waitFor(() => expect(created.length).toBe(2));
|
||||
|
||||
expect(first.getAttribute('src')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,24 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
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={{
|
||||
|
||||
@@ -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=/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user