From e8d27a006f77009a2bd2c6d0f4560534623e8e49 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:02:22 +0530 Subject: [PATCH] fix(settings): resolve blank state on first open (#429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(settings): resolve blank state on first open Keep CoreStateProvider bootstrapping alive on initial RPC failure instead of immediately giving up — lets the 3s poll retry until the sidecar responds (up to 5 attempts). Add catch-all route in Settings to redirect unmatched sub-paths, and show RouteLoadingScreen during PersistGate rehydration instead of rendering nothing. Closes #413 * refactor: use async/await in poll handler for consistency Address CodeRabbit nitpick — replace .then()/.catch() chain with async/await in the setInterval poll callback to match the rest of the codebase style. --- .claude/memory.md | 8 ++++++ app/src/App.tsx | 3 +- app/src/pages/Settings.tsx | 3 +- app/src/providers/CoreStateProvider.tsx | 38 +++++++++++++++++++++---- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/.claude/memory.md b/.claude/memory.md index da6e9f790..5a8fd2947 100644 --- a/.claude/memory.md +++ b/.claude/memory.md @@ -54,6 +54,14 @@ Quick reference for anyone starting with Claude on this project. Updated by the - **`DEV_FORCE_ONBOARDING` was a no-op** — The old ternary had identical branches; fixed to actually force-show when the flag is set. - **`isOnboardedRedux` must be in useEffect deps** — When reading a selector value inside a useEffect, add it to the dependency array or the effect won't re-run when Redux state changes. +## CoreStateProvider & Auth Bootstrap + +- **Auth session tokens are NOT in Redux persist** — They live entirely in the Rust sidecar, fetched via `fetchCoreAppSnapshot()` RPC. `PersistGate` only gates non-auth state (AI config, threads, channel connections). `CoreStateProvider` bootstrap is the critical auth path. +- **`CoreStateProvider` premature `isBootstrapping: false` causes blank Settings** — If the initial RPC call fails (sidecar still starting), the old error handler set `isBootstrapping: false` immediately, causing `ProtectedRoute` to redirect to `/` before the 3s poll could recover. Fix (issue #413): keep `isBootstrapping: true` on initial failure, let the poll retry, give up after 5 attempts (~15s). +- **`CoreStateProvider` is consumed by ~25 components** — Changes to its state shape or bootstrap behavior affect routes, socket, onboarding, nav, settings, and hooks. Treat it as a high-blast-radius file. +- **Settings is a full route, not a modal** — `/settings/*` uses nested `` in `Settings.tsx`. The `.claude/rules/15-settings-modal-system.md` doc describing a portal/modal approach is outdated. A catch-all `` redirects unmatched sub-paths to `/settings`. +- **`PersistGate loading={null}` causes flash** — Changed to `loading={}` (issue #413). `RouteLoadingScreen` accepts an optional `label` prop (defaults to "Initializing OpenHuman...") and can be rendered with no props. + ## Build Blockers: macOS Tahoe + whisper-rs - **`whisper-rs` breaks `cargo build` on macOS Tahoe (Apple Silicon)** — Added in main via `whisper-rs = "0.16"` (voice feature #178). Apple clang 21+ refuses `-mcpu=native` when `--target=arm64-apple-macosx` is also set. This is NOT fixable by updating CLT. diff --git a/app/src/App.tsx b/app/src/App.tsx index 5aa7682ec..9c62a1b30 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -11,6 +11,7 @@ import ErrorFallbackScreen from './components/ErrorFallbackScreen'; import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar'; import MeshGradient from './components/MeshGradient'; import OnboardingOverlay from './components/OnboardingOverlay'; +import RouteLoadingScreen from './components/RouteLoadingScreen'; import CoreStateProvider from './providers/CoreStateProvider'; import SocketProvider from './providers/SocketProvider'; import { tagErrorSource } from './services/errorReportQueue'; @@ -26,7 +27,7 @@ function App() { tagErrorSource(eventId, 'react', componentStack); }}> - + } persistor={persistor}> diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index ee4e8e0f6..651c73441 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -1,4 +1,4 @@ -import { Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes } from 'react-router-dom'; import AccessibilityPanel from '../components/settings/panels/AccessibilityPanel'; import AdvancedPanel from '../components/settings/panels/AdvancedPanel'; @@ -331,6 +331,7 @@ const Settings = () => { } /> } /> } /> + } />
Beta build - v{APP_VERSION} diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index 72c5262de..c1e016a39 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -33,6 +33,7 @@ import { } from '../utils/tauriCommands'; const POLL_MS = 3000; +const MAX_BOOTSTRAP_RETRIES = 5; interface CoreStateContextValue extends CoreState { refresh: () => Promise; @@ -76,6 +77,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const snapshotRequestIdRef = useRef(0); const teamsRequestIdRef = useRef(0); const memoryTokenRef = useRef(state.snapshot.sessionToken); + const bootstrapFailCountRef = useRef(0); const commitState = useCallback((updater: (previous: CoreState) => CoreState) => { setState(previous => { @@ -170,23 +172,49 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const load = async () => { try { await refresh(); + bootstrapFailCountRef.current = 0; const next = getCoreStateSnapshot(); if (next.snapshot.auth.isAuthenticated) { await refreshTeams().catch(() => {}); } } catch (error) { if (!cancelled) { - console.warn('[core-state] initial refresh failed:', error); - commitState(previous => ({ ...previous, isBootstrapping: false })); + bootstrapFailCountRef.current += 1; + console.warn( + `[core-state] initial refresh failed (attempt ${bootstrapFailCountRef.current}/${MAX_BOOTSTRAP_RETRIES}):`, + error + ); + if (bootstrapFailCountRef.current >= MAX_BOOTSTRAP_RETRIES) { + commitState(previous => ({ ...previous, isBootstrapping: false })); + } } } }; void load(); const interval = window.setInterval(() => { - void refresh().catch(error => { - console.warn('[core-state] poll failed:', error); - }); + void (async () => { + try { + await refresh(); + bootstrapFailCountRef.current = 0; + } catch (error) { + if (!cancelled) { + bootstrapFailCountRef.current += 1; + console.warn( + `[core-state] poll failed (attempt ${bootstrapFailCountRef.current}/${MAX_BOOTSTRAP_RETRIES}):`, + error + ); + if (bootstrapFailCountRef.current >= MAX_BOOTSTRAP_RETRIES) { + commitState(previous => { + if (previous.isBootstrapping) { + return { ...previous, isBootstrapping: false }; + } + return previous; + }); + } + } + } + })(); }, POLL_MS); return () => {