fix(app): stop pickers re-slicing their grid on first paint (#1898)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:41:47 +02:00
1 parent 7a938c85f6
commit 670a3a970e
4 files changed
+52 -39

No files matched your search

@@ -67,23 +67,23 @@ export const GifPickerView = observer(({onClose, selectGif}: GifPickerProps = {}
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const [estimatedContentSize, setEstimatedContentSize] = useState<number | null>(null); const [estimatedContentSize, setEstimatedContentSize] = useState<number | null>(null);
useSearchInputAutofocus(searchInputRef); useSearchInputAutofocus(searchInputRef);
const {viewportSize, scrollTop, handleScroll, handleResize, scrollToTop} = useScrollerViewport(scrollerRef); const {viewportSize, scrollTop, handleScroll, handleResize, jumpToStartEdge} = useScrollerViewport(scrollerRef);
useWindowFocusVideoControl({scrollerRef, videoPool, gifAutoPlay}); useWindowFocusVideoControl({scrollerRef, videoPool});
useEffect(() => { useEffect(() => {
store.ensureFeaturedLoaded(); store.ensureFeaturedLoaded();
return () => store.dispose(); return () => store.dispose();
}, [store]); }, [store]);
useEffect(() => { useEffect(() => {
scrollToTop(); jumpToStartEdge();
}, [store.view, scrollToTop]); }, [store.view, jumpToStartEdge]);
useEffect(() => { useEffect(() => {
if ( if (
(!store.loading && store.shouldRenderSearchResults) || (!store.loading && store.shouldRenderSearchResults) ||
(!store.searchTerm.trim() && !store.shouldRenderSearchResults) (!store.searchTerm.trim() && !store.shouldRenderSearchResults)
) { ) {
scrollToTop(); jumpToStartEdge();
} }
}, [store.loading, store.shouldRenderSearchResults, store.searchTerm, scrollToTop]); }, [store.loading, store.shouldRenderSearchResults, store.searchTerm, jumpToStartEdge]);
useEffect(() => { useEffect(() => {
if (!store.isShowingFavorites || FavoriteGif.totalCount === 0) return; if (!store.isShowingFavorites || FavoriteGif.totalCount === 0) return;
void FavoriteGifCommands.refreshFavoriteGifPreviews(); void FavoriteGifCommands.refreshFavoriteGifPreviews();
@@ -58,11 +58,11 @@ export const MemesPickerView = observer(({onClose}: MemesPickerProps = {}) => {
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const [estimatedContentSize, setEstimatedContentSize] = useState<number | null>(null); const [estimatedContentSize, setEstimatedContentSize] = useState<number | null>(null);
useSearchInputAutofocus(searchInputRef); useSearchInputAutofocus(searchInputRef);
const {viewportSize, scrollTop, handleScroll, handleResize, scrollToTop} = useScrollerViewport(scrollerRef); const {viewportSize, scrollTop, handleScroll, handleResize, jumpToStartEdge} = useScrollerViewport(scrollerRef);
useWindowFocusVideoControl({scrollerRef, videoPool, gifAutoPlay: false}); useWindowFocusVideoControl({scrollerRef, videoPool});
useEffect(() => { useEffect(() => {
scrollToTop(); jumpToStartEdge();
}, [state.selectedFilter, state.searchTerm, scrollToTop]); }, [state.selectedFilter, state.searchTerm, jumpToStartEdge]);
const filteredMemes = useMemo(() => { const filteredMemes = useMemo(() => {
let memes = [...favoriteMemes]; let memes = [...favoriteMemes];
if (state.selectedFilter !== 'all') { if (state.selectedFilter !== 'all') {
@@ -1,10 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import type {ScrollerHandle} from '@app/features/ui/components/Scroller'; import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
import {useCallback, useEffect, useRef, useState} from 'react'; import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
type ResizeType = 'container' | 'content'; type ResizeType = 'container' | 'content';
function readContentBoxSize(element: HTMLElement): {width: number; height: number} | null {
const view = element.ownerDocument.defaultView;
if (view === null) return null;
const style = view.getComputedStyle(element);
const width = element.clientWidth - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight);
const height = element.clientHeight - Number.parseFloat(style.paddingTop) - Number.parseFloat(style.paddingBottom);
if (!(width > 0) || !(height > 0)) return null;
return {width, height};
}
export function useScrollerViewport(scrollerRef: React.RefObject<ScrollerHandle | null>) { export function useScrollerViewport(scrollerRef: React.RefObject<ScrollerHandle | null>) {
const [viewportSize, setViewportSize] = useState({width: 0, height: 0}); const [viewportSize, setViewportSize] = useState({width: 0, height: 0});
const [scrollTop, setScrollTop] = useState(0); const [scrollTop, setScrollTop] = useState(0);
@@ -12,6 +22,7 @@ export function useScrollerViewport(scrollerRef: React.RefObject<ScrollerHandle
const scrollFrameRef = useRef<number | null>(null); const scrollFrameRef = useRef<number | null>(null);
const pendingViewportSizeRef = useRef<{width: number; height: number} | null>(null); const pendingViewportSizeRef = useRef<{width: number; height: number} | null>(null);
const viewportFrameRef = useRef<number | null>(null); const viewportFrameRef = useRef<number | null>(null);
const hasMeasuredViewportRef = useRef(false);
const flushScrollTop = useCallback(() => { const flushScrollTop = useCallback(() => {
scrollFrameRef.current = null; scrollFrameRef.current = null;
const nextScrollTop = pendingScrollTopRef.current; const nextScrollTop = pendingScrollTopRef.current;
@@ -27,16 +38,25 @@ export function useScrollerViewport(scrollerRef: React.RefObject<ScrollerHandle
}, },
[flushScrollTop], [flushScrollTop],
); );
const flushViewportSize = useCallback(() => { const commitViewportSize = useCallback((nextViewportSize: {width: number; height: number}) => {
viewportFrameRef.current = null;
const nextViewportSize = pendingViewportSizeRef.current;
pendingViewportSizeRef.current = null; pendingViewportSizeRef.current = null;
if (!nextViewportSize) return; if (viewportFrameRef.current != null) {
cancelAnimationFrame(viewportFrameRef.current);
viewportFrameRef.current = null;
}
hasMeasuredViewportRef.current = true;
setViewportSize((prev) => { setViewportSize((prev) => {
if (prev.width === nextViewportSize.width && prev.height === nextViewportSize.height) return prev; if (prev.width === nextViewportSize.width && prev.height === nextViewportSize.height) return prev;
return nextViewportSize; return nextViewportSize;
}); });
}, []); }, []);
const flushViewportSize = useCallback(() => {
viewportFrameRef.current = null;
const nextViewportSize = pendingViewportSizeRef.current;
pendingViewportSizeRef.current = null;
if (!nextViewportSize) return;
commitViewportSize(nextViewportSize);
}, [commitViewportSize]);
const scheduleViewportSize = useCallback( const scheduleViewportSize = useCallback(
(nextViewportSize: {width: number; height: number}) => { (nextViewportSize: {width: number; height: number}) => {
pendingViewportSizeRef.current = nextViewportSize; pendingViewportSizeRef.current = nextViewportSize;
@@ -55,15 +75,22 @@ export function useScrollerViewport(scrollerRef: React.RefObject<ScrollerHandle
(entry: ResizeObserverEntry, type: ResizeType) => { (entry: ResizeObserverEntry, type: ResizeType) => {
if (type !== 'container') return; if (type !== 'container') return;
const {width, height} = entry.contentRect; const {width, height} = entry.contentRect;
if (!hasMeasuredViewportRef.current && width > 0 && height > 0) {
commitViewportSize({width, height});
return;
}
scheduleViewportSize({width, height}); scheduleViewportSize({width, height});
}, },
[scheduleViewportSize], [commitViewportSize, scheduleViewportSize],
); );
useEffect(() => { useLayoutEffect(() => {
const state = scrollerRef.current?.readViewportMetrics(); if (hasMeasuredViewportRef.current) return;
if (!state || state.offsetWidth === 0 || state.offsetHeight === 0) return; const element = scrollerRef.current?.getViewportElement() ?? null;
scheduleViewportSize({width: state.offsetWidth, height: state.offsetHeight}); if (element === null) return;
}, [scrollerRef, scheduleViewportSize]); const contentBox = readContentBoxSize(element);
if (contentBox === null) return;
commitViewportSize(contentBox);
}, [scrollerRef, commitViewportSize]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (scrollFrameRef.current != null) { if (scrollFrameRef.current != null) {
@@ -74,7 +101,7 @@ export function useScrollerViewport(scrollerRef: React.RefObject<ScrollerHandle
} }
}; };
}, []); }, []);
const scrollToTop = useCallback(() => { const jumpToStartEdge = useCallback(() => {
scrollerRef.current?.scrollTo({to: 0, animate: false}); scrollerRef.current?.scrollTo({to: 0, animate: false});
pendingScrollTopRef.current = null; pendingScrollTopRef.current = null;
if (scrollFrameRef.current != null) { if (scrollFrameRef.current != null) {
@@ -89,6 +116,6 @@ export function useScrollerViewport(scrollerRef: React.RefObject<ScrollerHandle
setScrollTop, setScrollTop,
handleScroll, handleScroll,
handleResize, handleResize,
scrollToTop, jumpToStartEdge,
}; };
} }
@@ -16,30 +16,16 @@ interface VideoPoolControl {
interface UseWindowFocusVideoControlOptions { interface UseWindowFocusVideoControlOptions {
scrollerRef: React.RefObject<ScrollerHandle | null>; scrollerRef: React.RefObject<ScrollerHandle | null>;
videoPool: VideoPoolControl; videoPool: VideoPoolControl;
gifAutoPlay?: boolean;
} }
export function useWindowFocusVideoControl({ export function useWindowFocusVideoControl({scrollerRef, videoPool}: UseWindowFocusVideoControlOptions): void {
scrollerRef,
videoPool,
gifAutoPlay = true,
}: UseWindowFocusVideoControlOptions): void {
const poolRef = useRef(videoPool); const poolRef = useRef(videoPool);
poolRef.current = videoPool; poolRef.current = videoPool;
const scrollerRefRef = useRef(scrollerRef); const scrollerRefRef = useRef(scrollerRef);
scrollerRefRef.current = scrollerRef; scrollerRefRef.current = scrollerRef;
const gifAutoPlayRef = useRef(gifAutoPlay);
gifAutoPlayRef.current = gifAutoPlay;
useEffect(() => {
if (gifAutoPlay && getAnimatedMediaPlaybackAllowed()) {
poolRef.current.resumeAll();
} else {
poolRef.current.pauseAll();
}
}, [gifAutoPlay]);
useEffect(() => { useEffect(() => {
const updatePlayback = () => { const updatePlayback = () => {
if (gifAutoPlayRef.current && getAnimatedMediaPlaybackAllowed()) { if (getAnimatedMediaPlaybackAllowed()) {
poolRef.current.resumeAll(); poolRef.current.resumeAll();
} else { } else {
poolRef.current.pauseAll(); poolRef.current.pauseAll();