fix(client): reduce unnecessary renders and fetches on hard refresh
Some checks failed
CI / test-python (push) Successful in 4m54s
CI / test-typescript (push) Failing after 57s
CI / test-rust (push) Successful in 1m56s

- Contexts wait for connection before loading to avoid stale cached data
- MeetingsPage skips fetch when URL project ID is invalid
- Workspace isLoading starts as true for proper loading state
- Remove debug logging added during investigation
- Add PROJECTS_FETCH_LIMIT constant to timing.ts
This commit is contained in:
2026-01-26 14:59:50 +00:00
parent 01a8d02d60
commit 0d98a36317
4 changed files with 37 additions and 11 deletions

View File

@@ -5,9 +5,11 @@ import { IdentityDefaults } from '@/api';
import { extractErrorMessage } from '@/api'; import { extractErrorMessage } from '@/api';
import { getAPI } from '@/api/interface'; import { getAPI } from '@/api/interface';
import type { CreateProjectRequest, Project, UpdateProjectRequest } from '@/api/types'; import type { CreateProjectRequest, Project, UpdateProjectRequest } from '@/api/types';
import { useConnectionState } from '@/contexts/connection-state';
import { ProjectContext, type ProjectContextValue } from '@/contexts/project-state'; import { ProjectContext, type ProjectContextValue } from '@/contexts/project-state';
import { projectStorageKey } from '@/contexts/storage'; import { projectStorageKey } from '@/contexts/storage';
import { useWorkspace } from '@/contexts/workspace-state'; import { useWorkspace } from '@/contexts/workspace-state';
import { PROJECTS_FETCH_LIMIT } from '@/lib/constants/timing';
import { errorLog } from '@/lib/observability/debug'; import { errorLog } from '@/lib/observability/debug';
import { readStorageRaw, writeStorageRaw } from '@/lib/storage/utils'; import { readStorageRaw, writeStorageRaw } from '@/lib/storage/utils';
@@ -54,6 +56,7 @@ function fallbackProject(workspaceId: string): Project {
export function ProjectProvider({ children }: { children: React.ReactNode }) { export function ProjectProvider({ children }: { children: React.ReactNode }) {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { isConnected } = useConnectionState();
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [activeProjectId, setActiveProjectId] = useState<string | null>(null); const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -75,7 +78,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }) {
const response = await getAPI().listProjects({ const response = await getAPI().listProjects({
workspace_id: workspace.id, workspace_id: workspace.id,
include_archived: true, include_archived: true,
limit: 200, limit: PROJECTS_FETCH_LIMIT,
offset: 0, offset: 0,
}); });
let preferredId = readStoredProjectId(workspace.id); let preferredId = readStoredProjectId(workspace.id);
@@ -110,14 +113,15 @@ export function ProjectProvider({ children }: { children: React.ReactNode }) {
} }
}, []); }, []);
// Reload projects when workspace ID changes (not on every workspace object reference change)
const workspaceId = currentWorkspace?.id; const workspaceId = currentWorkspace?.id;
const hasLoaded = useRef(false);
useEffect(() => { useEffect(() => {
if (!workspaceId) { if (!workspaceId || !isConnected || hasLoaded.current) {
return; return;
} }
hasLoaded.current = true;
void loadProjects(); void loadProjects();
}, [loadProjects, workspaceId]); }, [loadProjects, workspaceId, isConnected]);
const switchProject = useCallback( const switchProject = useCallback(
(projectId: string) => { (projectId: string) => {

View File

@@ -5,13 +5,16 @@ import { IdentityDefaults } from '@/api';
import { getAPI } from '@/api/interface'; import { getAPI } from '@/api/interface';
import { extractErrorMessage } from '@/api'; import { extractErrorMessage } from '@/api';
import type { GetCurrentUserResponse, Workspace } from '@/api/types'; import type { GetCurrentUserResponse, Workspace } from '@/api/types';
import { useConnectionState } from '@/contexts/connection-state';
import { WorkspaceContext, type WorkspaceContextValue } from '@/contexts/workspace-state'; import { WorkspaceContext, type WorkspaceContextValue } from '@/contexts/workspace-state';
import { workspaceStorageKey } from '@/contexts/storage'; import { workspaceStorageKey } from '@/contexts/storage';
import { readStorageRaw, writeStorageRaw } from '@/lib/storage/utils'; import { readStorageRaw, writeStorageRaw } from '@/lib/storage/utils';
const fallbackUser: GetCurrentUserResponse = { const fallbackUser: GetCurrentUserResponse = {
user_id: IdentityDefaults.DEFAULT_USER_ID, user_id: IdentityDefaults.DEFAULT_USER_ID,
workspace_id: IdentityDefaults.DEFAULT_WORKSPACE_ID,
display_name: IdentityDefaults.DEFAULT_USER_NAME, display_name: IdentityDefaults.DEFAULT_USER_NAME,
is_authenticated: false,
}; };
const fallbackWorkspace: Workspace = { const fallbackWorkspace: Workspace = {
id: IdentityDefaults.DEFAULT_WORKSPACE_ID, id: IdentityDefaults.DEFAULT_WORKSPACE_ID,
@@ -44,10 +47,11 @@ function resolveWorkspace(workspaces: Workspace[], preferredId: string | null):
} }
export function WorkspaceProvider({ children }: { children: React.ReactNode }) { export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
const { isConnected } = useConnectionState();
const [currentWorkspace, setCurrentWorkspace] = useState<Workspace | null>(null); const [currentWorkspace, setCurrentWorkspace] = useState<Workspace | null>(null);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]); const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [currentUser, setCurrentUser] = useState<GetCurrentUserResponse | null>(null); const [currentUser, setCurrentUser] = useState<GetCurrentUserResponse | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadContext = useCallback(async () => { const loadContext = useCallback(async () => {
@@ -85,9 +89,17 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
} }
}, []); }, []);
const hasLoaded = useRef(false);
useEffect(() => { useEffect(() => {
if (hasLoaded.current) {
return;
}
if (!isConnected) {
return;
}
hasLoaded.current = true;
void loadContext(); void loadContext();
}, [loadContext]); }, [loadContext, isConnected]);
// Use ref for workspaces to avoid stale closure in switchWorkspace // Use ref for workspaces to avoid stale closure in switchWorkspace
const workspacesRef = useRef(workspaces); const workspacesRef = useRef(workspaces);

View File

@@ -123,6 +123,9 @@ export const HOME_TASKS_LIMIT = 5;
/** Default number of meetings to show on meetings list page */ /** Default number of meetings to show on meetings list page */
export const MEETINGS_PAGE_LIMIT = 50; export const MEETINGS_PAGE_LIMIT = 50;
/** Maximum number of projects to fetch in project context */
export const PROJECTS_FETCH_LIMIT = 200;
/** Default number of meetings to fetch for tasks page */ /** Default number of meetings to fetch for tasks page */
export const TASKS_PAGE_MEETINGS_LIMIT = 100; export const TASKS_PAGE_MEETINGS_LIMIT = 100;

View File

@@ -2,7 +2,7 @@
import { Calendar, Loader2, CheckSquare } from 'lucide-react'; import { Calendar, Loader2, CheckSquare } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom'; import { Navigate, useParams } from 'react-router-dom';
import { getAPI } from '@/api/interface'; import { getAPI } from '@/api/interface';
import type { Meeting, MeetingState } from '@/api/types'; import type { Meeting, MeetingState } from '@/api/types';
import type { ProjectScope } from '@/api/types/requests'; import type { ProjectScope } from '@/api/types/requests';
@@ -38,7 +38,7 @@ export default function MeetingsPage() {
}); });
const { guard } = useGuardedMutation(); const { guard } = useGuardedMutation();
const resolvedProjectId = projectId ?? activeProject?.id; const resolvedProjectId = projectId ?? activeProject?.id;
const selectedIdsRef = useRef(selectedProjectIds); const selectedIdsRef = useRef(selectedProjectIds);
selectedIdsRef.current = selectedProjectIds; selectedIdsRef.current = selectedProjectIds;
const activeProjects = useMemo( const activeProjects = useMemo(
@@ -51,7 +51,6 @@ export default function MeetingsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
// Bulk selection state
const [isSelectionMode, setIsSelectionMode] = useState(false); const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedMeetingIds, setSelectedMeetingIds] = useState<Set<string>>(new Set()); const [selectedMeetingIds, setSelectedMeetingIds] = useState<Set<string>>(new Set());
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false); const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
@@ -66,9 +65,11 @@ export default function MeetingsPage() {
onSuccess: handleDeleteSuccess, onSuccess: handleDeleteSuccess,
}); });
const urlProjectValid = !projectId || projects.some((p) => p.id === projectId);
const shouldSkipFetch = const shouldSkipFetch =
(projectScope === 'selected' && selectedProjectIds.length === 0) || (projectScope === 'selected' && selectedProjectIds.length === 0) ||
(projectScope === 'active' && !resolvedProjectId && !projectsLoading); (projectScope === 'active' && !resolvedProjectId && !projectsLoading) ||
(projectScope === 'active' && projectId && !projectsLoading && !urlProjectValid);
const fetchMeetings = useCallback( const fetchMeetings = useCallback(
async (offset: number, append: boolean) => { async (offset: number, append: boolean) => {
@@ -86,11 +87,12 @@ export default function MeetingsPage() {
} }
try { try {
const requestProjectId = projectScope === 'active' ? resolvedProjectId : undefined;
const response = await getAPI().listMeetings({ const response = await getAPI().listMeetings({
limit: MEETINGS_PAGE_LIMIT, limit: MEETINGS_PAGE_LIMIT,
offset, offset,
states: stateFilter === 'all' ? undefined : [stateFilter as MeetingState], states: stateFilter === 'all' ? undefined : [stateFilter as MeetingState],
project_id: projectScope === 'active' ? resolvedProjectId : undefined, project_id: requestProjectId,
project_ids: projectScope === 'selected' ? selectedIdsRef.current : undefined, project_ids: projectScope === 'selected' ? selectedIdsRef.current : undefined,
include_segments: true, include_segments: true,
}); });
@@ -198,6 +200,11 @@ export default function MeetingsPage() {
} }
}; };
const projectExistsInList = projectId && projects.some((p) => p.id === projectId);
if (!projectsLoading && projectId && !projectExistsInList && activeProject) {
return <Navigate to={`/projects/${activeProject.id}/meetings`} replace />;
}
return ( return (
<div className="p-6 max-w-6xl mx-auto space-y-6"> <div className="p-6 max-w-6xl mx-auto space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">