Compare commits

..

1 Commits

Author SHA1 Message Date
Claude baee028792 fix(cuesheet): reduce image reload flicker when scrolling
Cuesheet rows are virtualized (react-virtuoso), so rows scrolling out
of the small overscan buffer get unmounted, destroying their <img>
elements; scrolling back remounts them from scratch. Native
loading='lazy' on the image compounded this with an extra deferred
load on every remount. Widen the overscan buffer and drop the
redundant lazy attribute so images stay mounted through normal
scroll-and-back gestures.
2026-07-25 18:57:02 +00:00
4 changed files with 3 additions and 107 deletions
@@ -1,38 +0,0 @@
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);
});
});
@@ -1,47 +0,0 @@
/**
* 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);
}
}
@@ -260,7 +260,7 @@ export default function CuesheetTable({
context={virtuosoContext}
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
computeItemKey={computeItemKey}
increaseViewportBy={{ top: 100, bottom: 200 }}
increaseViewportBy={{ top: 300, bottom: 600 }}
components={virtuosoComponents}
fixedHeaderContent={fixedHeaderContent}
/>
@@ -1,8 +1,7 @@
import { memo, useState } from 'react';
import { memo } 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';
@@ -15,14 +14,6 @@ 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;
@@ -71,17 +62,7 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
</Button>
</div>
)}
<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}
/>
{Boolean(initialValue) && <img src={initialValue} className={style.image} />}
</div>
);
}