From 03c4ffb4e5eb69abb4573f07b97bacb8538f8159 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 27 Feb 2026 15:47:09 -0600 Subject: [PATCH] Bug Fix: Applying any filter that temporarily empties the channel list (e.g. switching directly between two channel groups, or typing a search query that matches nothing) caused the guide to show a blank/empty view with no programs visible. The `VariableSizeList` unmounts when `filteredChannels` becomes empty, destroying its DOM node and resetting `scrollLeft` to 0. On remount the scroll position was never restored because `initialScrollComplete` was still `true`. Fixed by saving the user's current scroll position when the channel list empties mid-transition, then restoring it once new channels have loaded. On first load the guide still scrolls to the current time as before. --- CHANGELOG.md | 1 + frontend/src/pages/Guide.jsx | 52 +++++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b3ffbb3..074f6beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Channel table onboarding shown when filter returns zero results: The channel store refactor changed to loading only channel IDs instead of full channel objects, leaving `Object.keys(channels).length` always `0` and incorrectly triggering the onboarding state on any empty filter. Fixed by checking `channelIds.length` instead. +- TV Guide scrolls to position 0 when a filter yields no results: Applying any filter that temporarily empties the channel list (e.g. switching directly between two channel groups, or typing a search query that matches nothing) caused the guide to show a blank/empty view with no programs visible. The `VariableSizeList` unmounts when `filteredChannels` becomes empty, destroying its DOM node and resetting `scrollLeft` to 0. On remount the scroll position was never restored because `initialScrollComplete` was still `true`. Fixed by saving the user's current scroll position when the channel list empties mid-transition, then restoring it once new channels have loaded. On first load the guide still scrolls to the current time as before. ## [0.20.1] - 2026-02-26 diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index c59185f4..310ab78f 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -9,7 +9,7 @@ import React, { } from 'react'; import useChannelsStore from '../store/channels'; import useLogosStore from '../store/logos'; -import useVideoStore from '../store/useVideoStore'; // NEW import +import useVideoStore from '../store/useVideoStore'; import useSettingsStore from '../store/settings'; import { ActionIcon, @@ -232,7 +232,13 @@ export default function TVChannelGuide({ startDate, endDate }) { return () => { cancelled = true; }; - }, [channelGroups, searchQuery, selectedGroupId, selectedProfileId]); + }, [ + allowAllGroups, + channelGroups, + searchQuery, + selectedGroupId, + selectedProfileId, + ]); // Apply filters when search, group, or profile changes const filteredChannels = useMemo(() => { @@ -609,14 +615,46 @@ export default function TVChannelGuide({ startDate, endDate }) { }); }, []); - // Scroll to the nearest half-hour mark ONLY on initial load - useEffect(() => { - if (programs.length > 0 && !initialScrollComplete) { - syncScrollLeft(calculateScrollPosition(now, start)); + // Holds the scroll position to restore after a filter-induced remount. + // null means "use the default scroll-to-now on first load". + const savedScrollLeftRef = useRef(null); + // When channels become empty (filter transition unmounts the list), save the + // current scroll position so we can restore it once new channels arrive. + // Only save if the initial scroll has already happened — otherwise the saved + // position would be 0 (the DOM default) and we'd skip the scroll-to-now. + useEffect(() => { + if (filteredChannels.length === 0) { + if (initialScrollComplete) { + savedScrollLeftRef.current = guideScrollLeftRef.current; + } + setInitialScrollComplete(false); + } + }, [filteredChannels.length, initialScrollComplete]); + + // Scroll on initial load, or restore saved position after a filter transition. + // Guard with guideRef.current — the VariableSizeList outer div is null while + // unmounted, so we must wait until it remounts before calling syncScrollLeft. + useEffect(() => { + if (programs.length > 0 && !initialScrollComplete && guideRef.current) { + if (savedScrollLeftRef.current !== null) { + // Restore where the user was before the filter change + syncScrollLeft(savedScrollLeftRef.current); + savedScrollLeftRef.current = null; + } else { + // Genuine first load — scroll to current time + syncScrollLeft(calculateScrollPosition(now, start)); + } setInitialScrollComplete(true); } - }, [programs, start, now, initialScrollComplete, syncScrollLeft]); + }, [ + programs, + start, + now, + initialScrollComplete, + syncScrollLeft, + filteredChannels.length, + ]); const findChannelByTvgId = useCallback( (tvgId) => matchChannelByTvgId(channelIdByTvgId, channelById, tvgId),