From 7ec1179edec8e009a2668cbc8e5428ea7a638453 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 19:28:58 +0000 Subject: [PATCH] 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 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 Claude-Session: https://claude.ai/code/session_01CKXcDrZoQXbJpaXiLi1aff --- .../utils/__tests__/imageDimensions.test.ts | 38 +++++++++++++++ .../src/common/utils/imageDimensions.ts | 47 +++++++++++++++++++ .../cuesheet-table-elements/EditableImage.tsx | 23 ++++++++- 3 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 apps/client/src/common/utils/__tests__/imageDimensions.test.ts create mode 100644 apps/client/src/common/utils/imageDimensions.ts diff --git a/apps/client/src/common/utils/__tests__/imageDimensions.test.ts b/apps/client/src/common/utils/__tests__/imageDimensions.test.ts new file mode 100644 index 000000000..fa0e3a4d4 --- /dev/null +++ b/apps/client/src/common/utils/__tests__/imageDimensions.test.ts @@ -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); + }); +}); diff --git a/apps/client/src/common/utils/imageDimensions.ts b/apps/client/src/common/utils/imageDimensions.ts new file mode 100644 index 000000000..27519dccf --- /dev/null +++ b/apps/client/src/common/utils/imageDimensions.ts @@ -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(); + +/** + * @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); + } +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EditableImage.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EditableImage.tsx index a9bbc00be..c68612817 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EditableImage.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EditableImage.tsx @@ -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 )} - {Boolean(initialValue) && } + { + 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} + /> ); }