diff --git a/CHANGELOG.md b/CHANGELOG.md index 27a5608e..ddc1f5dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,20 @@ 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. +- **Channel table performance**: + - Removed unused Zustand store subscriptions (`channels`, `selectedProfileChannels`, `selectedProfileChannelIds`) and an unused `channelIds` array subscription from `ChannelsTable` to reduce unnecessary re-renders on unrelated store updates. Cleaned up associated dead code and unused imports. + - Optimized `ChannelTableStreams` (the expanded stream list inside each channel row) to reduce mount cost: moved pure helper functions and static values (`getCoreRowModel`, `defaultColumn`, stat categorization/formatting) outside the component so they're created once; stabilized the TanStack column definitions by removing `data` and `expandedAdvancedStats` from the `useMemo` dependency array (cell renderers receive the row at render time); switched advanced-stats toggle tracking from `useState` to a `useRef` + per-cell local state so toggling one stream's stats doesn't recreate the entire column array and table instance; memoized `dataIds`, `removeStream`, `handleDragEnd`, and `handleWatchStream` with `useMemo`/`useCallback`; extracted `StreamInfoCell` as a `React.memo` component with its own memoized stat categorization. + - Fixed `getChannelStreams` store selector to return a stable empty-array reference instead of creating a new `[]` on every call for channels without streams, preventing unnecessary re-renders via the `shallow` comparator. + - Memoized individual rows in `CustomTableBody` so that expanding/collapsing a channel only re-renders the 1-2 affected rows instead of all rows on the page. Callback functions (`renderBodyCell`, `expandedRowRenderer`) are stored in refs so memoized rows always use the latest version without the function references themselves defeating the memo comparator. - 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) +### Changed + +- Decoupled row expansion from row selection in `CustomTable`, expanding a channel row no longer also selects it. Added an `onRowExpansionChange` callback to `useTable` so callers can react to expansion changes independently of selection state. +- Fixed a stale-closure bug in memoized channel row checkboxes, `selectedTableIdsRef` now ensures the checkbox `onChange` handler and `handleShiftSelect` always read the current selection set rather than the stale set captured at render time. Without this, clicking any checkbox after the first would silently deselect previously checked rows. +- Fixed the "Add to Channel" per-row and bulk buttons in the Streams table not activating when a channel row is expanded. `StreamRowActions` now subscribes to the Zustand store directly (bypassing `React.memo`) so button state updates when `expandedChannelId` or `selectedChannelIds` change without any parent row props changing. Added `targetChannelId` (expanded channel takes priority; falls back to a single selected channel) used by both the per-row and bulk add paths. +- Removed dead props `getExpandedRowHeight` and `tableBodyProps` from `CustomTableBody` and their corresponding pass-throughs in `CustomTable`, both were accepted but never consumed. + ## [0.23.0] - 2026-04-17 ### Security diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index 4344fdb2..4f67dd18 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -1,4 +1,10 @@ -import React, { useMemo, useState, useEffect } from 'react'; +import React, { + useMemo, + useState, + useEffect, + useCallback, + useRef, +} from 'react'; import API from '../../api'; import { copyToClipboard } from '../../utils'; import { @@ -21,7 +27,6 @@ import { Collapse, Button, } from '@mantine/core'; -import { notifications } from '@mantine/notifications'; import { useReactTable, getCoreRowModel, @@ -54,6 +59,114 @@ import { shallow } from 'zustand/shallow'; import useAuthStore from '../../store/auth'; import { USER_LEVELS } from '../../constants'; +// ── Static values (created once, shared across all instances) ──────────────── + +const coreRowModel = getCoreRowModel(); + +const defaultColumnConfig = { + size: undefined, + minSize: 0, +}; + +const categoryMapping = { + basic: [ + 'resolution', + 'video_codec', + 'source_fps', + 'audio_codec', + 'audio_channels', + ], + video: [ + 'video_bitrate', + 'pixel_format', + 'width', + 'height', + 'aspect_ratio', + 'frame_rate', + ], + audio: [ + 'audio_bitrate', + 'sample_rate', + 'audio_format', + 'audio_channels_layout', + ], + technical: [ + 'stream_type', + 'container_format', + 'duration', + 'file_size', + 'ffmpeg_output_bitrate', + 'input_bitrate', + ], + other: [], +}; + +const categorizeStreamStats = (stats) => { + if (!stats) + return { basic: {}, video: {}, audio: {}, technical: {}, other: {} }; + + const categories = { + basic: {}, + video: {}, + audio: {}, + technical: {}, + other: {}, + }; + + Object.entries(stats).forEach(([key, value]) => { + let categorized = false; + for (const [category, keys] of Object.entries(categoryMapping)) { + if (keys.includes(key)) { + categories[category][key] = value; + categorized = true; + break; + } + } + if (!categorized) { + categories.other[key] = value; + } + }); + + return categories; +}; + +const formatStatValue = (key, value) => { + if (value === null || value === undefined) return 'N/A'; + + switch (key) { + case 'video_bitrate': + case 'audio_bitrate': + case 'ffmpeg_output_bitrate': + return `${value} kbps`; + case 'source_fps': + case 'frame_rate': + return `${value} fps`; + case 'sample_rate': + return `${value} Hz`; + case 'file_size': + if (typeof value === 'number') { + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`; + if (value < 1024 * 1024 * 1024) + return `${(value / (1024 * 1024)).toFixed(2)} MB`; + return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } + return value; + case 'duration': + if (typeof value === 'number') { + const hours = Math.floor(value / 3600); + const minutes = Math.floor((value % 3600) / 60); + const seconds = Math.floor(value % 60); + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } + return value; + default: + return value.toString(); + } +}; + +// ── Sub-components ─────────────────────────────────────────────────────────── + const RowDragHandleCell = ({ rowId }) => { const { attributes, listeners, setNodeRef } = useDraggable({ id: rowId, @@ -129,7 +242,250 @@ const DraggableRow = ({ row, index }) => { ); }; -const ChannelStreams = ({ channel, isExpanded }) => { +// Stats category display component +const StatsCategory = ({ categoryName, stats }) => { + if (!stats || Object.keys(stats).length === 0) return null; + + return ( + + + {categoryName} + + + {Object.entries(stats).map(([key, value]) => ( + + + {key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}:{' '} + {formatStatValue(key, value)} + + + ))} + + + ); +}; + +// Extracted stream info cell — reads expandedAdvancedStats from a ref +// so toggling advanced stats doesn't recreate the columns array. +const StreamInfoCell = React.memo( + ({ + stream, + accountName, + expandedAdvancedStatsRef, + toggleAdvancedStats, + handleWatchStream, + }) => { + const [, forceUpdate] = useState(0); + const isExpanded = expandedAdvancedStatsRef.current.has(stream.id); + + const categorizedStats = useMemo( + () => categorizeStreamStats(stream.stream_stats), + [stream.stream_stats] + ); + const hasAdvancedStats = useMemo( + () => + Object.values(categorizedStats).some( + (category) => Object.keys(category).length > 0 + ), + [categorizedStats] + ); + + const onToggle = useCallback(() => { + toggleAdvancedStats(stream.id); + forceUpdate((n) => n + 1); + }, [stream.id, toggleAdvancedStats]); + + return ( + + + + {stream.name} + + + {accountName} + + {stream.quality && ( + + {stream.quality} + + )} + {stream.url && ( + <> + + { + e.stopPropagation(); + await copyToClipboard(stream.url, { + successTitle: 'URL Copied', + successMessage: 'Stream URL copied to clipboard', + }); + }} + > + URL + + + + + handleWatchStream( + stream.stream_hash || stream.id, + stream.name + ) + } + style={{ marginLeft: 2 }} + > + + + + + )} + + + {/* Basic Stream Stats (always shown) */} + {stream.stream_stats && ( + + {(stream.stream_stats.video_codec || + stream.stream_stats.resolution || + stream.stream_stats.video_bitrate || + stream.stream_stats.source_fps) && ( + <> + + Video: + + {stream.stream_stats.resolution && ( + + {stream.stream_stats.resolution} + + )} + {stream.stream_stats.video_bitrate && ( + + {stream.stream_stats.video_bitrate} kbps + + )} + {stream.stream_stats.source_fps && ( + + {stream.stream_stats.source_fps} FPS + + )} + {stream.stream_stats.video_codec && ( + + {stream.stream_stats.video_codec.toUpperCase()} + + )} + + )} + + {(stream.stream_stats.audio_codec || + stream.stream_stats.audio_channels) && ( + <> + + Audio: + + {stream.stream_stats.audio_channels && ( + + {stream.stream_stats.audio_channels} + + )} + {stream.stream_stats.audio_codec && ( + + {stream.stream_stats.audio_codec.toUpperCase()} + + )} + + )} + + {stream.stream_stats.ffmpeg_output_bitrate && ( + <> + + Output Bitrate: + + + {stream.stream_stats.ffmpeg_output_bitrate} kbps + + + )} + + )} + + {hasAdvancedStats && ( + + + + )} + + + + + + + + + {stream.stream_stats_updated_at && ( + + Last updated:{' '} + {new Date(stream.stream_stats_updated_at).toLocaleString()} + + )} + + + + ); + } +); + +// ── Main component ─────────────────────────────────────────────────────────── + +const ChannelStreams = ({ channel }) => { const theme = useMantineTheme(); const channelStreams = useChannelsTableStore( @@ -140,13 +496,17 @@ const ChannelStreams = ({ channel, isExpanded }) => { const authUser = useAuthStore((s) => s.user); const showVideo = useVideoStore((s) => s.showVideo); const env_mode = useSettingsStore((s) => s.environment.env_mode); - function handleWatchStream(streamHash, streamName) { - let vidUrl = `/proxy/ts/stream/${streamHash}`; - if (env_mode === 'dev') { - vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; - } - showVideo(vidUrl, 'live', streamName ? { name: streamName } : null); - } + + const handleWatchStream = useCallback( + (streamHash, streamName) => { + let vidUrl = `/proxy/ts/stream/${streamHash}`; + if (env_mode === 'dev') { + vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; + } + showVideo(vidUrl, 'live', streamName ? { name: streamName } : null); + }, + [env_mode, showVideo] + ); const [data, setData] = useState(channelStreams || []); @@ -154,19 +514,22 @@ const ChannelStreams = ({ channel, isExpanded }) => { setData(channelStreams); }, [channelStreams]); - const dataIds = data?.map(({ id }) => id); + const dataIds = useMemo(() => data?.map(({ id }) => id), [data]); - const removeStream = async (stream) => { - const newStreamList = data.filter((s) => s.id !== stream.id); - await API.updateChannel({ - ...channel, - streams: newStreamList.map((s) => s.id), - }); - await API.requeryChannels(); - await API.requeryStreams(); - }; + const removeStream = useCallback( + async (stream) => { + const newStreamList = data.filter((s) => s.id !== stream.id); + await API.updateChannel({ + ...channel, + streams: newStreamList.map((s) => s.id), + }); + await API.requeryChannels(); + await API.requeryStreams(); + }, + [channel, data] + ); - // Create M3U account map for quick lookup + // M3U account map for quick lookup const m3uAccountsMap = useMemo(() => { const map = {}; if (playlists && Array.isArray(playlists)) { @@ -179,426 +542,121 @@ const ChannelStreams = ({ channel, isExpanded }) => { return map; }, [playlists]); - // Add state for tracking which streams have advanced stats expanded - const [expandedAdvancedStats, setExpandedAdvancedStats] = useState(new Set()); + // Track expanded advanced stats via ref so toggling doesn't recreate columns + const expandedAdvancedStatsRef = useRef(new Set()); - // Helper function to categorize stream stats - const categorizeStreamStats = (stats) => { - if (!stats) - return { basic: {}, video: {}, audio: {}, technical: {}, other: {} }; - - const categories = { - basic: {}, - video: {}, - audio: {}, - technical: {}, - other: {}, - }; - - // Define which stats go in which category - const categoryMapping = { - basic: [ - 'resolution', - 'video_codec', - 'source_fps', - 'audio_codec', - 'audio_channels', - ], - video: [ - 'video_bitrate', - 'pixel_format', - 'width', - 'height', - 'aspect_ratio', - 'frame_rate', - ], - audio: [ - 'audio_bitrate', - 'sample_rate', - 'audio_format', - 'audio_channels_layout', - ], - technical: [ - 'stream_type', - 'container_format', - 'duration', - 'file_size', - 'ffmpeg_output_bitrate', - 'input_bitrate', - ], - other: [], // Will catch anything not categorized above - }; - - // Categorize each stat - Object.entries(stats).forEach(([key, value]) => { - let categorized = false; - - for (const [category, keys] of Object.entries(categoryMapping)) { - if (keys.includes(key)) { - categories[category][key] = value; - categorized = true; - break; - } - } - - // If not categorized, put it in 'other' - if (!categorized) { - categories.other[key] = value; - } - }); - - return categories; - }; - - // Function to format stat values for display - const formatStatValue = (key, value) => { - if (value === null || value === undefined) return 'N/A'; - - // Handle specific formatting cases - switch (key) { - case 'video_bitrate': - case 'audio_bitrate': - case 'ffmpeg_output_bitrate': - return `${value} kbps`; - case 'source_fps': - case 'frame_rate': - return `${value} fps`; - case 'sample_rate': - return `${value} Hz`; - case 'file_size': - // Convert bytes to appropriate unit - if (typeof value === 'number') { - if (value < 1024) return `${value} B`; - if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`; - if (value < 1024 * 1024 * 1024) - return `${(value / (1024 * 1024)).toFixed(2)} MB`; - return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`; - } - return value; - case 'duration': - // Format duration if it's in seconds - if (typeof value === 'number') { - const hours = Math.floor(value / 3600); - const minutes = Math.floor((value % 3600) / 60); - const seconds = Math.floor(value % 60); - return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - } - return value; - default: - return value.toString(); - } - }; - - // Function to render a stats category - const renderStatsCategory = (categoryName, stats) => { - if (!stats || Object.keys(stats).length === 0) return null; - - return ( - - - {categoryName} - - - {Object.entries(stats).map(([key, value]) => ( - - - {key - .replace(/_/g, ' ') - .replace(/\b\w/g, (l) => l.toUpperCase())} - : {formatStatValue(key, value)} - - - ))} - - - ); - }; - - // Function to toggle advanced stats for a stream - const toggleAdvancedStats = (streamId) => { - const newExpanded = new Set(expandedAdvancedStats); - if (newExpanded.has(streamId)) { - newExpanded.delete(streamId); + const toggleAdvancedStats = useCallback((streamId) => { + const current = expandedAdvancedStatsRef.current; + if (current.has(streamId)) { + current.delete(streamId); } else { - newExpanded.add(streamId); + current.add(streamId); } - setExpandedAdvancedStats(newExpanded); - }; + }, []); + + // Columns are now stable — they don't depend on data or expandedAdvancedStats. + // Cell renderers receive the row from TanStack at render time. + // StreamInfoCell reads the expanded stats ref directly. + const columns = useMemo( + () => [ + { + id: 'drag-handle', + header: 'Move', + cell: ({ row }) => , + size: 30, + }, + { + id: 'name', + header: 'Stream Info', + accessorKey: 'name', + cell: ({ row }) => { + const stream = row.original; + const playlistName = playlists[stream.m3u_account]?.name || 'Unknown'; + const accountName = + m3uAccountsMap[stream.m3u_account] || playlistName; + + return ( + + ); + }, + }, + { + id: 'actions', + header: '', + size: 30, + cell: ({ row }) => ( +
+ + removeStream(row.original)} + disabled={authUser.user_level != USER_LEVELS.ADMIN} + /> + +
+ ), + }, + ], + [ + playlists, + m3uAccountsMap, + theme, + authUser.user_level, + removeStream, + toggleAdvancedStats, + handleWatchStream, + ] + ); const table = useReactTable({ - columns: useMemo( - () => [ - { - id: 'drag-handle', - header: 'Move', - cell: ({ row }) => , - size: 30, - }, - { - id: 'name', - header: 'Stream Info', - accessorKey: 'name', - cell: ({ row }) => { - const stream = row.original; - const playlistName = - playlists[stream.m3u_account]?.name || 'Unknown'; - const accountName = - m3uAccountsMap[stream.m3u_account] || playlistName; - - // Categorize stream stats - const categorizedStats = categorizeStreamStats(stream.stream_stats); - const hasAdvancedStats = Object.values(categorizedStats).some( - (category) => Object.keys(category).length > 0 - ); - - return ( - - - - {stream.name} - - - {accountName} - - {stream.quality && ( - - {stream.quality} - - )} - {stream.url && ( - <> - - { - e.stopPropagation(); - await copyToClipboard(stream.url, { - successTitle: 'URL Copied', - successMessage: 'Stream URL copied to clipboard', - }); - }} - > - URL - - - - - handleWatchStream( - stream.stream_hash || stream.id, - stream.name - ) - } - style={{ marginLeft: 2 }} - > - - - - - )} - - - {/* Basic Stream Stats (always shown) */} - {stream.stream_stats && ( - - {/* Video Information */} - {(stream.stream_stats.video_codec || - stream.stream_stats.resolution || - stream.stream_stats.video_bitrate || - stream.stream_stats.source_fps) && ( - <> - - Video: - - {stream.stream_stats.resolution && ( - - {stream.stream_stats.resolution} - - )} - {stream.stream_stats.video_bitrate && ( - - {stream.stream_stats.video_bitrate} kbps - - )} - {stream.stream_stats.source_fps && ( - - {stream.stream_stats.source_fps} FPS - - )} - {stream.stream_stats.video_codec && ( - - {stream.stream_stats.video_codec.toUpperCase()} - - )} - - )} - - {/* Audio Information */} - {(stream.stream_stats.audio_codec || - stream.stream_stats.audio_channels) && ( - <> - - Audio: - - {stream.stream_stats.audio_channels && ( - - {stream.stream_stats.audio_channels} - - )} - {stream.stream_stats.audio_codec && ( - - {stream.stream_stats.audio_codec.toUpperCase()} - - )} - - )} - - {/* Output Bitrate */} - {stream.stream_stats.ffmpeg_output_bitrate && ( - <> - - Output Bitrate: - - {stream.stream_stats.ffmpeg_output_bitrate && ( - - {stream.stream_stats.ffmpeg_output_bitrate} kbps - - )} - - )} - - )} - - {/* Advanced Stats Toggle Button */} - {hasAdvancedStats && ( - - - - )} - - {/* Advanced Stats (expandable) */} - - - {renderStatsCategory('Video', categorizedStats.video)} - {renderStatsCategory('Audio', categorizedStats.audio)} - {renderStatsCategory( - 'Technical', - categorizedStats.technical - )} - {renderStatsCategory('Other', categorizedStats.other)} - - {/* Show when stats were last updated */} - {stream.stream_stats_updated_at && ( - - Last updated:{' '} - {new Date( - stream.stream_stats_updated_at - ).toLocaleString()} - - )} - - - - ); - }, - }, - { - id: 'actions', - header: '', - size: 30, - cell: ({ row }) => ( -
- - removeStream(row.original)} - disabled={authUser.user_level != USER_LEVELS.ADMIN} - /> - -
- ), - }, - ], - [data, playlists, m3uAccountsMap, expandedAdvancedStats] - ), + columns, data, state: { data, }, - defaultColumn: { - size: undefined, - minSize: 0, - }, + defaultColumn: defaultColumnConfig, manualPagination: true, manualSorting: true, manualFiltering: true, enableRowSelection: true, getRowId: (row) => row.id, - getCoreRowModel: getCoreRowModel(), + getCoreRowModel: coreRowModel, }); - const handleDragEnd = (event) => { - if (authUser.user_level != USER_LEVELS.ADMIN) { - return; - } + const handleDragEnd = useCallback( + (event) => { + if (authUser.user_level != USER_LEVELS.ADMIN) { + return; + } - const { active, over } = event; - if (active && over && active.id !== over.id) { - setData((data) => { - const oldIndex = dataIds.indexOf(active.id); - const newIndex = dataIds.indexOf(over.id); - const retval = arrayMove(data, oldIndex, newIndex); + const { active, over } = event; + if (active && over && active.id !== over.id) { + setData((prevData) => { + const currentIds = prevData.map(({ id }) => id); + const oldIndex = currentIds.indexOf(active.id); + const newIndex = currentIds.indexOf(over.id); + const retval = arrayMove(prevData, oldIndex, newIndex); - const { streams: _, ...channelUpdate } = channel; - API.updateChannel({ - ...channelUpdate, - streams: retval.map((row) => row.id), - }).then(() => { - API.requeryChannels(); + const { streams: _, ...channelUpdate } = channel; + API.updateChannel({ + ...channelUpdate, + streams: retval.map((row) => row.id), + }).then(() => { + API.requeryChannels(); + }); + + return retval; }); - - return retval; //this is just a splice util - }); - } - }; + } + }, + [authUser.user_level, channel] + ); const sensors = useSensors( useSensor(MouseSensor, {}), @@ -606,10 +664,6 @@ const ChannelStreams = ({ channel, isExpanded }) => { useSensor(KeyboardSensor, {}) ); - if (!isExpanded) { - return <>; - } - const rows = table.getRowModel().rows; return ( @@ -623,7 +677,6 @@ const ChannelStreams = ({ channel, isExpanded }) => { onDragEnd={handleDragEnd} sensors={sensors} > - {' '} { )} {rows.length > 0 && - table - .getRowModel() - .rows.map((row) => )} + rows.map((row, index) => ( + + ))} diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index b531cff6..05c548fd 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -288,6 +288,9 @@ const ChannelsTable = ({ onReady }) => { (s) => s.setSelectedChannelIds ); const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds); + const setExpandedChannelId = useChannelsTableStore( + (s) => s.setExpandedChannelId + ); const pagination = useChannelsTableStore((s) => s.pagination); const setPagination = useChannelsTableStore((s) => s.setPagination); const sorting = useChannelsTableStore((s) => s.sorting); @@ -695,6 +698,13 @@ const ChannelsTable = ({ onReady }) => { setSelectedChannelIds(newSelection); }; + const onRowExpansionChange = useCallback( + (expandedIds) => { + setExpandedChannelId(expandedIds.length > 0 ? expandedIds[0] : null); + }, + [setExpandedChannelId] + ); + const onPageSizeChange = (e) => { setPagination({ ...pagination, @@ -1136,6 +1146,7 @@ const ChannelsTable = ({ onReady }) => { enableRowSelection: true, enableDragDrop: true, onRowSelectionChange: onRowSelectionChange, + onRowExpansionChange: onRowExpansionChange, state: { pagination, sorting, @@ -1151,7 +1162,7 @@ const ChannelsTable = ({ onReady }) => { className="tr" style={{ display: 'flex', width: '100%' }} > - + ); }, diff --git a/frontend/src/components/tables/CustomTable/CustomTable.jsx b/frontend/src/components/tables/CustomTable/CustomTable.jsx index f1f081dd..55af4724 100644 --- a/frontend/src/components/tables/CustomTable/CustomTable.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTable.jsx @@ -57,11 +57,10 @@ const CustomTable = ({ table }) => { expandedRowIds={table.expandedRowIds} expandedRowRenderer={table.expandedRowRenderer} renderBodyCell={table.renderBodyCell} - getExpandedRowHeight={table.getExpandedRowHeight} getRowStyles={table.getRowStyles} - tableBodyProps={table.tableBodyProps} tableCellProps={table.tableCellProps} enableDragDrop={table.enableDragDrop} + selectedTableIdsSet={table.selectedTableIdsSet} /> ); diff --git a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx index 0f098835..40fd5678 100644 --- a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx @@ -1,68 +1,35 @@ import { Box, Flex } from '@mantine/core'; -import { VariableSizeList as List } from 'react-window'; -import AutoSizer from 'react-virtualized-auto-sizer'; -import { useMemo } from 'react'; -import table from '../../../helpers/table'; +import React, { useRef } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { GripVertical } from 'lucide-react'; import useChannelsTableStore from '../../../store/channelsTable'; -const CustomTableBody = ({ - getRowModel, - expandedRowIds, - expandedRowRenderer, - renderBodyCell, - getExpandedRowHeight, - getRowStyles, - tableBodyProps, - tableCellProps, - enableDragDrop = false, -}) => { - const renderExpandedRow = (row) => { - if (expandedRowRenderer) { - return expandedRowRenderer({ row }); - } - - return <>; - }; - - const rows = getRowModel().rows; - - // Calculate minimum width based only on fixed-size columns - const minTableWidth = useMemo(() => { - if (rows.length === 0) return 0; - - return rows[0].getVisibleCells().reduce((total, cell) => { - // Only count columns with fixed sizes, flexible columns will expand - const columnSize = cell.column.columnDef.size - ? cell.column.getSize() - : cell.column.columnDef.minSize || 150; // Default min for flexible columns - return total + columnSize; - }, 0); - }, [rows]); - - const renderTableBodyContents = () => { - return ( - - {rows.map((row, index) => renderTableBodyRow(row, index))} - - ); - }; - - const renderTableBodyRow = (row, index, style = {}) => { - // Get custom styles for this row if the function exists +// Memoized row — only re-renders when this specific row's data, expansion +// state, or drag-drop config actually changes. Callback functions are read +// from refs so the memoized row always uses the latest version when it *does* +// re-render, without needing them as comparator inputs. +const MemoizedTableRow = React.memo( + ({ + row, + index, + isExpanded, + isSelected, + renderBodyCellRef, + expandedRowRendererRef, + getRowStyles, + tableCellProps, + enableDragDrop, + }) => { + const renderBodyCell = renderBodyCellRef.current; const customRowStyles = getRowStyles ? getRowStyles(row) : {}; - - // Extract any className from customRowStyles const customClassName = customRowStyles.className || ''; - delete customRowStyles.className; // Remove from object so it doesn't get applied as inline style + delete customRowStyles.className; return ( {row.getVisibleCells().map((cell) => { @@ -107,12 +74,63 @@ const CustomTableBody = ({ ); })} - {expandedRowIds.includes(row.original.id) && renderExpandedRow(row)} + {isExpanded && expandedRowRendererRef.current({ row })} ); - }; + }, + (prev, next) => { + return ( + prev.row.original === next.row.original && + prev.index === next.index && + prev.isExpanded === next.isExpanded && + prev.isSelected === next.isSelected && + prev.enableDragDrop === next.enableDragDrop + ); + } +); - return renderTableBodyContents(); +const CustomTableBody = ({ + getRowModel, + expandedRowIds, + expandedRowRenderer, + renderBodyCell, + getRowStyles, + tableCellProps, + enableDragDrop = false, + selectedTableIdsSet, +}) => { + // Store callbacks in refs so memoized rows always access the latest versions + // without the function references themselves triggering re-renders. + const renderBodyCellRef = useRef(renderBodyCell); + renderBodyCellRef.current = renderBodyCell; + + const expandedRowRendererRef = useRef(expandedRowRenderer); + expandedRowRendererRef.current = expandedRowRenderer; + + const rows = getRowModel().rows; + + return ( + + {rows.map((row, index) => ( + + ))} + + ); }; const DraggableRowWrapper = ({ diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index c87b57bb..0c081721 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -8,7 +8,7 @@ import { getCoreRowModel, flexRender, } from '@tanstack/react-table'; -import { useCallback, useMemo, useState, useEffect } from 'react'; +import { useCallback, useMemo, useRef, useState, useEffect } from 'react'; import { ChevronDown, ChevronRight } from 'lucide-react'; const useTable = ({ @@ -17,6 +17,7 @@ const useTable = ({ bodyCellRenderFns = {}, expandedRowRenderer = () => <>, onRowSelectionChange = null, + onRowExpansionChange = null, getExpandedRowHeight = null, state = {}, columnSizing, @@ -25,6 +26,8 @@ const useTable = ({ ...options }) => { const [selectedTableIds, setSelectedTableIds] = useState([]); + const selectedTableIdsRef = useRef(selectedTableIds); + selectedTableIdsRef.current = selectedTableIds; const [expandedRowIds, setExpandedRowIds] = useState([]); const [lastClickedId, setLastClickedId] = useState(null); const [isShiftKeyDown, setIsShiftKeyDown] = useState(false); @@ -146,12 +149,15 @@ const useTable = ({ }; const onRowExpansion = (row) => { - let isExpanded = false; + const rowId = row.original.id; + let newIds; setExpandedRowIds((prev) => { - isExpanded = prev.includes(row.original.id) ? [] : [row.original.id]; - return isExpanded; + newIds = prev.includes(rowId) ? [] : [rowId]; + return newIds; }); - updateSelectedTableIds([row.original.id]); + if (onRowExpansionChange) { + onRowExpansionChange(newIds); + } }; // Handle the shift+click selection @@ -174,7 +180,7 @@ const useTable = ({ const rangeIds = allRowIds.slice(startIndex, endIndex + 1); // Preserve existing selections outside the range - const idsOutsideRange = selectedTableIds.filter( + const idsOutsideRange = selectedTableIdsRef.current.filter( (id) => !rangeIds.includes(id) ); const newSelection = [...new Set([...rangeIds, ...idsOutsideRange])]; @@ -206,7 +212,7 @@ const useTable = ({ // Try to handle with shift-select logic first if (!handleShiftSelect(rowId, isShiftKey)) { // If not handled by shift-select, do regular toggle - const newSet = new Set(selectedTableIds); + const newSet = new Set(selectedTableIdsRef.current); if (e.target.checked) { newSet.add(rowId); } else { diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 1d2e972e..a164b885 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -74,19 +74,23 @@ const StreamRowActions = ({ editStream, deleteStream, handleWatchStream, - selectedChannelIds, createChannelFromStream, table, }) => { const tableSize = table?.tableSize ?? 'default'; + const expandedChannelId = useChannelsTableStore((s) => s.expandedChannelId); + const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds); + const targetChannelId = + expandedChannelId || + (selectedChannelIds.length === 1 ? selectedChannelIds[0] : null); const channelSelectionStreams = useChannelsTableStore( (state) => - state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams + state.channels.find((chan) => chan.id === targetChannelId)?.streams ); const addStreamToChannel = async () => { await API.updateChannel({ - id: selectedChannelIds[0], + id: targetChannelId, streams: [ ...new Set( channelSelectionStreams.map((s) => s.id).concat([row.original.id]) @@ -129,7 +133,7 @@ const StreamRowActions = ({ onClick={addStreamToChannel} style={{ background: 'none' }} disabled={ - selectedChannelIds.length !== 1 || + !targetChannelId || (channelSelectionStreams && channelSelectionStreams .map((s) => s.id) @@ -331,10 +335,14 @@ const StreamsTable = ({ onReady }) => { const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups); const channelGroups = useChannelsStore((s) => s.channelGroups); + const expandedChannelId = useChannelsTableStore((s) => s.expandedChannelId); const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds); + const targetChannelId = + expandedChannelId || + (selectedChannelIds.length === 1 ? selectedChannelIds[0] : null); const channelSelectionStreams = useChannelsTableStore( (state) => - state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams + state.channels.find((chan) => chan.id === targetChannelId)?.streams ); const channelProfiles = useChannelsStore((s) => s.profiles); const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); @@ -1033,7 +1041,7 @@ const StreamsTable = ({ onReady }) => { const addStreamsToChannel = async () => { await API.updateChannel({ - id: selectedChannelIds[0], + id: targetChannelId, streams: [ ...new Set( channelSelectionStreams.map((s) => s.id).concat(selectedStreamIds) @@ -1267,20 +1275,12 @@ const StreamsTable = ({ onReady }) => { editStream={editStream} deleteStream={deleteStream} handleWatchStream={handleWatchStream} - selectedChannelIds={selectedChannelIds} createChannelFromStream={createChannelFromStream} /> ); } }, - [ - selectedChannelIds, - channelSelectionStreams, - theme, - editStream, - deleteStream, - handleWatchStream, - ] + [theme, editStream, deleteStream, handleWatchStream] ); const table = useTable({ @@ -1462,14 +1462,13 @@ const StreamsTable = ({ onReady }) => { > diff --git a/frontend/src/store/channelsTable.jsx b/frontend/src/store/channelsTable.jsx index 7c1f05f2..baa20925 100644 --- a/frontend/src/store/channelsTable.jsx +++ b/frontend/src/store/channelsTable.jsx @@ -1,5 +1,8 @@ import { create } from 'zustand'; +// Stable empty array to avoid creating new references in getChannelStreams +const emptyStreams = []; + const useChannelsTableStore = create((set, get) => ({ channels: [], pageCount: 0, @@ -12,6 +15,7 @@ const useChannelsTableStore = create((set, get) => ({ JSON.parse(localStorage.getItem('channel-table-prefs'))?.pageSize || 50, }, selectedChannelIds: [], + expandedChannelId: null, allQueryIds: [], isUnlocked: false, @@ -38,9 +42,15 @@ const useChannelsTableStore = create((set, get) => ({ }); }, + setExpandedChannelId: (expandedChannelId) => { + set({ + expandedChannelId, + }); + }, + getChannelStreams: (id) => { const channel = get().channels.find((c) => c.id === id); - return channel?.streams ?? []; + return channel?.streams || emptyStreams; }, setPagination: (pagination) => {