From 7a73266cec855efca88f1dadfb363d209a518e04 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 1 Aug 2026 12:27:01 +0200 Subject: [PATCH] fix(media): queue emote grid image loads and drop the ones scrolled past --- src/app/components/emoji-board/EmojiBoard.tsx | 1 + .../emoji-board/components/Item.tsx | 2 + src/app/components/media/Image.test.tsx | 34 ++++++- src/app/components/media/Image.tsx | 54 ++++++++++- src/app/hooks/useMediaLoadSlot.test.tsx | 89 +++++++++++++++++++ src/app/hooks/useMediaLoadSlot.ts | 53 +++++++++++ src/app/utils/mediaLoadQueue.test.ts | 82 +++++++++++++++++ src/app/utils/mediaLoadQueue.ts | 75 ++++++++++++++++ 8 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 src/app/hooks/useMediaLoadSlot.test.tsx create mode 100644 src/app/hooks/useMediaLoadSlot.ts create mode 100644 src/app/utils/mediaLoadQueue.test.ts create mode 100644 src/app/utils/mediaLoadQueue.ts diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 6c8d78a04..ac102bfb2 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -194,6 +194,7 @@ const useItemRenderer = (tab: EmojiBoardTab, saveStickerEmojiBandwidth: boolean) > { fetchSpy.mockRestore(); }); + it('only starts a queued image when it is visible', () => { + let notify: IntersectionObserverCallback | undefined; + const originalIntersectionObserver = globalThis.IntersectionObserver; + class MockIntersectionObserver { + constructor(callback: IntersectionObserverCallback) { + notify = callback; + } + + observe() {} + + disconnect() {} + } + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + + try { + render(queued); + + const image = screen.getByAltText('queued'); + expect(image).not.toHaveAttribute('src'); + + act(() => { + notify?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver + ); + }); + expect(image).toHaveAttribute('src', 'https://example.com/queued.png'); + } finally { + vi.stubGlobal('IntersectionObserver', originalIntersectionObserver); + } + }); + it('uses the lottie renderer for gzipped lottie data', async () => { const gzipped = Buffer.from(gzipSync(lottieJson)).toString('base64'); render( diff --git a/src/app/components/media/Image.tsx b/src/app/components/media/Image.tsx index e4a2ea68c..1ff0c9782 100644 --- a/src/app/components/media/Image.tsx +++ b/src/app/components/media/Image.tsx @@ -3,6 +3,7 @@ import { forwardRef, lazy, Suspense, useCallback, useEffect, useRef, useState } import classNames from 'classnames'; import type { DotLottieReact as DotLottieReactComponent } from '@lottiefiles/dotlottie-react'; import { useSetting } from '$state/hooks/settings'; +import { useMediaLoadSlot } from '$hooks/useMediaLoadSlot'; import { isPixelatedRendering, settingsAtom } from '$state/settings'; import * as css from './media.css'; import type { IImageInfo } from '$types/matrix/common'; @@ -13,6 +14,11 @@ type ImageProps = Omit, 'onPointerDown'> & { disableDefaultSizing?: boolean; disablePixelation?: boolean; pixelated?: boolean; + /** + * Load through the media queue. Only for images inside a virtualized list, where being + * mounted means being on screen: a mounted image holds its slot until it reports back. + */ + queueLoad?: boolean; onLottieLoad?: (canvas?: HTMLCanvasElement) => void; onLottieError?: () => void; onPointerDown?: PointerEventHandler; @@ -399,11 +405,16 @@ export const Image = forwardRef { const [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering'); + const [imageElement, setImageElement] = useState(null); + const [isNearViewport, setIsNearViewport] = useState( + () => !queueLoad || typeof IntersectionObserver === 'undefined' + ); const [lottieResolution, setLottieResolution] = useState<{ source: string; resolved: string | null; @@ -452,6 +463,37 @@ export const Image = forwardRef { + if (!queueLoad || !imageElement || typeof IntersectionObserver === 'undefined') { + setIsNearViewport(true); + return undefined; + } + + setIsNearViewport(false); + const observer = new IntersectionObserver(([entry]) => { + setIsNearViewport(entry?.isIntersecting === true); + }); + observer.observe(imageElement); + return () => observer.disconnect(); + }, [imageElement, queueLoad]); + + // Only the plain branch consumes a queue slot: a candidate that is still being + // probed, or that resolved to animation data, never becomes an image request. + const imageSource = resolvedLottieJson === null ? src : undefined; + const { src: queuedSrc, onSettled: onImageSettled } = useMediaLoadSlot( + queueLoad && isNearViewport ? imageSource : undefined + ); + const renderedSrc = queueLoad ? queuedSrc : imageSource; + + const setImageRef = useCallback( + (image: HTMLImageElement | null) => { + setImageElement(image); + if (typeof ref === 'function') ref(image); + else if (ref) ref.current = image; + }, + [ref] + ); + const shouldRenderLottie = typeof resolvedLottieJson === 'string'; if (shouldRenderLottie) { @@ -481,12 +523,16 @@ export const Image = forwardRef { + onImageSettled(); + onLoad?.(event); + }} onPointerDown={onPointerDown} onError={(event) => { + onImageSettled(); if (!declaredLottieCandidate && fallbackSource !== src) { setFallbackSource(src); return; @@ -494,7 +540,7 @@ export const Image = forwardRef} + ref={setImageRef} /> ); } diff --git a/src/app/hooks/useMediaLoadSlot.test.tsx b/src/app/hooks/useMediaLoadSlot.test.tsx new file mode 100644 index 000000000..85e39bb3e --- /dev/null +++ b/src/app/hooks/useMediaLoadSlot.test.tsx @@ -0,0 +1,89 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useMediaLoadSlot } from './useMediaLoadSlot'; + +const renderSlot = (src: string | undefined) => renderHook(() => useMediaLoadSlot(src)); + +describe('useMediaLoadSlot', () => { + it('passes a local source straight through', () => { + const { result } = renderSlot('blob:https://example.com/1234'); + + expect(result.current.src).toBe('blob:https://example.com/1234'); + }); + + it('allows a remote source right away while the queue has room', () => { + const { result, unmount } = renderSlot('https://example.com/emote.png'); + + expect(result.current.src).toBe('https://example.com/emote.png'); + unmount(); + }); + + it('holds a remote source back once the queue is full', () => { + const held = Array.from({ length: 6 }, (_, index) => + renderSlot(`https://example.com/held-${index}.png`) + ); + + const queued = renderSlot('https://example.com/queued.png'); + expect(queued.result.current.src).toBeUndefined(); + + held[0]?.unmount(); + expect(queued.result.current.src).toBe('https://example.com/queued.png'); + + held.slice(1).forEach((slot) => slot.unmount()); + queued.unmount(); + }); + + it('gives up a queued slot on unmount without ever exposing the source', () => { + const held = Array.from({ length: 6 }, (_, index) => + renderSlot(`https://example.com/held-${index}.png`) + ); + + const scrolledPast = renderSlot('https://example.com/scrolled-past.png'); + expect(scrolledPast.result.current.src).toBeUndefined(); + scrolledPast.unmount(); + + const next = renderSlot('https://example.com/next.png'); + held.forEach((slot) => slot.unmount()); + + expect(scrolledPast.result.current.src).toBeUndefined(); + expect(next.result.current.src).toBe('https://example.com/next.png'); + + next.unmount(); + }); + + it('stops showing the previous source while the next one waits for a slot', () => { + const { rerender, result, unmount } = renderHook(({ src }) => useMediaLoadSlot(src), { + initialProps: { src: 'https://example.com/a.png' }, + }); + expect(result.current.src).toBe('https://example.com/a.png'); + + const held = Array.from({ length: 5 }, (_, index) => + renderSlot(`https://example.com/held-${index}.png`) + ); + // Takes the slot released by the source change, so the next source has to queue. + const next = renderSlot('https://example.com/next.png'); + + rerender({ src: 'https://example.com/b.png' }); + + expect(result.current.src).toBeUndefined(); + expect(next.result.current.src).toBe('https://example.com/next.png'); + + [...held, next].forEach((slot) => slot.unmount()); + unmount(); + }); + + it('frees the slot when the image reports back', () => { + const held = Array.from({ length: 6 }, (_, index) => + renderSlot(`https://example.com/held-${index}.png`) + ); + + const queued = renderSlot('https://example.com/queued.png'); + expect(queued.result.current.src).toBeUndefined(); + + act(() => held[0]?.result.current.onSettled()); + expect(queued.result.current.src).toBe('https://example.com/queued.png'); + + held.forEach((slot) => slot.unmount()); + queued.unmount(); + }); +}); diff --git a/src/app/hooks/useMediaLoadSlot.ts b/src/app/hooks/useMediaLoadSlot.ts new file mode 100644 index 000000000..30f0d63a2 --- /dev/null +++ b/src/app/hooks/useMediaLoadSlot.ts @@ -0,0 +1,53 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { requestMediaLoadSlot } from '$utils/mediaLoadQueue'; + +const noop = (): void => {}; + +const isRemoteSource = (src: string | undefined): src is string => + src !== undefined && !src.startsWith('blob:') && !src.startsWith('data:'); + +/** + * Holds a remote `src` back until the media queue has room for it. A source still queued + * when the element unmounts never becomes a request at all, so scrolling past media costs + * nothing and never leaves a half-finished download to repeat later. + * + * Runs as a layout effect so a source that is granted straight away is in place before + * the first paint, leaving media on an idle queue exactly as fast as it was before. + */ +export const useMediaLoadSlot = ( + src: string | undefined +): { src: string | undefined; onSettled: () => void } => { + const [allowedSrc, setAllowedSrc] = useState(undefined); + const settleRef = useRef<() => void>(noop); + + useLayoutEffect(() => { + if (!isRemoteSource(src)) { + settleRef.current = noop; + setAllowedSrc(src); + return undefined; + } + + let grantedNow = false; + const slot = requestMediaLoadSlot(() => { + grantedNow = true; + setAllowedSrc(src); + }); + // Drop the previous source while this one waits. Leaving it in place would render an + // image that holds no slot, and its load event would settle the slot this one is + // queued for, stranding it. + if (!grantedNow) setAllowedSrc(undefined); + settleRef.current = slot.settle; + + return () => { + settleRef.current = noop; + slot.settle(); + }; + }, [src]); + + const onSettled = useCallback(() => { + settleRef.current(); + settleRef.current = noop; + }, []); + + return { src: allowedSrc, onSettled }; +}; diff --git a/src/app/utils/mediaLoadQueue.test.ts b/src/app/utils/mediaLoadQueue.test.ts new file mode 100644 index 000000000..48b7b99ea --- /dev/null +++ b/src/app/utils/mediaLoadQueue.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest'; +import { requestMediaLoadSlot } from './mediaLoadQueue'; + +const openSlot = (label: string, log: string[]) => { + const onGranted = vi.fn<() => void>(() => { + log.push(label); + }); + const slot = requestMediaLoadSlot(onGranted); + return { ...slot, onGranted }; +}; + +describe('requestMediaLoadSlot', () => { + it('grants a free slot synchronously', () => { + const log: string[] = []; + const slot = openSlot('only', log); + + expect(slot.onGranted).toHaveBeenCalledOnce(); + expect(log).toEqual(['only']); + + slot.settle(); + }); + + it('grants no more than six loads at once', () => { + const log: string[] = []; + const slots = Array.from({ length: 10 }, (_, index) => openSlot(`load-${index}`, log)); + + expect(log).toHaveLength(6); + + slots.forEach((slot) => slot.settle()); + }); + + it('grants the newest waiting load first', () => { + const log: string[] = []; + const held = Array.from({ length: 6 }, (_, index) => openSlot(`held-${index}`, log)); + const older = openSlot('older', log); + const newer = openSlot('newer', log); + + expect(older.onGranted).not.toHaveBeenCalled(); + expect(newer.onGranted).not.toHaveBeenCalled(); + + held[0]?.settle(); + + expect(newer.onGranted).toHaveBeenCalledOnce(); + expect(older.onGranted).not.toHaveBeenCalled(); + + [...held, older, newer].forEach((slot) => slot.settle()); + }); + + it('never grants a load that left the queue before starting', () => { + const log: string[] = []; + const held = Array.from({ length: 6 }, (_, index) => openSlot(`held-${index}`, log)); + const dropped = openSlot('dropped', log); + const kept = openSlot('kept', log); + + dropped.settle(); + held.forEach((slot) => slot.settle()); + + expect(dropped.onGranted).not.toHaveBeenCalled(); + expect(kept.onGranted).toHaveBeenCalledOnce(); + + kept.settle(); + }); + + it('frees the slot of a load that never reports back', () => { + vi.useFakeTimers(); + try { + const log: string[] = []; + const held = Array.from({ length: 6 }, (_, index) => openSlot(`held-${index}`, log)); + const waiting = openSlot('waiting', log); + + expect(waiting.onGranted).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(10_000); + + expect(waiting.onGranted).toHaveBeenCalledOnce(); + + [...held, waiting].forEach((slot) => slot.settle()); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/app/utils/mediaLoadQueue.ts b/src/app/utils/mediaLoadQueue.ts new file mode 100644 index 000000000..14871b533 --- /dev/null +++ b/src/app/utils/mediaLoadQueue.ts @@ -0,0 +1,75 @@ +// Matches the browser's own per-host connection limit: wide enough that media stays +// instant on a healthy connection, narrow enough that scrolling a large emote pack +// cannot hand the transport hundreds of requests nobody is waiting for any more. +const MAX_CONCURRENT_MEDIA_LOADS = 6; + +// Frees the slot of a load that never reports back — a source the browser deferred, or +// one stalled on a dead connection. Kept short because releasing costs nothing: the load +// itself is left running, so the worst case is briefly running one over the limit rather +// than a grid that sits still waiting for a slot that is never coming back. +const LOAD_SLOT_TIMEOUT_MS = 10_000; + +type QueuedLoad = { begin: () => void }; + +const waiting: QueuedLoad[] = []; +let active = 0; + +function handOff(): void { + // Newest first: what just scrolled into view outranks what the user scrolled past. + const next = waiting.pop(); + if (next) next.begin(); + else active -= 1; +} + +export type MediaLoadSlot = { + /** Give the slot back, or leave the queue if the load never started. */ + settle: () => void; +}; + +/** + * Reserves one of the media load slots. `onGranted` runs synchronously when a slot is + * free, so media loads at full speed until the queue actually fills up. + */ +export function requestMediaLoadSlot(onGranted: () => void): MediaLoadSlot { + let settled = false; + let queued = false; + let holdTimer: ReturnType | undefined; + + const begin = (): void => { + queued = false; + holdTimer = setTimeout(() => { + holdTimer = undefined; + if (settled) return; + settled = true; + handOff(); + }, LOAD_SLOT_TIMEOUT_MS); + onGranted(); + }; + + const entry: QueuedLoad = { begin }; + + if (active < MAX_CONCURRENT_MEDIA_LOADS) { + active += 1; + begin(); + } else { + queued = true; + waiting.push(entry); + } + + return { + settle: () => { + if (settled) return; + settled = true; + if (holdTimer !== undefined) clearTimeout(holdTimer); + + if (queued) { + // Never started, so there is no slot to pass on and nothing to download again. + const index = waiting.indexOf(entry); + if (index !== -1) waiting.splice(index, 1); + return; + } + + handOff(); + }, + }; +}