diff --git a/packages/ui/src/hooks/scroll-container.ts b/packages/ui/src/hooks/scroll-container.ts index 7d6b1675..18243181 100644 --- a/packages/ui/src/hooks/scroll-container.ts +++ b/packages/ui/src/hooks/scroll-container.ts @@ -71,6 +71,7 @@ export type ScrollRestorationOptions = { const MAX_RESTORE_PAGE_FETCHES = 100; const RESTORE_SETTLE_DELAY_MS = 100; +const SCROLL_SAVE_DEBOUNCE_MS = 100; /** * Restores a list's scroll position after its initial pages have rendered. @@ -89,6 +90,7 @@ export function useScrollRestoration( let fetchInFlight = false; let frameId: number | undefined; let settleTimer: ReturnType | undefined; + let scrollSaveTimer: ReturnType | undefined; let restoreGeneration = 0; let reconcileFrameId: number | undefined; let secondReconcileFrameId: number | undefined; @@ -186,6 +188,15 @@ export function useScrollRestoration( if (position !== null) options.setPosition(key, position); }; + const scheduleSavePosition = () => { + if (!isRestored()) return; + if (scrollSaveTimer !== undefined) clearTimeout(scrollSaveTimer); + scrollSaveTimer = setTimeout(() => { + scrollSaveTimer = undefined; + savePosition(); + }, SCROLL_SAVE_DEBOUNCE_MS); + }; + createEffect(() => { const key = options.restoreKey(); if (key !== activeKey) { @@ -197,6 +208,10 @@ export function useScrollRestoration( clearTimeout(settleTimer); settleTimer = undefined; } + if (scrollSaveTimer !== undefined) { + clearTimeout(scrollSaveTimer); + scrollSaveTimer = undefined; + } restoreGeneration += 1; setIsRestored(false); } @@ -242,6 +257,30 @@ export function useScrollRestoration( }); }; + const scrollContainer = resolveScrollContainer( + options.scrollContainerSelector, + ); + const handleScrollForPersistence = (event: Event) => { + if (scrollContainer) { + if (event.target !== scrollContainer) return; + } else if ( + options.scrollContainerSelector && + event.target !== resolveScrollContainer(options.scrollContainerSelector) + ) { + return; + } + scheduleSavePosition(); + }; + const scrollEventTarget = + scrollContainer ?? (options.scrollContainerSelector ? document : window); + const useCapture = Boolean( + options.scrollContainerSelector && !scrollContainer, + ); + scrollEventTarget.addEventListener("scroll", handleScrollForPersistence, { + capture: useCapture, + passive: true, + }); + const cancelRestore = () => { if (!isRestored()) { cancelled = true; @@ -271,6 +310,11 @@ export function useScrollRestoration( window.addEventListener("touchstart", cancelRestore, { passive: true }); window.addEventListener("keydown", cancelRestore); onCleanup(() => { + scrollEventTarget.removeEventListener( + "scroll", + handleScrollForPersistence, + useCapture, + ); window.removeEventListener("pointerdown", cancelRestore); window.removeEventListener("wheel", cancelRestore); window.removeEventListener("touchstart", cancelRestore); @@ -281,6 +325,7 @@ export function useScrollRestoration( onCleanup(() => { cancelled = true; restoreGeneration += 1; + if (scrollSaveTimer !== undefined) clearTimeout(scrollSaveTimer); if (frameId !== undefined) cancelAnimationFrame(frameId); if (settleTimer !== undefined) clearTimeout(settleTimer); if (reconcileFrameId !== undefined) cancelAnimationFrame(reconcileFrameId); diff --git a/packages/ui/src/source-media-grid.tsx b/packages/ui/src/source-media-grid.tsx index 6b2fc068..3f18de7b 100644 --- a/packages/ui/src/source-media-grid.tsx +++ b/packages/ui/src/source-media-grid.tsx @@ -146,6 +146,8 @@ export function SourceMediaGrid(props: SourceMediaGridProps) { startIndex: number; } | null>(null); let mediaGridRef: HTMLDivElement | undefined; + let mediaGridResizeObserver: ResizeObserver | undefined; + let metricsFrameId: number | undefined; const columnCount = createMemo(() => { const width = mediaGridWidth() || windowWidth(); @@ -165,17 +167,16 @@ export function SourceMediaGrid(props: SourceMediaGridProps) { return width / (props.itemAspectRatio ?? 3 / 4); }); - const mediaRows = createMemo(() => { - const results = props.mediaResults(); + const rowCount = createMemo(() => { const columns = columnCount(); - const rows: Media[][] = []; - for (let index = 0; index < results.length; index += columns) { - rows.push(results.slice(index, index + columns)); - } - return rows; + return columns > 0 ? Math.ceil(props.mediaResults().length / columns) : 0; }); - - const rowCount = createMemo(() => mediaRows().length); + const getRowMedia = (rowIndex: number) => { + const columns = columnCount(); + if (columns <= 0) return []; + const results = props.mediaResults(); + return results.slice(rowIndex * columns, (rowIndex + 1) * columns); + }; const windowRowVirtualizer = createWindowVirtualizer({ get count() { @@ -204,15 +205,23 @@ export function SourceMediaGrid(props: SourceMediaGridProps) { overscan: 0, onChange: (instance) => { const range = instance.range; - setElementLoadState( - range - ? { - direction: instance.scrollDirection, - endIndex: range.endIndex, - startIndex: range.startIndex, - } - : null, - ); + const nextState = range + ? { + direction: instance.scrollDirection, + endIndex: range.endIndex, + startIndex: range.startIndex, + } + : null; + setElementLoadState((previous) => { + if ( + previous?.direction === nextState?.direction && + previous?.endIndex === nextState?.endIndex && + previous?.startIndex === nextState?.startIndex + ) { + return previous; + } + return nextState; + }); }, rangeExtractor: (range) => extractDirectionalRows( @@ -252,7 +261,12 @@ export function SourceMediaGrid(props: SourceMediaGridProps) { // A virtualizer can briefly have no measured range while its scroll // container is being restored. Keep mounted rows loadable so a // transient range reset does not blank the entire viewport. - return { enabled: true, loading: "eager" }; + return { + enabled: true, + fetchpriority: + mediaIndex < INITIAL_HIGH_PRIORITY_MEDIA ? "high" : undefined, + loading: "eager", + }; } const isVisible = @@ -305,7 +319,8 @@ export function SourceMediaGrid(props: SourceMediaGridProps) { const updateMediaGridMetrics = () => { if (!mediaGridRef) return; - setMediaGridWidth(mediaGridRef.getBoundingClientRect().width); + const gridRect = mediaGridRef.getBoundingClientRect(); + setMediaGridWidth(gridRect.width); const resolvedScrollElement = resolveScrollElement(); if (resolvedScrollElement !== scrollElement()) { setScrollElement(resolvedScrollElement); @@ -313,12 +328,19 @@ export function SourceMediaGrid(props: SourceMediaGridProps) { const scroller = resolvedScrollElement; setScrollMargin( props.scrollMode === "element" && scroller - ? mediaGridRef.getBoundingClientRect().top - + ? gridRect.top - scroller.getBoundingClientRect().top + scroller.scrollTop - : mediaGridRef.getBoundingClientRect().top + window.scrollY, + : gridRect.top + window.scrollY, ); }; + const scheduleMediaGridMetrics = () => { + if (metricsFrameId !== undefined) return; + metricsFrameId = requestAnimationFrame(() => { + metricsFrameId = undefined; + updateMediaGridMetrics(); + }); + }; onMount(() => { setWindowWidth(window.innerWidth); @@ -327,20 +349,25 @@ export function SourceMediaGrid(props: SourceMediaGridProps) { const handleResize = () => { setWindowWidth(window.innerWidth); - updateMediaGridMetrics(); }; window.addEventListener("resize", handleResize); - const resizeObserver = new ResizeObserver(() => { - updateMediaGridMetrics(); - }); + const resizeObserver = new ResizeObserver(scheduleMediaGridMetrics); + mediaGridResizeObserver = resizeObserver; if (mediaGridRef) { resizeObserver.observe(mediaGridRef); } onCleanup(() => { window.removeEventListener("resize", handleResize); + if (metricsFrameId !== undefined) { + cancelAnimationFrame(metricsFrameId); + metricsFrameId = undefined; + } resizeObserver.disconnect(); + if (mediaGridResizeObserver === resizeObserver) { + mediaGridResizeObserver = undefined; + } }); }); @@ -396,10 +423,12 @@ export function SourceMediaGrid(props: SourceMediaGridProps) {
{ + if (mediaGridRef && mediaGridRef !== element) { + mediaGridResizeObserver?.unobserve(mediaGridRef); + } mediaGridRef = element; - requestAnimationFrame(() => { - updateMediaGridMetrics(); - }); + mediaGridResizeObserver?.observe(element); + scheduleMediaGridMetrics(); }} style={{ height: shouldVirtualize() @@ -470,7 +499,7 @@ export function SourceMediaGrid(props: SourceMediaGridProps) { > {(virtualRow) => { - const rowMedia = () => mediaRows()[virtualRow.index] || []; + const rowMedia = () => getRowMedia(virtualRow.index); return (
)} diff --git a/packages/ui/src/thumbnail-source.test.ts b/packages/ui/src/thumbnail-source.test.ts index d6a32a90..75a55c33 100644 --- a/packages/ui/src/thumbnail-source.test.ts +++ b/packages/ui/src/thumbnail-source.test.ts @@ -1,5 +1,5 @@ import { createRoot } from "solid-js"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { type BuildThumbnailUrlArgs, createHttpThumbnailSource, @@ -48,4 +48,32 @@ describe("createHttpThumbnailSource", () => { dispose(); }); }); + + it("notifies active images when a failed thumbnail is retried", () => { + vi.useFakeTimers(); + try { + createRoot((dispose) => { + const source = createHttpThumbnailSource({ + buildUrl, + mediaId: "media-1", + mediaSourceId: "source-1", + modifiedAt: "2026-08-02T00:00:00.000Z", + retryDelayMs: 100, + }); + const listener = vi.fn(); + const unsubscribe = source.subscribe?.(listener); + + source.onError?.(); + vi.advanceTimersByTime(99); + expect(listener).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(listener).toHaveBeenCalledOnce(); + + unsubscribe?.(); + dispose(); + }); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/packages/ui/src/thumbnail-source.ts b/packages/ui/src/thumbnail-source.ts index 3e5bf17e..187e8c2a 100644 --- a/packages/ui/src/thumbnail-source.ts +++ b/packages/ui/src/thumbnail-source.ts @@ -85,6 +85,7 @@ export function createHttpThumbnailSource( ): ThumbnailSource { const [cacheKey, setCacheKey] = createSignal(0); const [retryCount, setRetryCount] = createSignal(0); + const listeners = new Set<() => void>(); let retryTimer: ReturnType | undefined; const clearRetryTimer = () => { @@ -105,6 +106,7 @@ export function createHttpThumbnailSource( onCleanup(() => { clearRetryTimer(); + listeners.clear(); }); return { @@ -140,8 +142,13 @@ export function createHttpThumbnailSource( retryTimer = setTimeout(() => { setRetryCount((prev) => prev + 1); setCacheKey(Date.now()); + for (const listener of listeners) listener(); }, props.retryDelayMs ?? DEFAULT_HTTP_RETRY_DELAY_MS); }, + subscribe(callback) { + listeners.add(callback); + return () => listeners.delete(callback); + }, }; }