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 && (
+
+
+ ) : (
+
+ )
+ }
+ onClick={onToggle}
+ c="dimmed"
+ >
+ {isExpanded ? 'Hide' : 'Show'} Advanced Stats
+
+
+ )}
+
+
+
+
+
+
+
+
+ {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 && (
-
-
- ) : (
-
- )
- }
- onClick={() => toggleAdvancedStats(stream.id)}
- c="dimmed"
- >
- {expandedAdvancedStats.has(stream.id) ? 'Hide' : 'Show'}{' '}
- Advanced Stats
-
-
- )}
-
- {/* 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 }) => {
>
}
variant={
- selectedStreamIds.length > 0 &&
- selectedChannelIds.length === 1
+ selectedStreamIds.length > 0 && targetChannelId
? 'light'
: 'default'
}
@@ -1477,14 +1476,12 @@ const StreamsTable = ({ onReady }) => {
onClick={addStreamsToChannel}
p={5}
color={
- selectedStreamIds.length > 0 &&
- selectedChannelIds.length === 1
+ selectedStreamIds.length > 0 && targetChannelId
? theme.tailwind.green[5]
: undefined
}
style={
- selectedStreamIds.length > 0 &&
- selectedChannelIds.length === 1
+ selectedStreamIds.length > 0 && targetChannelId
? {
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
@@ -1492,12 +1489,7 @@ const StreamsTable = ({ onReady }) => {
}
: undefined
}
- disabled={
- !(
- selectedStreamIds.length > 0 &&
- selectedChannelIds.length === 1
- )
- }
+ disabled={!(selectedStreamIds.length > 0 && targetChannelId)}
>
Add to Channel
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) => {