From e22e003be9064bb17ff00ff98020b08e882fc379 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 25 Jan 2026 19:46:34 -0600 Subject: [PATCH 01/15] Data loading and initialization refactor: Major performance improvement reducing initial page load time by eliminating duplicate API requests caused by race conditions between authentication flow and route rendering: - Fixed authentication race condition where `isAuthenticated` was set before data loading completed, causing routes to render and tables to mount prematurely - Added `isInitialized` flag to delay route rendering until after all initialization data is loaded via `initData()` - Consolidated version and environment settings fetching into centralized settings store with caching to prevent redundant calls - Implemented stale fetch prevention in ChannelsTable and StreamsTable using fetch version tracking to ignore outdated responses - Fixed filter handling in tables to use `debouncedFilters` consistently, preventing unnecessary refetches - Added initialization guards using refs to prevent double-execution of auth and superuser checks during React StrictMode's intentional double-rendering in development - Removed duplicate version/environment fetch calls from Sidebar, LoginForm, and SuperuserForm by using centralized store --- CHANGELOG.md | 8 +++ frontend/src/App.jsx | 36 +++++++++---- frontend/src/components/Sidebar.jsx | 42 ++------------- frontend/src/components/forms/LoginForm.jsx | 21 ++++---- .../src/components/forms/SuperuserForm.jsx | 16 +++--- .../src/components/tables/ChannelsTable.jsx | 20 +++++-- .../src/components/tables/StreamsTable.jsx | 18 +++++++ frontend/src/store/auth.jsx | 41 +++++++++++---- frontend/src/store/settings.jsx | 52 +++++++++++++++++-- 9 files changed, 167 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 388f2156..1fd90457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Data loading and initialization refactor: Major performance improvement reducing initial page load time by eliminating duplicate API requests caused by race conditions between authentication flow and route rendering: + - Fixed authentication race condition where `isAuthenticated` was set before data loading completed, causing routes to render and tables to mount prematurely + - Added `isInitialized` flag to delay route rendering until after all initialization data is loaded via `initData()` + - Consolidated version and environment settings fetching into centralized settings store with caching to prevent redundant calls + - Implemented stale fetch prevention in ChannelsTable and StreamsTable using fetch version tracking to ignore outdated responses + - Fixed filter handling in tables to use `debouncedFilters` consistently, preventing unnecessary refetches + - Added initialization guards using refs to prevent double-execution of auth and superuser checks during React StrictMode's intentional double-rendering in development + - Removed duplicate version/environment fetch calls from Sidebar, LoginForm, and SuperuserForm by using centralized store - Table preferences (header pin and table size) now managed together with centralized state management and localStorage persistence. - Streams table button labels: Renamed "Remove" to "Delete" and "Add Stream to Channel" to "Add to Channel" for clarity and consistency with other UI terminology. - Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f22d408f..3869740e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,4 @@ -// frontend/src/App.js -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { BrowserRouter as Router, Route, @@ -40,18 +39,25 @@ const defaultRoute = '/channels'; const App = () => { const [open, setOpen] = useState(true); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const isInitialized = useAuthStore((s) => s.isInitialized); const setIsAuthenticated = useAuthStore((s) => s.setIsAuthenticated); const logout = useAuthStore((s) => s.logout); const initData = useAuthStore((s) => s.initData); const initializeAuth = useAuthStore((s) => s.initializeAuth); const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists); + const authCheckStarted = useRef(false); + const superuserCheckStarted = useRef(false); + const toggleDrawer = () => { setOpen(!open); }; // Check if a superuser exists on first load. useEffect(() => { + if (superuserCheckStarted.current) return; + superuserCheckStarted.current = true; + async function checkSuperuser() { try { const response = await API.fetchSuperUser(); @@ -69,10 +75,13 @@ const App = () => { } } checkSuperuser(); - }, []); + }, [setSuperuserExists]); // Authentication check useEffect(() => { + if (authCheckStarted.current) return; + authCheckStarted.current = true; + const checkAuth = async () => { try { const loggedIn = await initializeAuth(); @@ -105,14 +114,15 @@ const App = () => { height: 0, }} navbar={{ - width: isAuthenticated - ? open - ? drawerWidth - : miniDrawerWidth - : 0, + width: + isAuthenticated && isInitialized + ? open + ? drawerWidth + : miniDrawerWidth + : 0, }} > - {isAuthenticated && ( + {isAuthenticated && isInitialized && ( { > - {isAuthenticated ? ( + {isAuthenticated && isInitialized ? ( <> } /> } /> @@ -154,7 +164,11 @@ const App = () => { path="*" element={ } diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index d8c3fae8..a25aa301 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useState } from 'react'; +import React, { useRef, useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { copyToClipboard } from '../utils'; import { @@ -33,8 +33,7 @@ import logo from '../images/logo.png'; import useChannelsStore from '../store/channels'; import './sidebar.css'; import useSettingsStore from '../store/settings'; -import useAuthStore from '../store/auth'; // Add this import -import API from '../api'; +import useAuthStore from '../store/auth'; import { USER_LEVELS } from '../constants'; import UserForm from './forms/User'; @@ -75,16 +74,13 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { const channels = useChannelsStore((s) => s.channels); const environment = useSettingsStore((s) => s.environment); + const appVersion = useSettingsStore((s) => s.version); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const authUser = useAuthStore((s) => s.user); const logout = useAuthStore((s) => s.logout); const publicIPRef = useRef(null); - const [appVersion, setAppVersion] = useState({ - version: '', - timestamp: null, - }); const [userFormOpen, setUserFormOpen] = useState(false); const closeUserForm = () => setUserFormOpen(false); @@ -144,36 +140,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { }, ]; - // Fetch environment settings including version on component mount - useEffect(() => { - if (!isAuthenticated) { - return; - } - - const fetchEnvironment = async () => { - API.getEnvironmentSettings(); - }; - - fetchEnvironment(); - }, [isAuthenticated]); - - // Fetch version information on component mount (regardless of authentication) - useEffect(() => { - const fetchVersion = async () => { - try { - const versionData = await API.getVersion(); - setAppVersion({ - version: versionData.version || '', - timestamp: versionData.timestamp || null, - }); - } catch (error) { - console.error('Failed to fetch version information:', error); - // Keep using default values from useState initialization - } - }; - - fetchVersion(); - }, []); + // Environment settings and version are loaded by the settings store during initData() + // No need to fetch them again here - just use the store values const copyPublicIP = async () => { const success = await copyToClipboard(environment.public_ip); diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx index 353cd50e..4e973891 100644 --- a/frontend/src/components/forms/LoginForm.jsx +++ b/frontend/src/components/forms/LoginForm.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import useAuthStore from '../../store/auth'; -import API from '../../api'; +import useSettingsStore from '../../store/settings'; import { Paper, Title, @@ -25,13 +25,14 @@ const LoginForm = () => { const logout = useAuthStore((s) => s.logout); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const initData = useAuthStore((s) => s.initData); + const fetchVersion = useSettingsStore((s) => s.fetchVersion); + const storedVersion = useSettingsStore((s) => s.version); const navigate = useNavigate(); // Hook to navigate to other routes const [formData, setFormData] = useState({ username: '', password: '' }); const [rememberMe, setRememberMe] = useState(false); const [savePassword, setSavePassword] = useState(false); const [forgotPasswordOpened, setForgotPasswordOpened] = useState(false); - const [version, setVersion] = useState(null); const [isLoading, setIsLoading] = useState(false); // Simple base64 encoding/decoding for localStorage @@ -55,11 +56,9 @@ const LoginForm = () => { }; useEffect(() => { - // Fetch version info - API.getVersion().then((data) => { - setVersion(data?.version); - }); - }, []); + // Fetch version info using the settings store (will skip if already loaded) + fetchVersion(); + }, [fetchVersion]); useEffect(() => { // Load saved username if it exists @@ -234,8 +233,8 @@ const LoginForm = () => { lineHeight: '1.2', }} > - ⚠ Password will be stored locally without encryption. Only - use on trusted devices. + ⚠ Password will be stored locally without encryption. Only use + on trusted devices. )} @@ -252,7 +251,7 @@ const LoginForm = () => { - {version && ( + {storedVersion.version && ( { right: 30, }} > - v{version} + v{storedVersion.version} )} diff --git a/frontend/src/components/forms/SuperuserForm.jsx b/frontend/src/components/forms/SuperuserForm.jsx index ca8c81fc..b5094993 100644 --- a/frontend/src/components/forms/SuperuserForm.jsx +++ b/frontend/src/components/forms/SuperuserForm.jsx @@ -13,6 +13,7 @@ import { } from '@mantine/core'; import API from '../../api'; import useAuthStore from '../../store/auth'; +import useSettingsStore from '../../store/settings'; import logo from '../../assets/logo.png'; function SuperuserForm() { @@ -22,15 +23,14 @@ function SuperuserForm() { email: '', }); const [error, setError] = useState(''); - const [version, setVersion] = useState(null); const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists); + const fetchVersion = useSettingsStore((s) => s.fetchVersion); + const storedVersion = useSettingsStore((s) => s.version); useEffect(() => { - // Fetch version info - API.getVersion().then((data) => { - setVersion(data?.version); - }); - }, []); + // Fetch version info using the settings store (will skip if already loaded) + fetchVersion(); + }, [fetchVersion]); const handleChange = (e) => { setFormData((prev) => ({ @@ -120,7 +120,7 @@ function SuperuserForm() { - {version && ( + {storedVersion.version && ( - v{version} + v{storedVersion.version} )} diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index f9780139..962fcd7d 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -344,6 +344,7 @@ const ChannelsTable = ({ onReady }) => { const [deleting, setDeleting] = useState(false); const hasFetchedData = useRef(false); + const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates // Drag-and-drop sensors const sensors = useSensors( @@ -423,6 +424,9 @@ const ChannelsTable = ({ onReady }) => { * Functions */ const fetchData = useCallback(async () => { + // Increment fetch version to track this specific fetch request + const currentFetchVersion = ++fetchVersionRef.current; + setIsLoading(true); const params = new URLSearchParams(); @@ -447,7 +451,7 @@ const ChannelsTable = ({ onReady }) => { } // Apply debounced filters - Object.entries(filters).forEach(([key, value]) => { + Object.entries(debouncedFilters).forEach(([key, value]) => { if (value) { if (Array.isArray(value)) { // Convert null values to "null" string for URL parameter @@ -467,6 +471,11 @@ const ChannelsTable = ({ onReady }) => { await API.getAllChannelIds(params), ]); + // Skip state updates if a newer fetch has been initiated + if (currentFetchVersion !== fetchVersionRef.current) { + return; + } + setIsLoading(false); hasFetchedData.current = true; @@ -483,6 +492,10 @@ const ChannelsTable = ({ onReady }) => { onReady(); } } catch (error) { + // Skip state updates if a newer fetch has been initiated + if (currentFetchVersion !== fetchVersionRef.current) { + return; + } setIsLoading(false); // API layer handles "Invalid page" errors by resetting and retrying // Just re-throw to show notification for actual errors @@ -492,11 +505,9 @@ const ChannelsTable = ({ onReady }) => { pagination, sorting, debouncedFilters, - onReady, showDisabled, selectedProfileId, showOnlyStreamlessChannels, - tvgsLoaded, ]); const stopPropagation = useCallback((e) => { @@ -987,8 +998,9 @@ const ChannelsTable = ({ onReady }) => { // the actual sizes through its own state after initialization. // Note: logos is intentionally excluded - LazyLogo components handle their own logo data // from the store, so we don't need to recreate columns when logos load. + // Note: tvgsLoaded is intentionally excluded - EditableEPGCell handles loading state internally // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedProfileId, channelGroups, theme, tvgsById, epgs, tvgsLoaded] + [selectedProfileId, channelGroups, theme, tvgsById, epgs] ); const renderHeaderCell = (header) => { diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 5b9182b2..ec8fad30 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -197,6 +197,7 @@ const StreamsTable = ({ onReady }) => { const [paginationString, setPaginationString] = useState(''); const [isLoading, setIsLoading] = useState(true); + const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates // Channel creation modal state (bulk) const [channelNumberingModalOpen, setChannelNumberingModalOpen] = @@ -412,6 +413,9 @@ const StreamsTable = ({ onReady }) => { const fetchData = useCallback( async ({ showLoader = true } = {}) => { + // Increment fetch version to track this specific fetch request + const currentFetchVersion = ++fetchVersionRef.current; + if (showLoader) { setIsLoading(true); } @@ -450,6 +454,11 @@ const StreamsTable = ({ onReady }) => { API.getStreamFilterOptions(params), ]); + // Skip state updates if a newer fetch has been initiated + if (currentFetchVersion !== fetchVersionRef.current) { + return; + } + setAllRowIds(ids); // Set filtered options based on current filters @@ -481,9 +490,18 @@ const StreamsTable = ({ onReady }) => { onReady(); } } catch (error) { + // Skip logging if a newer fetch has been initiated + if (currentFetchVersion !== fetchVersionRef.current) { + return; + } console.error('Error fetching data:', error); } + // Skip state updates if a newer fetch has been initiated + if (currentFetchVersion !== fetchVersionRef.current) { + return; + } + hasFetchedOnce.current = true; if (showLoader) { setIsLoading(false); diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index 8fe943b7..efc531ab 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -25,6 +25,7 @@ const isTokenExpired = (expirationTime) => { const useAuthStore = create((set, get) => ({ isAuthenticated: false, isInitialized: false, + isInitializing: false, needsSuperuser: false, user: { username: '', @@ -37,17 +38,24 @@ const useAuthStore = create((set, get) => ({ setUser: (user) => set({ user }), initData: async () => { - const user = await API.me(); - if (user.user_level <= USER_LEVELS.STREAMER) { - throw new Error('Unauthorized'); + // Prevent multiple simultaneous initData calls + if (get().isInitializing || get().isInitialized) { + return; } - set({ user, isAuthenticated: true }); - - // Ensure settings are loaded first - await useSettingsStore.getState().fetchSettings(); + set({ isInitializing: true }); try { + const user = await API.me(); + if (user.user_level <= USER_LEVELS.STREAMER) { + throw new Error('Unauthorized'); + } + + set({ user }); + + // Ensure settings are loaded first + await useSettingsStore.getState().fetchSettings(); + // Only after settings are loaded, fetch the essential data await Promise.all([ useChannelsStore.getState().fetchChannels(), @@ -64,10 +72,20 @@ const useAuthStore = create((set, get) => ({ await Promise.all([useUsersStore.getState().fetchUsers()]); } + // Only set isAuthenticated and isInitialized AFTER all data is loaded + // This prevents routes from rendering before data is ready + set({ + isAuthenticated: true, + isInitialized: true, + isInitializing: false, + }); + // Note: Logos are loaded after the Channels page tables finish loading // This is handled by the tables themselves signaling completion } catch (error) { console.error('Error initializing data:', error); + set({ isInitializing: false }); + throw error; } }, @@ -118,7 +136,7 @@ const useAuthStore = create((set, get) => ({ // Action to refresh the token getRefreshToken: async () => { const refreshToken = localStorage.getItem('refreshToken'); - if (!refreshToken) return false; // Add explicit return here + if (!refreshToken) return false; try { const data = await api.refreshToken(refreshToken); @@ -126,18 +144,17 @@ const useAuthStore = create((set, get) => ({ set({ accessToken: data.access, tokenExpiration: decodeToken(data.access), - isAuthenticated: true, }); localStorage.setItem('accessToken', data.access); localStorage.setItem('tokenExpiration', decodeToken(data.access)); return data.access; } - return false; // Add explicit return for when data.access is not available + return false; } catch (error) { console.error('Token refresh failed:', error); await get().logout(); - return false; // Add explicit return after error + return false; } }, @@ -156,6 +173,8 @@ const useAuthStore = create((set, get) => ({ refreshToken: null, tokenExpiration: null, isAuthenticated: false, + isInitialized: false, + isInitializing: false, user: null, }); localStorage.removeItem('accessToken'); diff --git a/frontend/src/store/settings.jsx b/frontend/src/store/settings.jsx index 99390320..d7d46a3f 100644 --- a/frontend/src/store/settings.jsx +++ b/frontend/src/store/settings.jsx @@ -1,7 +1,7 @@ import { create } from 'zustand'; import api from '../api'; -const useSettingsStore = create((set) => ({ +const useSettingsStore = create((set, get) => ({ settings: {}, environment: { // Add default values for environment settings @@ -10,15 +10,27 @@ const useSettingsStore = create((set) => ({ country_name: '', env_mode: 'prod', }, + version: { + version: '', + timestamp: null, + }, isLoading: false, error: null, fetchSettings: async () => { set({ isLoading: true, error: null }); try { - const settings = await api.getSettings(); - const env = await api.getEnvironmentSettings(); - set({ + // Only fetch version if not already loaded (may have been fetched by Login/Superuser form) + const currentVersion = get().version; + const needsVersion = !currentVersion.version; + + const [settings, env, versionData] = await Promise.all([ + api.getSettings(), + api.getEnvironmentSettings(), + needsVersion ? api.getVersion() : Promise.resolve(null), + ]); + + const newState = { settings: settings.reduce((acc, setting) => { acc[setting.key] = setting; return acc; @@ -30,12 +42,42 @@ const useSettingsStore = create((set) => ({ country_name: '', env_mode: 'prod', }, - }); + }; + + // Only update version if we fetched it + if (versionData) { + newState.version = { + version: versionData?.version || '', + timestamp: versionData?.timestamp || null, + }; + } + + set(newState); } catch (error) { set({ error: 'Failed to load settings.', isLoading: false }); } }, + // Fetch version independently (for unauthenticated pages like Login) + fetchVersion: async () => { + // Skip if already loaded + if (get().version.version) { + return get().version; + } + try { + const versionData = await api.getVersion(); + const version = { + version: versionData?.version || '', + timestamp: versionData?.timestamp || null, + }; + set({ version }); + return version; + } catch (error) { + console.error('Failed to fetch version:', error); + return get().version; + } + }, + updateSetting: (setting) => set((state) => ({ settings: { ...state.settings, [setting.key]: setting }, From 9440bbe2ab7e8d4a5b7c3859c7db85542900c58b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 25 Jan 2026 20:56:07 -0600 Subject: [PATCH 02/15] Performance: Removed unnecessary double wait that was causing fetching of channel data to not run in parallel. --- frontend/src/components/tables/ChannelsTable.jsx | 4 ++-- frontend/src/utils.js | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index f9780139..45d46f17 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -463,8 +463,8 @@ const ChannelsTable = ({ onReady }) => { try { const [results, ids] = await Promise.all([ - await API.queryChannels(params), - await API.getAllChannelIds(params), + API.queryChannels(params), + API.getAllChannelIds(params), ]); setIsLoading(false); diff --git a/frontend/src/utils.js b/frontend/src/utils.js index 81836f0a..bb1b6060 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; export default { Limiter: (n, list) => { @@ -40,13 +40,25 @@ export default { // Custom debounce hook export function useDebounce(value, delay = 500, callback = null) { const [debouncedValue, setDebouncedValue] = useState(value); + const isFirstRender = useRef(true); + const previousValueRef = useRef(JSON.stringify(value)); useEffect(() => { + const currentValueStr = JSON.stringify(value); + + // Skip if value hasn't actually changed (prevents unnecessary state updates) + if (previousValueRef.current === currentValueStr) { + return; + } + const handler = setTimeout(() => { setDebouncedValue(value); - if (callback) { + // Only fire callback if not the first render + if (callback && !isFirstRender.current) { callback(); } + isFirstRender.current = false; + previousValueRef.current = currentValueStr; }, delay); return () => clearTimeout(handler); // Cleanup timeout on unmount or value change From 716dc25c52e6a3c977808011b08e697a925dd8a8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 25 Jan 2026 21:14:44 -0600 Subject: [PATCH 03/15] Enhancement: Prevent duplicate fetch requests in ChannelsTable and StreamsTable by tracking last fetch parameters and in-progress status --- .../src/components/tables/ChannelsTable.jsx | 29 ++++++++++++++--- .../src/components/tables/StreamsTable.jsx | 32 +++++++++++++++---- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 86711124..3d27ca61 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -345,6 +345,8 @@ const ChannelsTable = ({ onReady }) => { const hasFetchedData = useRef(false); const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates + const lastFetchParamsRef = useRef(null); // Track last fetch params to prevent duplicate requests + const fetchInProgressRef = useRef(false); // Track if a fetch is currently in progress // Drag-and-drop sensors const sensors = useSensors( @@ -424,11 +426,7 @@ const ChannelsTable = ({ onReady }) => { * Functions */ const fetchData = useCallback(async () => { - // Increment fetch version to track this specific fetch request - const currentFetchVersion = ++fetchVersionRef.current; - - setIsLoading(true); - + // Build params first to check for duplicates const params = new URLSearchParams(); params.append('page', pagination.pageIndex + 1); params.append('page_size', pagination.pageSize); @@ -465,12 +463,31 @@ const ChannelsTable = ({ onReady }) => { } }); + const paramsString = params.toString(); + + // Skip if same fetch is already in progress (prevents StrictMode double-fetch) + if ( + fetchInProgressRef.current && + lastFetchParamsRef.current === paramsString + ) { + return; + } + + // Increment fetch version to track this specific fetch request + const currentFetchVersion = ++fetchVersionRef.current; + lastFetchParamsRef.current = paramsString; + fetchInProgressRef.current = true; + + setIsLoading(true); + try { const [results, ids] = await Promise.all([ API.queryChannels(params), API.getAllChannelIds(params), ]); + fetchInProgressRef.current = false; + // Skip state updates if a newer fetch has been initiated if (currentFetchVersion !== fetchVersionRef.current) { return; @@ -492,6 +509,8 @@ const ChannelsTable = ({ onReady }) => { onReady(); } } catch (error) { + fetchInProgressRef.current = false; + // Skip state updates if a newer fetch has been initiated if (currentFetchVersion !== fetchVersionRef.current) { return; diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index ec8fad30..00741d46 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -198,6 +198,8 @@ const StreamsTable = ({ onReady }) => { const [paginationString, setPaginationString] = useState(''); const [isLoading, setIsLoading] = useState(true); const fetchVersionRef = useRef(0); // Track fetch version to prevent stale updates + const lastFetchParamsRef = useRef(null); // Track last fetch params to prevent duplicate requests + const fetchInProgressRef = useRef(false); // Track if a fetch is currently in progress // Channel creation modal state (bulk) const [channelNumberingModalOpen, setChannelNumberingModalOpen] = @@ -413,13 +415,6 @@ const StreamsTable = ({ onReady }) => { const fetchData = useCallback( async ({ showLoader = true } = {}) => { - // Increment fetch version to track this specific fetch request - const currentFetchVersion = ++fetchVersionRef.current; - - if (showLoader) { - setIsLoading(true); - } - const params = new URLSearchParams(); params.append('page', pagination.pageIndex + 1); params.append('page_size', pagination.pageSize); @@ -447,6 +442,25 @@ const StreamsTable = ({ onReady }) => { } }); + const paramsString = params.toString(); + + // Skip if same fetch is already in progress (prevents StrictMode double-fetch) + if ( + fetchInProgressRef.current && + lastFetchParamsRef.current === paramsString + ) { + return; + } + + // Increment fetch version to track this specific fetch request + const currentFetchVersion = ++fetchVersionRef.current; + lastFetchParamsRef.current = paramsString; + fetchInProgressRef.current = true; + + if (showLoader) { + setIsLoading(true); + } + try { const [result, ids, filterOptions] = await Promise.all([ API.queryStreamsTable(params), @@ -454,6 +468,8 @@ const StreamsTable = ({ onReady }) => { API.getStreamFilterOptions(params), ]); + fetchInProgressRef.current = false; + // Skip state updates if a newer fetch has been initiated if (currentFetchVersion !== fetchVersionRef.current) { return; @@ -490,6 +506,8 @@ const StreamsTable = ({ onReady }) => { onReady(); } } catch (error) { + fetchInProgressRef.current = false; + // Skip logging if a newer fetch has been initiated if (currentFetchVersion !== fetchVersionRef.current) { return; From e2a915f10ba34a66c6ddb36bdffa71a41c33733b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 26 Jan 2026 16:19:07 -0600 Subject: [PATCH 04/15] Perf: lazy load editable cells on focus, not on unlock. Also don't wait for all channels to load before logging in. --- .../src/components/tables/ChannelsTable.jsx | 20 +- .../tables/ChannelsTable/EditableCell.jsx | 561 ++++++++++-------- frontend/src/store/auth.jsx | 10 +- 3 files changed, 320 insertions(+), 271 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 3d27ca61..f41ca72d 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -253,7 +253,7 @@ const ChannelsTable = ({ onReady }) => { const tvgsLoaded = useEPGsStore((s) => s.tvgsLoaded); // Get channel logos for logo selection - const { logos: channelLogos, ensureLogosLoaded } = useChannelLogoSelection(); + const { ensureLogosLoaded } = useChannelLogoSelection(); const theme = useMantineTheme(); const channelGroups = useChannelsStore((s) => s.channelGroups); @@ -977,19 +977,11 @@ const ChannelsTable = ({ onReady }) => { enableResizing: false, header: '', cell: (props) => ( - { - // Ensure logos are loaded when user tries to edit - ensureLogosLoaded(); - }} - style={{ width: '100%', height: '100%' }} - > - - + ), }, { diff --git a/frontend/src/components/tables/ChannelsTable/EditableCell.jsx b/frontend/src/components/tables/ChannelsTable/EditableCell.jsx index 065e4631..42d99e73 100644 --- a/frontend/src/components/tables/ChannelsTable/EditableCell.jsx +++ b/frontend/src/components/tables/ChannelsTable/EditableCell.jsx @@ -4,6 +4,7 @@ import React, { useEffect, useRef, useMemo, + memo, } from 'react'; import { Box, @@ -16,33 +17,88 @@ import { } from '@mantine/core'; import API from '../../../api'; import useChannelsTableStore from '../../../store/channelsTable'; +import useLogosStore from '../../../store/logos'; + +// Lightweight wrapper that only renders full editable cell when unlocked +// This prevents 250+ heavy component instances when table is locked +const EditableCellWrapper = memo( + ({ children, getValue, isUnlocked, renderLocked }) => { + if (!isUnlocked) { + // Render lightweight locked view + return renderLocked ? ( + renderLocked(getValue()) + ) : ( + + {getValue() ?? ''} + + ); + } + // Only render heavy component when unlocked + return children; + } +); // Editable text cell export const EditableTextCell = ({ row, column, getValue }) => { const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const [isFocused, setIsFocused] = useState(false); + + // When locked or not focused, show simple display + if (!isUnlocked || !isFocused) { + return ( + isUnlocked && setIsFocused(true)} + style={{ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + cursor: isUnlocked ? 'text' : 'default', + padding: '0 4px', + }} + > + {getValue() ?? ''} + + ); + } + + // Only mount heavy component when actually editing + return ( + setIsFocused(false)} + /> + ); +}; + +// Inner component with all the editing logic - only rendered when focused +const EditableTextCellInner = ({ row, column, getValue, onBlur }) => { const initialValue = getValue() || ''; const [value, setValue] = useState(initialValue); - const [isFocused, setIsFocused] = useState(false); const previousValue = useRef(initialValue); const isMounted = useRef(false); const debounceTimer = useRef(null); useEffect(() => { const currentValue = getValue() || ''; - if (!isFocused && currentValue !== previousValue.current) { + if (currentValue !== previousValue.current) { setValue(currentValue); previousValue.current = currentValue; } - }, [getValue, isFocused]); + }, [getValue]); const saveValue = useCallback( async (newValue) => { - // Don't save if not mounted, not unlocked, or value hasn't changed - if ( - !isMounted.current || - !isUnlocked || - newValue === previousValue.current - ) { + // Don't save if not mounted or value hasn't changed + if (!isMounted.current || newValue === previousValue.current) { return; } @@ -62,7 +118,7 @@ export const EditableTextCell = ({ row, column, getValue }) => { setValue(previousValue.current || ''); } }, - [row.original.id, column.id, isUnlocked] + [row.original.id, column.id] ); useEffect(() => { @@ -77,7 +133,6 @@ export const EditableTextCell = ({ row, column, getValue }) => { }, []); const handleChange = (e) => { - if (!isUnlocked) return; const newValue = e.currentTarget.value; setValue(newValue); @@ -93,40 +148,10 @@ export const EditableTextCell = ({ row, column, getValue }) => { }; const handleBlur = () => { - setIsFocused(false); - if (isUnlocked) { - saveValue(value); - } + saveValue(value); + onBlur(); }; - const handleClick = () => { - if (isUnlocked) { - setIsFocused(true); - } - }; - - if (!isUnlocked || !isFocused) { - return ( - - {value} - - ); - } - return ( { // Editable number cell export const EditableNumberCell = ({ row, column, getValue }) => { const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const [isFocused, setIsFocused] = useState(false); + + const value = getValue(); + const formattedValue = + value !== null && value !== undefined + ? value === Math.floor(value) + ? Math.floor(value) + : value + : ''; + + // When locked or not focused, show simple display + if (!isUnlocked || !isFocused) { + return ( + isUnlocked && setIsFocused(true)} + style={{ + textAlign: 'right', + width: '100%', + cursor: isUnlocked ? 'text' : 'default', + padding: '0 4px', + }} + > + {formattedValue} + + ); + } + + return ( + setIsFocused(false)} + /> + ); +}; + +// Inner component with all the editing logic - only rendered when focused +const EditableNumberCellInner = ({ row, column, getValue, onBlur }) => { const initialValue = getValue(); const [value, setValue] = useState(initialValue); - const [isFocused, setIsFocused] = useState(false); const previousValue = useRef(initialValue); const isMounted = useRef(false); useEffect(() => { const currentValue = getValue(); - if (!isFocused && currentValue !== previousValue.current) { + if (currentValue !== previousValue.current) { setValue(currentValue); previousValue.current = currentValue; } - }, [getValue, isFocused]); + }, [getValue]); const saveValue = useCallback( async (newValue) => { - // Don't save if not mounted, not unlocked, or value hasn't changed - if ( - !isMounted.current || - !isUnlocked || - newValue === previousValue.current - ) { + // Don't save if not mounted or value hasn't changed + if (!isMounted.current || newValue === previousValue.current) { return; } @@ -203,8 +262,7 @@ export const EditableNumberCell = ({ row, column, getValue }) => { // If channel_number was changed, refetch to reorder the table if (column.id === 'channel_number') { await API.requeryChannels(); - // Exit edit mode after resorting to avoid confusion - setIsFocused(false); + onBlur(); } } } catch (error) { @@ -212,7 +270,7 @@ export const EditableNumberCell = ({ row, column, getValue }) => { setValue(previousValue.current); } }, - [row.original.id, column.id, isUnlocked] + [row.original.id, column.id, onBlur] ); useEffect(() => { @@ -223,51 +281,14 @@ export const EditableNumberCell = ({ row, column, getValue }) => { }, []); const handleChange = (newValue) => { - if (!isUnlocked) return; setValue(newValue); }; const handleBlur = () => { - setIsFocused(false); - if (isUnlocked) { - saveValue(value); - } + saveValue(value); + onBlur(); }; - const handleClick = () => { - if (isUnlocked) { - setIsFocused(true); - } - }; - - const formattedValue = - value !== null && value !== undefined - ? value === Math.floor(value) - ? Math.floor(value) - : value - : ''; - - if (!isUnlocked || !isFocused) { - return ( - - {formattedValue} - - ); - } - return ( { }; // Editable select cell for groups -export const EditableGroupCell = ({ row, getValue, channelGroups }) => { +export const EditableGroupCell = ({ row, channelGroups }) => { const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const [isFocused, setIsFocused] = useState(false); const groupId = row.original.channel_group_id; const groupName = channelGroups[groupId]?.name || ''; + + // Show simple display when locked OR when unlocked but not focused + if (!isUnlocked || !isFocused) { + return ( + isUnlocked && setIsFocused(true)} + style={{ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + padding: '0 4px', + cursor: isUnlocked ? 'pointer' : 'default', + }} + > + {groupName} + + ); + } + + return ( + setIsFocused(false)} + /> + ); +}; + +// Inner component with all the editing logic - only rendered when focused +const EditableGroupCellInner = ({ + row, + channelGroups, + groupName, + groupId, + onBlur, +}) => { const previousGroupId = useRef(groupId); - const [isFocused, setIsFocused] = useState(false); const [searchValue, setSearchValue] = useState(''); const saveValue = useCallback( async (newGroupId) => { - // Don't save if not unlocked or value hasn't changed - if ( - !isUnlocked || - String(newGroupId) === String(previousGroupId.current) - ) { + // Don't save if value hasn't changed + if (String(newGroupId) === String(previousGroupId.current)) { return; } @@ -324,18 +380,12 @@ export const EditableGroupCell = ({ row, getValue, channelGroups }) => { console.error('Failed to update channel group:', error); } }, - [row.original.id, isUnlocked] + [row.original.id] ); - const handleClick = () => { - if (isUnlocked) { - setIsFocused(true); - } - }; - const handleChange = (newGroupId) => { saveValue(newGroupId); - setIsFocused(false); + onBlur(); setSearchValue(''); }; @@ -344,33 +394,11 @@ export const EditableGroupCell = ({ row, getValue, channelGroups }) => { label: group.name, })); - if (!isUnlocked || !isFocused) { - return ( - - {groupName} - - ); - } - return ( setIsFocused(false)} + onBlur={onBlur} data={epgOptions} size="xs" variant="unstyled" @@ -599,17 +637,69 @@ export const EditableEPGCell = ({ }; // Editable cell for Logo selection -export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => { +export const EditableLogoCell = ({ + row, + getValue, + LazyLogo, + ensureLogosLoaded, +}) => { const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); - const logoId = getValue(); - const previousLogoId = useRef(logoId); const [isFocused, setIsFocused] = useState(false); + const logoId = getValue(); + + const handleClick = () => { + if (isUnlocked) { + // Ensure logos are loaded when user tries to edit + ensureLogosLoaded?.(); + setIsFocused(true); + } + }; + + // Show simple display when locked OR when unlocked but not focused + if (!isUnlocked || !isFocused) { + return ( + + {LazyLogo && ( + + )} + + ); + } + + return ( + setIsFocused(false)} + /> + ); +}; + +// Inner component with all the editing logic - only rendered when focused +const EditableLogoCellInner = ({ row, logoId, onBlur }) => { + // Subscribe directly to the logos store so we get updates when logos load + const channelLogos = useLogosStore((s) => s.channelLogos); + const previousLogoId = useRef(logoId); const [searchValue, setSearchValue] = useState(''); const saveValue = useCallback( async (newLogoId) => { - // Don't save if not unlocked or value hasn't changed - if (!isUnlocked || String(newLogoId) === String(previousLogoId.current)) { + // Don't save if value hasn't changed + if (String(newLogoId) === String(previousLogoId.current)) { return; } @@ -628,20 +718,13 @@ export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => { console.error('Failed to update logo:', error); } }, - [row.original.id, isUnlocked] + [row.original.id] ); - const handleClick = () => { - if (isUnlocked) { - setSearchValue(''); - setIsFocused(true); - } - }; - const handleChange = (newLogoId) => { saveValue(newLogoId); setSearchValue(''); - setIsFocused(false); + onBlur(); }; // Build logo options with logo data @@ -706,36 +789,6 @@ export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => { ); }; - if (!isUnlocked || !isFocused) { - // When not editing, show the logo image - return ( - - {LazyLogo && ( - - )} - - ); - } - return ( {