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
This commit is contained in:
SergeantPanda 2026-01-25 19:46:34 -06:00
parent 655760f652
commit e22e003be9
9 changed files with 167 additions and 87 deletions

View file

@ -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.

View file

@ -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 && (
<Sidebar
drawerWidth={drawerWidth}
miniDrawerWidth={miniDrawerWidth}
@ -134,7 +144,7 @@ const App = () => {
>
<Box sx={{ p: 2, flex: 1, overflow: 'auto' }}>
<Routes>
{isAuthenticated ? (
{isAuthenticated && isInitialized ? (
<>
<Route path="/channels" element={<Channels />} />
<Route path="/sources" element={<ContentSources />} />
@ -154,7 +164,11 @@ const App = () => {
path="*"
element={
<Navigate
to={isAuthenticated ? defaultRoute : '/login'}
to={
isAuthenticated && isInitialized
? defaultRoute
: '/login'
}
replace
/>
}

View file

@ -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);

View file

@ -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.
</Text>
)}
</div>
@ -252,7 +251,7 @@ const LoginForm = () => {
</Stack>
</form>
{version && (
{storedVersion.version && (
<Text
size="xs"
color="dimmed"
@ -262,7 +261,7 @@ const LoginForm = () => {
right: 30,
}}
>
v{version}
v{storedVersion.version}
</Text>
)}
</Paper>

View file

@ -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() {
</Stack>
</form>
{version && (
{storedVersion.version && (
<Text
size="xs"
color="dimmed"
@ -130,7 +130,7 @@ function SuperuserForm() {
right: 30,
}}
>
v{version}
v{storedVersion.version}
</Text>
)}
</Paper>

View file

@ -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) => {

View file

@ -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);

View file

@ -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');

View file

@ -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 },