diff --git a/CHANGELOG.md b/CHANGELOG.md index 91500d87..27a5608e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Performance +- Reduced unnecessary re-renders in the channels table. Removed three Zustand store subscriptions from `ChannelsTable` that caused a full component re-render on every unrelated store update: the entire `channels` map, `selectedProfileChannels`, and `selectedProfileChannelIds` (these were superseded when `ChannelEnabledSwitch` was refactored to subscribe to the store directly). Narrowed the `channelIds` subscription to a stable boolean `hasChannels` so only the empty↔non-empty transition triggers a re-render instead of every channel add/remove. Added a custom `React.memo` comparator to `ChannelRowActions` that compares `row.original.id` rather than the full TanStack Table row object (which is recreated each render cycle), preventing every action cell from re-rendering on table updates. Memoized `getChannelURL`, `createRecording`, and `handleWatchStream` with `useCallback` for stable column cell references. - Eliminated repeated DB queries in the `ts_proxy` hot path. `StreamManager`, `StreamGenerator`, and `ProxyServer` were each calling `Channel.objects.get(uuid=...)` on every retry, reconnect, failover, and buffering event solely to retrieve `channel.name` for log events. `StreamManager` and `StreamGenerator` now fetch the channel name once at construction via a lightweight `values_list` query and store it as `self.channel_name`. `ProxyServer` caches the name in a `_channel_names` dict keyed by channel ID at channel-start time and pops it at channel-stop time. (Fixes #1138) ## [0.23.0] - 2026-04-17 diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index c3a3e363..b531cff6 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -17,13 +17,11 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; import useChannelsStore from '../../store/channels'; -import { notifications } from '@mantine/notifications'; import API from '../../api'; import ChannelForm from '../forms/Channel'; import ChannelBatchForm from '../forms/ChannelBatch'; import RecordingForm from '../forms/Recording'; import { useDebounce, copyToClipboard } from '../../utils'; -import logo from '../../images/logo.png'; import useVideoStore from '../../store/useVideoStore'; import useSettingsStore from '../../store/settings'; import { @@ -65,7 +63,6 @@ import { Tooltip, Skeleton, } from '@mantine/core'; -import { getCoreRowModel, flexRender } from '@tanstack/react-table'; import './table.css'; import useChannelsTableStore from '../../store/channelsTable'; import ChannelTableStreams from './ChannelTableStreams'; @@ -243,7 +240,12 @@ const ChannelRowActions = React.memo( ); - } + }, + // Custom comparator: only re-render when the actual channel changes. + // The row object is a new TanStack Table reference on each render, but + // row.original.id is stable. Callbacks read fresh data at call time. + (prevProps, nextProps) => + prevProps.row.original.id === nextProps.row.original.id ); const ChannelsTable = ({ onReady }) => { @@ -291,22 +293,16 @@ const ChannelsTable = ({ onReady }) => { const sorting = useChannelsTableStore((s) => s.sorting); const setSorting = useChannelsTableStore((s) => s.setSorting); const totalCount = useChannelsTableStore((s) => s.totalCount); - const setChannelStreams = useChannelsTableStore((s) => s.setChannelStreams); const allRowIds = useChannelsTableStore((s) => s.allQueryIds); const setAllRowIds = useChannelsTableStore((s) => s.setAllQueryIds); - const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); // store/channels - const channels = useChannelsStore((s) => s.channels); - const channelIds = useChannelsStore((s) => s.channelIds); + const hasChannels = useChannelsStore((s) => s.channelIds.length > 0); const profiles = useChannelsStore((s) => s.profiles); const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); - const [tablePrefs, setTablePrefs] = useLocalStorage('channel-table-prefs', { + const [, setTablePrefs] = useLocalStorage('channel-table-prefs', { pageSize: 50, }); - const selectedProfileChannels = useChannelsStore( - (s) => s.profiles[selectedProfileId]?.channels - ); // store/settings const env_mode = useSettingsStore((s) => s.environment.env_mode); @@ -316,14 +312,6 @@ const ChannelsTable = ({ onReady }) => { const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); - /** - * useMemo - */ - const selectedProfileChannelIds = useMemo( - () => new Set(selectedProfileChannels), - [selectedProfileChannels] - ); - /** * useState */ @@ -331,9 +319,6 @@ const ChannelsTable = ({ onReady }) => { const [channelModalOpen, setChannelModalOpen] = useState(false); const [channelBatchModalOpen, setChannelBatchModalOpen] = useState(false); const [recordingModalOpen, setRecordingModalOpen] = useState(false); - const [selectedProfile, setSelectedProfile] = useState( - profiles[selectedProfileId] - ); const [showDisabled, setShowDisabled] = useState(true); const [showOnlyStreamlessChannels, setShowOnlyStreamlessChannels] = useState(false); @@ -345,7 +330,7 @@ const ChannelsTable = ({ onReady }) => { channel_group: '', epg: '', }); - const [isLoading, setIsLoading] = useState(true); + const [, setIsLoading] = useState(true); const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase); const [epgUrl, setEPGUrl] = useState(epgUrlBase); @@ -490,7 +475,7 @@ const ChannelsTable = ({ onReady }) => { setIsLoading(true); try { - const [results, ids] = await Promise.all([ + const [, ids] = await Promise.all([ API.queryChannels(params), API.getAllChannelIds(params), ]); @@ -673,37 +658,38 @@ const ChannelsTable = ({ onReady }) => { } }; - const createRecording = (channel) => { + const createRecording = useCallback((channel) => { console.log(`Recording channel ID: ${channel.id}`); setChannel(channel); setRecordingModalOpen(true); - }; + }, []); - const getChannelURL = (channel) => { - // Make sure we're using the channel UUID consistently - if (!channel || !channel.uuid) { - console.error('Invalid channel object or missing UUID:', channel); - return ''; - } + const getChannelURL = useCallback( + (channel) => { + // Make sure we're using the channel UUID consistently + if (!channel || !channel.uuid) { + console.error('Invalid channel object or missing UUID:', channel); + return ''; + } - const uri = `/proxy/ts/stream/${channel.uuid}`; - let channelUrl = `${window.location.protocol}//${window.location.host}${uri}`; - if (env_mode == 'dev') { - channelUrl = `${window.location.protocol}//${window.location.hostname}:5656${uri}`; - } + const uri = `/proxy/ts/stream/${channel.uuid}`; + let channelUrl = `${window.location.protocol}//${window.location.host}${uri}`; + if (env_mode == 'dev') { + channelUrl = `${window.location.protocol}//${window.location.hostname}:5656${uri}`; + } - return channelUrl; - }; + return channelUrl; + }, + [env_mode] + ); - const handleWatchStream = (channel) => { - // Add additional logging to help debug issues - console.log( - `Watching stream for channel: ${channel.name} (${channel.id}), UUID: ${channel.uuid}` - ); - const url = getChannelURL(channel); - console.log(`Stream URL: ${url}`); - showVideo(url, 'live', { name: channel.name }); - }; + const handleWatchStream = useCallback( + (channel) => { + const url = getChannelURL(channel); + showVideo(url, 'live', { name: channel.name }); + }, + [getChannelURL, showVideo] + ); const onRowSelectionChange = (newSelection) => { setSelectedChannelIds(newSelection); @@ -861,8 +847,6 @@ const ChannelsTable = ({ onReady }) => { }, [fetchData]); useEffect(() => { - setSelectedProfile(profiles[selectedProfileId]); - const profileString = selectedProfileId != '0' ? `/${profiles[selectedProfileId].name}` : ''; setHDHRUrl(`${hdhrUrlBase}${profileString}`); @@ -1524,12 +1508,12 @@ const ChannelsTable = ({ onReady }) => { {/* Table or ghost empty state inside Paper */} - {channelsTableLength === 0 && channelIds.length === 0 && ( + {channelsTableLength === 0 && !hasChannels && ( )} - {(channelsTableLength > 0 || channelIds.length > 0) && ( + {(channelsTableLength > 0 || hasChannels) && ( { - const virtualized = false; - - if (virtualized) { - return ( - - - {({ height }) => { - const getItemSize = (index) => { - const row = rows[index]; - const isExpanded = expandedRowIds.includes(row.original.id); - console.log(isExpanded); - - // Default row height - let rowHeight = 28; - - if (isExpanded && getExpandedRowHeight) { - // If row is expanded, adjust the height to be larger (based on your logic) - // You can get this height from your state, or calculate based on number of items in the expanded row - rowHeight += getExpandedRowHeight(row); // This function would calculate the expanded row's height - } - - return rowHeight; - }; - - return ( - - {({ index, style }) => { - const row = rows[index]; - return renderTableBodyRow(row, index, style); - }} - - ); - }} - - - ); - } - return ( {rows.map((row, index) => renderTableBodyRow(row, index))}