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