From b6c21de0bbdb5a7e94430c20933526a96a595618 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:30:08 -0400 Subject: [PATCH 1/5] feat(frontend): persist the query cache and restore the signed-in session offline Allowlisted query data is persisted to localStorage for 24h, scoped to the origin and user and cleared on sign-out. The last validated user is remembered so a cold start opens with cached lists when the session check fails on the network, while a 401 or signed-out response still signs out. Lists keep cached data when a refetch fails. --- frontend/package.json | 4 +- frontend/src/App.tsx | 18 +- .../navigation/RemoteProjectNav.test.tsx | 71 +++-- .../navigation/RemoteProjectNav.tsx | 9 +- .../src/components/repo/RepoList.test.tsx | 30 ++- frontend/src/components/repo/RepoList.tsx | 6 +- .../components/servers/RemoteProjectList.tsx | 18 +- .../__tests__/RemoteProjectList.test.tsx | 26 +- .../components/session/SessionList.test.tsx | 20 +- .../src/components/session/SessionList.tsx | 7 +- frontend/src/contexts/AuthContext.test.tsx | 141 ++++++++-- frontend/src/contexts/AuthContext.tsx | 47 +++- .../QueryCachePersistenceContext.test.tsx | 228 ++++++++++++++++ .../contexts/QueryCachePersistenceContext.tsx | 93 +++++++ frontend/src/hooks/useOpenCode.ts | 1 + frontend/src/hooks/useOpenCodeServers.ts | 5 + frontend/src/hooks/useRemoteProjects.test.tsx | 31 ++- frontend/src/hooks/useRemoteProjects.ts | 20 +- frontend/src/hooks/useSessionSearch.ts | 2 + frontend/src/lib/auth-loaders.test.ts | 62 ++++- frontend/src/lib/auth-loaders.ts | 35 ++- frontend/src/lib/authSession.ts | 61 +++++ frontend/src/lib/persistentStorage.ts | 8 + frontend/src/lib/queryPersistence.test.ts | 250 ++++++++++++++++++ frontend/src/lib/queryPersistence.ts | 111 ++++++++ frontend/src/lib/queryState.test.ts | 43 +++ frontend/src/lib/queryState.ts | 27 ++ pnpm-lock.yaml | 51 +++- 28 files changed, 1324 insertions(+), 101 deletions(-) create mode 100644 frontend/src/contexts/QueryCachePersistenceContext.test.tsx create mode 100644 frontend/src/contexts/QueryCachePersistenceContext.tsx create mode 100644 frontend/src/lib/authSession.ts create mode 100644 frontend/src/lib/queryPersistence.test.ts create mode 100644 frontend/src/lib/queryPersistence.ts create mode 100644 frontend/src/lib/queryState.test.ts create mode 100644 frontend/src/lib/queryState.ts diff --git a/frontend/package.json b/frontend/package.json index b7b4dfd48..33d670686 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,7 +28,9 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@tailwindcss/vite": "^4.1.14", - "@tanstack/react-query": "^5.90.5", + "@tanstack/query-sync-storage-persister": "5.101.2", + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query-persist-client": "5.101.2", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6453076d7..ddb4e713c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,7 @@ import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { TTSProvider } from './contexts/TTSContext' import { ThemeProvider, useThemeMode } from './contexts/ThemeContext' import { AuthProvider } from './contexts/AuthContext' +import { QueryCachePersistenceProvider } from './contexts/QueryCachePersistenceContext' import { KeyboardShortcutsProvider } from './contexts/KeyboardShortcutsContext' import { EventProvider, usePermissions, useEventContext } from '@/contexts/EventContext' import { OpenCodeServerProvider, isLocalOpenCodeServer } from '@/contexts/OpenCodeServerContext' @@ -38,6 +39,7 @@ import { SSHHostKeyDialog } from './components/ssh/SSHHostKeyDialog' import { loginLoader, setupLoader, registerLoader, protectedLoader } from './lib/auth-loaders' import { getSwipeBackTarget, isSessionDetailRoute } from '@/lib/navigation' import { onNotificationClick } from '@/lib/serviceWorker' +import { applyPersistedQueryDefaults } from '@/lib/queryPersistence' import { useAuth } from '@/hooks/useAuth' import { useOpenCodeFailureToast } from '@/hooks/useOpenCodeFailureToast' import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions' @@ -55,6 +57,8 @@ const queryClient = new QueryClient({ }, }) +applyPersistedQueryDefaults(queryClient) + function SSHHostKeyDialogWrapper() { const { sshHostKey } = useEventContext() return ( @@ -168,12 +172,14 @@ function ServerScope({ children }: { children: ReactNode }) { function RootLayout() { return ( - - - - - - + + + + + + + + ) } diff --git a/frontend/src/components/navigation/RemoteProjectNav.test.tsx b/frontend/src/components/navigation/RemoteProjectNav.test.tsx index 1e12c8d68..0567be327 100644 --- a/frontend/src/components/navigation/RemoteProjectNav.test.tsx +++ b/frontend/src/components/navigation/RemoteProjectNav.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { IsRestoringProvider, QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { OpenCodeProject } from '@/api/opencode' import { RemoteProjectNavList } from './RemoteProjectNav' @@ -132,22 +132,25 @@ function createWrapper( queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }), + isRestoring = false, ) { return ({ children }: { children: React.ReactNode }) => ( - - - - {children} - - - } - /> - - + + + + + {children} + + + } + /> + + + ) } @@ -174,7 +177,11 @@ describe('RemoteProjectNavList', () => { mocks.sessions = [createSession('a1', '/projects/alpha', Date.now())] mocks.hasNextPage.current = false mocks.listProjects.mockResolvedValue([alpha, beta]) - mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) + mocks.useOpenCodeServers.mockReturnValue({ + servers: [kitchen], + isLoading: false, + showsInitialLoading: false, + }) mocks.useOpenCodeServerStatuses.mockReturnValue({ statuses: [], statusById: { srv_kitchen: { serverId: 'srv_kitchen', connected: true, version: null, error: null } }, @@ -289,6 +296,7 @@ describe('RemoteProjectNavList', () => { mocks.useOpenCodeServers.mockReturnValue({ servers: [{ ...kitchen, enabled: false }], isLoading: false, + showsInitialLoading: false, }) render( @@ -343,6 +351,39 @@ describe('RemoteProjectNavList', () => { expect(screen.queryByText('Could not load projects')).not.toBeInTheDocument() }) + it('keeps the project list loading while the cache restore holds the query', () => { + mocks.listProjects.mockReturnValue(new Promise(() => {})) + + render(, { + wrapper: createWrapper('/', undefined, true), + }) + + expect(screen.getByText('Loading projects...')).toBeInTheDocument() + expect(screen.queryByText('No projects')).not.toBeInTheDocument() + expect(mocks.listProjects).not.toHaveBeenCalled() + }) + + it('keeps the sidebar loading while the server list is restoring', () => { + mocks.useOpenCodeServers.mockReturnValue({ + servers: [], + isLoading: false, + showsInitialLoading: true, + }) + + render( + , + { wrapper: createWrapper('/') }, + ) + + expect(screen.getByText('Loading projects...')).toBeInTheDocument() + expect(screen.queryByText('Server not found')).not.toBeInTheDocument() + }) + it('reports an unreachable server on a failed project load', async () => { mocks.listProjects.mockRejectedValue(new Error('offline')) mocks.useOpenCodeServerStatuses.mockReturnValue({ diff --git a/frontend/src/components/navigation/RemoteProjectNav.tsx b/frontend/src/components/navigation/RemoteProjectNav.tsx index e1c8f764e..6bb5c068b 100644 --- a/frontend/src/components/navigation/RemoteProjectNav.tsx +++ b/frontend/src/components/navigation/RemoteProjectNav.tsx @@ -360,20 +360,19 @@ export function RemoteProjectNavList({ onOpenProject, onSelectSession, }: RemoteProjectNavListProps) { - const { servers, isLoading: isServersLoading } = useOpenCodeServers() + const { servers, showsInitialLoading: isServersLoading } = useOpenCodeServers() const { statusById } = useOpenCodeServerStatuses() const server = servers.find((candidate) => candidate.id === serverId) const { directory: activeDirectory } = useActiveRemoteDirectory(serverId) - const { projects, isLoading: projectsLoading, isError: projectsError, hasData: hasProjects } = - useRemoteProjectsByActivity(serverId) + const { projects, showsInitialLoading, showsError } = useRemoteProjectsByActivity(serverId) const trimmedSearch = search.trim() if (isServersLoading) return Loading projects... if (!server) return Server not found if (!server.enabled) return Server is disabled - if (projectsLoading) return Loading projects... - if (projectsError && !hasProjects) { + if (showsInitialLoading) return Loading projects... + if (showsError) { return ( {statusById[serverId]?.connected === false ? 'Server unreachable' : 'Could not load projects'} diff --git a/frontend/src/components/repo/RepoList.test.tsx b/frontend/src/components/repo/RepoList.test.tsx index 3c0966784..8946c1655 100644 --- a/frontend/src/components/repo/RepoList.test.tsx +++ b/frontend/src/components/repo/RepoList.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { render, screen, within } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter } from 'react-router-dom' @@ -64,8 +64,12 @@ function createRepo(overrides: Partial = {}): Repo { } } -function renderRepoList(onAddRepo: () => void = vi.fn()) { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) +function renderRepoList( + onAddRepo: () => void = vi.fn(), + options: { queryClient?: QueryClient } = {}, +) { + const queryClient = options.queryClient + ?? new QueryClient({ defaultOptions: { queries: { retry: false } } }) return render( @@ -121,6 +125,26 @@ describe('RepoList', () => { expect(await screen.findByText('No recently accessed repositories')).toBeInTheDocument() }) + it('shows the error state when the first load fails', async () => { + mocks.listRepos.mockRejectedValue(new Error('offline')) + + renderRepoList() + + expect(await screen.findByText(/Failed to load repositories/)).toBeInTheDocument() + }) + + it('keeps cached repositories when the refetch fails', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + queryClient.setQueryData(['repos'], [createRepo()]) + mocks.listRepos.mockRejectedValue(new Error('offline')) + + renderRepoList(vi.fn(), { queryClient }) + + expect(await screen.findByText('demo-app')).toBeInTheDocument() + await waitFor(() => expect(queryClient.getQueryState(['repos'])?.status).toBe('error')) + expect(screen.queryByText(/Failed to load repositories/)).not.toBeInTheDocument() + }) + it('labels the confirm button "Unlink" for a local repository', async () => { mocks.listRepos.mockResolvedValue([createRepo()]) const user = userEvent.setup() diff --git a/frontend/src/components/repo/RepoList.tsx b/frontend/src/components/repo/RepoList.tsx index 28175a3dd..68f744b2c 100644 --- a/frontend/src/components/repo/RepoList.tsx +++ b/frontend/src/components/repo/RepoList.tsx @@ -162,7 +162,7 @@ export function RepoList({ onAddRepo }: { onAddRepo: () => void }) { const { data: repos, - isLoading, + isPending, error, } = useQuery({ queryKey: ["repos"], @@ -312,7 +312,7 @@ export function RepoList({ onAddRepo }: { onAddRepo: () => void }) { const renderContent = () => { switch (true) { - case isLoading && !repos: + case isPending && !repos: return (
@@ -332,7 +332,7 @@ export function RepoList({ onAddRepo }: { onAddRepo: () => void }) {
) - case !!error: + case !!error && !repos: return (
Failed to load repositories:{" "} diff --git a/frontend/src/components/servers/RemoteProjectList.tsx b/frontend/src/components/servers/RemoteProjectList.tsx index c0f8f14b7..89cdd9db4 100644 --- a/frontend/src/components/servers/RemoteProjectList.tsx +++ b/frontend/src/components/servers/RemoteProjectList.tsx @@ -86,7 +86,7 @@ function RemoteProjectListLoading() { } function RemoteProjectListContent({ serverId }: { serverId: string }) { - const { servers, isLoading: serversLoading } = useOpenCodeServers() + const { servers, showsInitialLoading: serversLoading } = useOpenCodeServers() const { openTab } = useSettingsDialog() const [openDirectoryOpen, setOpenDirectoryOpen] = useState(false) const [searchQuery, setSearchQuery] = useState('') @@ -94,8 +94,8 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) { const server = servers.find((candidate) => candidate.id === serverId) const isDisabled = server !== undefined && !server.enabled - const { projects, activityById, isLoading, isError, hasData, error, refetch } = useRemoteProjectsByActivity(serverId) - const showsProjectError = isError && !hasData + const { projects, activityById, showsInitialLoading, showsError, error, refetch } = + useRemoteProjectsByActivity(serverId) const filteredProjects = useMemo(() => { const query = searchQuery.trim().toLowerCase() @@ -121,6 +121,8 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) { /> ) : null} + {serversLoading ? : null} + {isDisabled ? ( ) : null} - {server && !isDisabled && showsProjectError ? ( + {server && !isDisabled && showsError ? (

Could not load projects

- {error instanceof Error - ? error.message - : 'The server did not respond.'} + {error instanceof Error ? error.message : 'The server did not respond.'}