Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions packages/ui/src/hooks/scroll-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -89,6 +90,7 @@ export function useScrollRestoration(
let fetchInFlight = false;
let frameId: number | undefined;
let settleTimer: ReturnType<typeof setTimeout> | undefined;
let scrollSaveTimer: ReturnType<typeof setTimeout> | undefined;
let restoreGeneration = 0;
let reconcileFrameId: number | undefined;
let secondReconcileFrameId: number | undefined;
Expand Down Expand Up @@ -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) {
Expand All @@ -197,6 +208,10 @@ export function useScrollRestoration(
clearTimeout(settleTimer);
settleTimer = undefined;
}
if (scrollSaveTimer !== undefined) {
clearTimeout(scrollSaveTimer);
scrollSaveTimer = undefined;
}
restoreGeneration += 1;
setIsRestored(false);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
89 changes: 59 additions & 30 deletions packages/ui/src/source-media-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<HTMLDivElement>({
get count() {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -305,20 +319,28 @@ 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);
}
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);
Expand All @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

onCleanup(() => {
window.removeEventListener("resize", handleResize);
if (metricsFrameId !== undefined) {
cancelAnimationFrame(metricsFrameId);
metricsFrameId = undefined;
}
resizeObserver.disconnect();
if (mediaGridResizeObserver === resizeObserver) {
mediaGridResizeObserver = undefined;
}
});
});

Expand Down Expand Up @@ -396,10 +423,12 @@ export function SourceMediaGrid(props: SourceMediaGridProps) {
<div
class="@container relative min-w-0 w-full"
ref={(element) => {
if (mediaGridRef && mediaGridRef !== element) {
mediaGridResizeObserver?.unobserve(mediaGridRef);
}
mediaGridRef = element;
requestAnimationFrame(() => {
updateMediaGridMetrics();
});
mediaGridResizeObserver?.observe(element);
scheduleMediaGridMetrics();
}}
style={{
height: shouldVirtualize()
Expand Down Expand Up @@ -470,7 +499,7 @@ export function SourceMediaGrid(props: SourceMediaGridProps) {
>
<For each={mediaRowVirtualizer().getVirtualItems()}>
{(virtualRow) => {
const rowMedia = () => mediaRows()[virtualRow.index] || [];
const rowMedia = () => getRowMedia(virtualRow.index);
return (
<div
class={`absolute top-0 left-0 ${mediaGridClassName}`}
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/thumbnail-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,15 @@ export function ThumbnailImage(props: ThumbnailImageProps) {
<img
alt={props.alt}
class={props.class}
decoding="async"
fetchpriority={props.fetchpriority}
height={props.height ?? undefined}
loading={props.loading}
onError={handleError}
onLoad={handleLoad}
sizes={srcSet() ? props.sizes : undefined}
src={resolvedUrl()}
srcset={srcSet()}
src={resolvedUrl()}
width={props.width ?? undefined}
/>
)}
Expand Down
30 changes: 29 additions & 1 deletion packages/ui/src/thumbnail-source.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
}
});
});
7 changes: 7 additions & 0 deletions packages/ui/src/thumbnail-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | undefined;

const clearRetryTimer = () => {
Expand All @@ -105,6 +106,7 @@ export function createHttpThumbnailSource(

onCleanup(() => {
clearRetryTimer();
listeners.clear();
});

return {
Expand Down Expand Up @@ -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);
},
};
}

Expand Down