From 670a3a970e9e75d54c88eb9b5d67ca9f71117f8d Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:41:47 +0200 Subject: [PATCH] fix(app): stop pickers re-slicing their grid on first paint (#1898) --- .../components/pickers/gif/GifPickerView.tsx | 12 ++--- .../pickers/memes/MemesPickerView.tsx | 8 +-- .../pickers/shared/useScrollerViewport.ts | 53 ++++++++++++++----- .../voice/hooks/useWindowFocusVideoControl.ts | 18 +------ 4 files changed, 52 insertions(+), 39 deletions(-) diff --git a/fluxer_app/src/features/channel/components/pickers/gif/GifPickerView.tsx b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerView.tsx index f96e1140d..84fa2e432 100644 --- a/fluxer_app/src/features/channel/components/pickers/gif/GifPickerView.tsx +++ b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerView.tsx @@ -67,23 +67,23 @@ export const GifPickerView = observer(({onClose, selectGif}: GifPickerProps = {} const searchInputRef = useRef(null); const [estimatedContentSize, setEstimatedContentSize] = useState(null); useSearchInputAutofocus(searchInputRef); - const {viewportSize, scrollTop, handleScroll, handleResize, scrollToTop} = useScrollerViewport(scrollerRef); - useWindowFocusVideoControl({scrollerRef, videoPool, gifAutoPlay}); + const {viewportSize, scrollTop, handleScroll, handleResize, jumpToStartEdge} = useScrollerViewport(scrollerRef); + useWindowFocusVideoControl({scrollerRef, videoPool}); useEffect(() => { store.ensureFeaturedLoaded(); return () => store.dispose(); }, [store]); useEffect(() => { - scrollToTop(); - }, [store.view, scrollToTop]); + jumpToStartEdge(); + }, [store.view, jumpToStartEdge]); useEffect(() => { if ( (!store.loading && store.shouldRenderSearchResults) || (!store.searchTerm.trim() && !store.shouldRenderSearchResults) ) { - scrollToTop(); + jumpToStartEdge(); } - }, [store.loading, store.shouldRenderSearchResults, store.searchTerm, scrollToTop]); + }, [store.loading, store.shouldRenderSearchResults, store.searchTerm, jumpToStartEdge]); useEffect(() => { if (!store.isShowingFavorites || FavoriteGif.totalCount === 0) return; void FavoriteGifCommands.refreshFavoriteGifPreviews(); diff --git a/fluxer_app/src/features/channel/components/pickers/memes/MemesPickerView.tsx b/fluxer_app/src/features/channel/components/pickers/memes/MemesPickerView.tsx index eedbf5d61..135a6faa7 100644 --- a/fluxer_app/src/features/channel/components/pickers/memes/MemesPickerView.tsx +++ b/fluxer_app/src/features/channel/components/pickers/memes/MemesPickerView.tsx @@ -58,11 +58,11 @@ export const MemesPickerView = observer(({onClose}: MemesPickerProps = {}) => { const searchInputRef = useRef(null); const [estimatedContentSize, setEstimatedContentSize] = useState(null); useSearchInputAutofocus(searchInputRef); - const {viewportSize, scrollTop, handleScroll, handleResize, scrollToTop} = useScrollerViewport(scrollerRef); - useWindowFocusVideoControl({scrollerRef, videoPool, gifAutoPlay: false}); + const {viewportSize, scrollTop, handleScroll, handleResize, jumpToStartEdge} = useScrollerViewport(scrollerRef); + useWindowFocusVideoControl({scrollerRef, videoPool}); useEffect(() => { - scrollToTop(); - }, [state.selectedFilter, state.searchTerm, scrollToTop]); + jumpToStartEdge(); + }, [state.selectedFilter, state.searchTerm, jumpToStartEdge]); const filteredMemes = useMemo(() => { let memes = [...favoriteMemes]; if (state.selectedFilter !== 'all') { diff --git a/fluxer_app/src/features/channel/components/pickers/shared/useScrollerViewport.ts b/fluxer_app/src/features/channel/components/pickers/shared/useScrollerViewport.ts index 484aa99b1..37688591d 100644 --- a/fluxer_app/src/features/channel/components/pickers/shared/useScrollerViewport.ts +++ b/fluxer_app/src/features/channel/components/pickers/shared/useScrollerViewport.ts @@ -1,10 +1,20 @@ // SPDX-License-Identifier: AGPL-3.0-or-later 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'; +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) { const [viewportSize, setViewportSize] = useState({width: 0, height: 0}); const [scrollTop, setScrollTop] = useState(0); @@ -12,6 +22,7 @@ export function useScrollerViewport(scrollerRef: React.RefObject(null); const pendingViewportSizeRef = useRef<{width: number; height: number} | null>(null); const viewportFrameRef = useRef(null); + const hasMeasuredViewportRef = useRef(false); const flushScrollTop = useCallback(() => { scrollFrameRef.current = null; const nextScrollTop = pendingScrollTopRef.current; @@ -27,16 +38,25 @@ export function useScrollerViewport(scrollerRef: React.RefObject { - viewportFrameRef.current = null; - const nextViewportSize = pendingViewportSizeRef.current; + const commitViewportSize = useCallback((nextViewportSize: {width: number; height: number}) => { pendingViewportSizeRef.current = null; - if (!nextViewportSize) return; + if (viewportFrameRef.current != null) { + cancelAnimationFrame(viewportFrameRef.current); + viewportFrameRef.current = null; + } + hasMeasuredViewportRef.current = true; setViewportSize((prev) => { if (prev.width === nextViewportSize.width && prev.height === nextViewportSize.height) return prev; return nextViewportSize; }); }, []); + const flushViewportSize = useCallback(() => { + viewportFrameRef.current = null; + const nextViewportSize = pendingViewportSizeRef.current; + pendingViewportSizeRef.current = null; + if (!nextViewportSize) return; + commitViewportSize(nextViewportSize); + }, [commitViewportSize]); const scheduleViewportSize = useCallback( (nextViewportSize: {width: number; height: number}) => { pendingViewportSizeRef.current = nextViewportSize; @@ -55,15 +75,22 @@ export function useScrollerViewport(scrollerRef: React.RefObject { if (type !== 'container') return; const {width, height} = entry.contentRect; + if (!hasMeasuredViewportRef.current && width > 0 && height > 0) { + commitViewportSize({width, height}); + return; + } scheduleViewportSize({width, height}); }, - [scheduleViewportSize], + [commitViewportSize, scheduleViewportSize], ); - useEffect(() => { - const state = scrollerRef.current?.readViewportMetrics(); - if (!state || state.offsetWidth === 0 || state.offsetHeight === 0) return; - scheduleViewportSize({width: state.offsetWidth, height: state.offsetHeight}); - }, [scrollerRef, scheduleViewportSize]); + useLayoutEffect(() => { + if (hasMeasuredViewportRef.current) return; + const element = scrollerRef.current?.getViewportElement() ?? null; + if (element === null) return; + const contentBox = readContentBoxSize(element); + if (contentBox === null) return; + commitViewportSize(contentBox); + }, [scrollerRef, commitViewportSize]); useEffect(() => { return () => { if (scrollFrameRef.current != null) { @@ -74,7 +101,7 @@ export function useScrollerViewport(scrollerRef: React.RefObject { + const jumpToStartEdge = useCallback(() => { scrollerRef.current?.scrollTo({to: 0, animate: false}); pendingScrollTopRef.current = null; if (scrollFrameRef.current != null) { @@ -89,6 +116,6 @@ export function useScrollerViewport(scrollerRef: React.RefObject; videoPool: VideoPoolControl; - gifAutoPlay?: boolean; } -export function useWindowFocusVideoControl({ - scrollerRef, - videoPool, - gifAutoPlay = true, -}: UseWindowFocusVideoControlOptions): void { +export function useWindowFocusVideoControl({scrollerRef, videoPool}: UseWindowFocusVideoControlOptions): void { const poolRef = useRef(videoPool); poolRef.current = videoPool; const scrollerRefRef = useRef(scrollerRef); scrollerRefRef.current = scrollerRef; - const gifAutoPlayRef = useRef(gifAutoPlay); - gifAutoPlayRef.current = gifAutoPlay; - useEffect(() => { - if (gifAutoPlay && getAnimatedMediaPlaybackAllowed()) { - poolRef.current.resumeAll(); - } else { - poolRef.current.pauseAll(); - } - }, [gifAutoPlay]); useEffect(() => { const updatePlayback = () => { - if (gifAutoPlayRef.current && getAnimatedMediaPlaybackAllowed()) { + if (getAnimatedMediaPlaybackAllowed()) { poolRef.current.resumeAll(); } else { poolRef.current.pauseAll();