fix(cuesheet): avoid layout shifts when images scroll back into view

The cuesheet is a virtualised table: rows are unmounted once they leave
the viewport, taking the <img> elements with them. On the way back the
image has no dimensions until the browser makes it available, so the row
grows under the user as it loads.

We now remember the aspect ratio of the images we have seen and use it to
reserve the space the image will take. This holds two numbers per image:
the image data itself is left to the browser cache, which is better
placed than us to decide when memory should be released.

Also drops the lazy loading attribute: the row is only mounted when it is
already close to the viewport, so it only adds a gate before the request.

Whether the image is fetched again on scroll back is decided by the cache
headers of the host serving it, and cannot be worked around from here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKXcDrZoQXbJpaXiLi1aff
This commit is contained in:
Claude
2026-07-25 19:28:58 +00:00
parent 5cf36f049a
commit 7ec1179ede
3 changed files with 106 additions and 2 deletions
@@ -0,0 +1,38 @@
import { describe, expect, test } from 'vitest';
import { getRememberedAspectRatio, rememberAspectRatio } from '../imageDimensions';
/** stand-in for a loaded HTMLImageElement */
function makeImage(naturalWidth: number, naturalHeight: number) {
return { naturalWidth, naturalHeight } as HTMLImageElement;
}
describe('image dimensions', () => {
test('remembers the aspect ratio of a loaded image', () => {
expect(getRememberedAspectRatio('http://ontime.local/image.png')).toBe(null);
rememberAspectRatio('http://ontime.local/image.png', makeImage(1920, 1080));
expect(getRememberedAspectRatio('http://ontime.local/image.png')).toBe(1920 / 1080);
});
test('handles images which have not loaded', () => {
rememberAspectRatio('http://ontime.local/broken.png', makeImage(0, 0));
expect(getRememberedAspectRatio('http://ontime.local/broken.png')).toBe(null);
});
test('handles missing values', () => {
expect(getRememberedAspectRatio(undefined)).toBe(null);
expect(getRememberedAspectRatio('')).toBe(null);
expect(() => rememberAspectRatio('', makeImage(100, 100))).not.toThrow();
});
test('forgets the least recently used entries', () => {
for (let i = 0; i < 600; i++) {
rememberAspectRatio(`http://ontime.local/${i}.png`, makeImage(100, 50));
}
expect(getRememberedAspectRatio('http://ontime.local/0.png')).toBe(null);
expect(getRememberedAspectRatio('http://ontime.local/599.png')).toBe(2);
});
});
@@ -0,0 +1,47 @@
/**
* Images in the cuesheet live inside a virtualised table:
* rows are unmounted when they leave the viewport and mounted again when they come back.
* A re-mounted image has no dimensions until it is available,
* which makes the row change height and the table shift under the user.
*
* We remember the aspect ratio of the images we have already seen
* so that we can reserve the space they will take.
* This only holds two numbers per image: we leave the image data itself to the browser cache,
* which knows better than us when memory should be released.
*/
/** how many aspect ratios we remember, this is only a few bytes per entry */
const maxSize = 500;
const aspectRatios = new Map<string, number>();
/**
* @returns the aspect ratio of a previously loaded image, if we have seen it before
*/
export function getRememberedAspectRatio(src: string | null | undefined): number | null {
if (!src) {
return null;
}
return aspectRatios.get(src) ?? null;
}
/**
* Records the aspect ratio of a loaded image
*/
export function rememberAspectRatio(src: string | null | undefined, image: HTMLImageElement) {
if (!src || image.naturalHeight === 0) {
return;
}
// the map iteration order is our LRU queue, re-adding the entry marks it as recently used
aspectRatios.delete(src);
aspectRatios.set(src, image.naturalWidth / image.naturalHeight);
while (aspectRatios.size > maxSize) {
const oldest = aspectRatios.keys().next();
if (oldest.done) {
return;
}
aspectRatios.delete(oldest.value);
}
}
@@ -1,7 +1,8 @@
import { memo } from 'react';
import { memo, useState } from 'react';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import { getRememberedAspectRatio, rememberAspectRatio } from '../../../../common/utils/imageDimensions';
import style from './EditableImage.module.scss';
@@ -14,6 +15,14 @@ interface EditableImageProps {
export default memo(EditableImage);
function EditableImage({ initialValue, readOnly, updateValue }: EditableImageProps) {
const [isLoaded, setIsLoaded] = useState(false);
/**
* The cuesheet is virtualised: rows are unmounted once they leave the viewport.
* When the row comes back, we reserve the space the image took
* so that the table does not shift while the browser makes it available.
*/
const knownAspectRatio = getRememberedAspectRatio(initialValue);
const handleUpdate = (newValue: string) => {
if (newValue === initialValue) {
return;
@@ -62,7 +71,17 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
</Button>
</div>
)}
{Boolean(initialValue) && <img loading='lazy' src={initialValue} className={style.image} />}
<img
src={initialValue}
alt=''
className={style.image}
onLoad={(event) => {
rememberAspectRatio(initialValue, event.currentTarget);
setIsLoaded(true);
}}
/** until the image is available, we reserve the space it took the last time we saw it */
style={!isLoaded && knownAspectRatio !== null ? { aspectRatio: knownAspectRatio, width: '100%' } : undefined}
/>
</div>
);
}