From ca52e0b35247ff6a57b4b37582a9ed8b173d0ec9 Mon Sep 17 00:00:00 2001 From: "posthog-eu[bot]" <226701856+posthog-eu[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:02:05 +0000 Subject: [PATCH] Surface recent-files load failures with a retry instead of a silent empty state The file-add modal's "Recent" list treated a failed or stalled load identically to an empty history: loadRecentFiles swallowed errors into an empty array (console.error/warn only) and FileListArea rendered just "Loading files..." or "No recent files", with no error message, no retry, and no telemetry. - Track a loadError flag in useFileManager and thread it through FileManagerContext to FileListArea, which now renders four distinct states: loading / loaded / error (with a "Try again" retry) / empty. - Capture the previously-swallowed errors as PostHog $exception events via a new analytics.captureException helper (top-level, server-files, and share-links failures) so the failure stops being invisible. - Add a 30s timeout to the storage metadata GETs so a stalled connection surfaces as an error/retry instead of an indefinite spinner. - Add FileListArea state-machine tests. Generated-By: PostHog Code Task-Id: 63a8e289-0cd7-4a4c-9914-43504209029d --- .../public/locales/en-US/translation.toml | 2 + .../src/core/components/FileManager.tsx | 4 +- .../fileManager/FileListArea.test.tsx | 131 ++++++++++++++++++ .../components/fileManager/FileListArea.tsx | 47 +++++-- .../src/core/contexts/FileManagerContext.tsx | 5 + .../editor/src/core/hooks/useFileManager.ts | 21 +++ .../editor/src/core/services/analytics.ts | 18 +++ 7 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 frontend/editor/src/core/components/fileManager/FileListArea.test.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index cb8b4d6fb7..40df249eb0 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3608,6 +3608,8 @@ lastSynced = "Last synced" leaveShare = "Remove from my list" leaveShareFailed = "Could not remove the shared file." leaveShareSuccess = "Removed from your shared list." +loadFailed = "Couldn't load your recent files" +loadFailedRetry = "Try again" loadingFiles = "Loading files..." localOnly = "Local only" makeCopy = "Make a copy" diff --git a/frontend/editor/src/core/components/FileManager.tsx b/frontend/editor/src/core/components/FileManager.tsx index d05d05ea2b..06de0324a3 100644 --- a/frontend/editor/src/core/components/FileManager.tsx +++ b/frontend/editor/src/core/components/FileManager.tsx @@ -42,7 +42,8 @@ const FileManager: React.FC = ({ selectedTool }) => { const [isDragging, setIsDragging] = useState(false); const [isMobile, setIsMobile] = useState(false); - const { loadRecentFiles, handleRemoveFile, loading } = useFileManager(); + const { loadRecentFiles, handleRemoveFile, loading, loadError } = + useFileManager(); const { actions: fileActions } = useFileActions(); // Get active file IDs from FileContext to show which files are already loaded @@ -240,6 +241,7 @@ const FileManager: React.FC = ({ selectedTool }) => { modalHeight={modalHeight} refreshRecentFiles={refreshRecentFiles} isLoading={loading} + loadError={loadError} activeFileIds={activeFileIds} > {isMobile ? : } diff --git a/frontend/editor/src/core/components/fileManager/FileListArea.test.tsx b/frontend/editor/src/core/components/fileManager/FileListArea.test.tsx new file mode 100644 index 0000000000..1e48372329 --- /dev/null +++ b/frontend/editor/src/core/components/fileManager/FileListArea.test.tsx @@ -0,0 +1,131 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MantineProvider } from "@mantine/core"; + +// Mock i18n so assertions can rely on the English default strings. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + }), +})); + +// Isolate FileListArea's branching logic from its heavy children — we only +// care which state it renders, not how each child looks. +vi.mock("@app/components/fileManager/EmptyFilesState", () => ({ + default: () =>
No recent files
, +})); +vi.mock("@app/components/fileManager/FileListItem", () => ({ + default: ({ file }: { file: { name: string } }) =>
{file.name}
, +})); +vi.mock("@app/components/fileManager/FileHistoryGroup", () => ({ + default: () => null, +})); +vi.mock("@app/ui/Button", () => ({ + Button: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => , +})); + +const contextValue = vi.hoisted(() => ({ + current: {} as Record, +})); +vi.mock("@app/contexts/FileManagerContext", () => ({ + useFileManagerContext: () => contextValue.current, +})); + +import FileListArea from "@app/components/fileManager/FileListArea"; + +function baseContext(overrides: Record) { + return { + activeSource: "recent", + recentFiles: [], + filteredFiles: [], + selectedFilesSet: new Set(), + expandedFileIds: new Set(), + loadedHistoryFiles: new Map(), + onFileSelect: vi.fn(), + onFileRemove: vi.fn(), + onHistoryFileRemove: vi.fn(), + onFileDoubleClick: vi.fn(), + onDownloadSingle: vi.fn(), + isLoading: false, + loadError: false, + activeFileIds: [], + refreshRecentFiles: vi.fn(), + ...overrides, + }; +} + +describe("FileListArea recent-files states", () => { + beforeEach(() => { + contextValue.current = baseContext({}); + }); + + it("shows the empty state when there are no files and no error", () => { + contextValue.current = baseContext({ isLoading: false, loadError: false }); + render( + + + , + ); + expect(screen.getByText("No recent files")).toBeInTheDocument(); + expect(screen.queryByText("Try again")).not.toBeInTheDocument(); + }); + + it("shows the loading state while loading with no files yet", () => { + contextValue.current = baseContext({ isLoading: true }); + render( + + + , + ); + expect(screen.getByText("Loading files...")).toBeInTheDocument(); + expect(screen.queryByText("No recent files")).not.toBeInTheDocument(); + }); + + it("shows an error state with a retry action when the load fails (not the empty state)", () => { + const refreshRecentFiles = vi.fn(); + contextValue.current = baseContext({ + isLoading: false, + loadError: true, + refreshRecentFiles, + }); + render( + + + , + ); + + // The failed load must be visually distinct from a genuinely empty history. + expect( + screen.getByText("Couldn't load your recent files"), + ).toBeInTheDocument(); + expect(screen.queryByText("No recent files")).not.toBeInTheDocument(); + + const retry = screen.getByText("Try again"); + fireEvent.click(retry); + expect(refreshRecentFiles).toHaveBeenCalledTimes(1); + }); + + it("renders the file list when files are present even if a partial load error occurred", () => { + contextValue.current = baseContext({ + recentFiles: [{ id: "a" }], + filteredFiles: [{ id: "a", name: "doc.pdf" }], + loadError: true, + }); + render( + + + , + ); + expect(screen.getByText("doc.pdf")).toBeInTheDocument(); + expect( + screen.queryByText("Couldn't load your recent files"), + ).not.toBeInTheDocument(); + expect(screen.queryByText("No recent files")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/core/components/fileManager/FileListArea.tsx b/frontend/editor/src/core/components/fileManager/FileListArea.tsx index 4ee5d7f2bd..b1c1cf6e1f 100644 --- a/frontend/editor/src/core/components/fileManager/FileListArea.tsx +++ b/frontend/editor/src/core/components/fileManager/FileListArea.tsx @@ -1,10 +1,12 @@ import React from "react"; import { Center, ScrollArea, Text, Stack } from "@mantine/core"; import CloudIcon from "@mui/icons-material/Cloud"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlineRounded"; import { useTranslation } from "react-i18next"; import FileListItem from "@app/components/fileManager/FileListItem"; import FileHistoryGroup from "@app/components/fileManager/FileHistoryGroup"; import EmptyFilesState from "@app/components/fileManager/EmptyFilesState"; +import { Button } from "@app/ui/Button"; import { useFileManagerContext } from "@app/contexts/FileManagerContext"; interface FileListAreaProps { @@ -29,7 +31,9 @@ const FileListArea: React.FC = ({ onFileDoubleClick, onDownloadSingle, isLoading, + loadError, activeFileIds, + refreshRecentFiles, } = useFileManagerContext(); const { t } = useTranslation(); @@ -44,15 +48,7 @@ const FileListArea: React.FC = ({ scrollbarSize={8} > - {recentFiles.length === 0 && !isLoading ? ( - - ) : recentFiles.length === 0 && isLoading ? ( -
- - {t("fileManager.loadingFiles", "Loading files...")} - -
- ) : ( + {recentFiles.length > 0 ? ( filteredFiles.map((file, index) => { // All files in filteredFiles are now leaf files only const historyFiles = loadedHistoryFiles.get(file.id) || []; @@ -84,6 +80,39 @@ const FileListArea: React.FC = ({ ); }) + ) : isLoading ? ( +
+ + {t("fileManager.loadingFiles", "Loading files...")} + +
+ ) : loadError ? ( +
+ + + + {t( + "fileManager.loadFailed", + "Couldn't load your recent files", + )} + + + +
+ ) : ( + )}
diff --git a/frontend/editor/src/core/contexts/FileManagerContext.tsx b/frontend/editor/src/core/contexts/FileManagerContext.tsx index a557a9d31b..83638f0253 100644 --- a/frontend/editor/src/core/contexts/FileManagerContext.tsx +++ b/frontend/editor/src/core/contexts/FileManagerContext.tsx @@ -42,6 +42,7 @@ interface FileManagerContextValue { fileGroups: Map; loadedHistoryFiles: Map; isLoading: boolean; + loadError: boolean; activeFileIds: FileId[]; // Handlers @@ -96,6 +97,7 @@ interface FileManagerProviderProps { modalHeight: string; refreshRecentFiles: () => Promise; isLoading: boolean; + loadError: boolean; activeFileIds: FileId[]; maxSelectable?: number | null; } @@ -115,6 +117,7 @@ export const FileManagerProvider: React.FC = ({ modalHeight, refreshRecentFiles, isLoading, + loadError, activeFileIds, maxSelectable = null, }) => { @@ -1089,6 +1092,7 @@ export const FileManagerProvider: React.FC = ({ fileGroups, loadedHistoryFiles, isLoading, + loadError, activeFileIds, // Handlers @@ -1131,6 +1135,7 @@ export const FileManagerProvider: React.FC = ({ fileGroups, loadedHistoryFiles, isLoading, + loadError, activeFileIds, handleSourceChange, handleStorageFilterChange, diff --git a/frontend/editor/src/core/hooks/useFileManager.ts b/frontend/editor/src/core/hooks/useFileManager.ts index 442027364e..8f87d1dfa7 100644 --- a/frontend/editor/src/core/hooks/useFileManager.ts +++ b/frontend/editor/src/core/hooks/useFileManager.ts @@ -5,6 +5,7 @@ import { StirlingFileStub, StirlingFile } from "@app/types/fileContext"; import { FileId } from "@app/types/fileContext"; import apiClient from "@app/services/apiClient"; import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { captureException } from "@app/services/analytics"; interface StoredFileResponse { id: number; @@ -33,6 +34,7 @@ interface AccessedShareLinkResponse { export const useFileManager = () => { const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(false); const indexedDB = useIndexedDB(); const { config } = useAppConfig(); @@ -99,6 +101,11 @@ export const useFileManager = () => { const loadRecentFiles = useCallback(async (): Promise => { setLoading(true); + setLoadError(false); + // Track whether any part of the load failed so callers can distinguish a + // failed/partial load from a genuinely empty history instead of silently + // falling through to an empty list. + let encounteredError = false; try { if (!indexedDB) { return []; @@ -122,6 +129,10 @@ export const useFileManager = () => { { suppressErrorToast: true, skipAuthRedirect: true, + // Metadata listing, not a file transfer — bound the wait so a + // stalled connection surfaces as an error/retry instead of an + // indefinite "Loading files..." spinner. + timeout: 30000, } as any, ); const serverFiles = Array.isArray(response.data) ? response.data : []; @@ -242,6 +253,8 @@ export const useFileManager = () => { combinedStubs = [...updatedLocalStubs, ...serverStubs]; } catch (error) { + encounteredError = true; + captureException(error, { context: "loadRecentFiles.serverFiles" }); console.warn("Failed to load server files:", error); } @@ -252,6 +265,8 @@ export const useFileManager = () => { >("/api/v1/storage/share-links/accessed", { suppressErrorToast: true, skipAuthRedirect: true, + // Bound the wait — see server-files fetch above. + timeout: 30000, } as any); const sharedLinks = Array.isArray(sharedResponse.data) ? sharedResponse.data @@ -346,6 +361,8 @@ export const useFileManager = () => { combinedStubs = [...combinedStubs, ...sharedStubs]; } catch (error) { + encounteredError = true; + captureException(error, { context: "loadRecentFiles.shareLinks" }); console.warn("Failed to load shared links:", error); } } @@ -358,9 +375,12 @@ export const useFileManager = () => { return sortedFiles; } catch (error) { + encounteredError = true; + captureException(error, { context: "loadRecentFiles" }); console.error("Failed to load recent files:", error); return []; } finally { + setLoadError(encounteredError); setLoading(false); } }, [ @@ -505,6 +525,7 @@ export const useFileManager = () => { return { loading, + loadError, convertToFile, loadRecentFiles, handleRemoveFile, diff --git a/frontend/editor/src/core/services/analytics.ts b/frontend/editor/src/core/services/analytics.ts index f4ea317eab..8d2f648bb0 100644 --- a/frontend/editor/src/core/services/analytics.ts +++ b/frontend/editor/src/core/services/analytics.ts @@ -38,3 +38,21 @@ export function trackEditorOperation(toolId: string, fileCount: number): void { if (DEV) console.warn("[analytics] trackEditorOperation failed", error); } } + +/** + * Report a caught error to PostHog so otherwise-swallowed failures become + * visible as `$exception` events instead of vanishing into a console.warn. + */ +export function captureException( + error: unknown, + context?: Record, +): void { + try { + if (!canCapture()) return; + const normalized = + error instanceof Error ? error : new Error(String(error)); + posthog.captureException(normalized, context); + } catch (captureError) { + if (DEV) console.warn("[analytics] captureException failed", captureError); + } +}