fix(settings): resolve blank state on first open (#429)

* 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.
This commit is contained in:
Cyrus Gray
2026-04-08 20:02:22 +05:30
committed by GitHub
parent 8c52236737
commit e8d27a006f
4 changed files with 45 additions and 7 deletions
+8
View File
@@ -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 `<Routes>` in `Settings.tsx`. The `.claude/rules/15-settings-modal-system.md` doc describing a portal/modal approach is outdated. A catch-all `<Route path="*">` redirects unmatched sub-paths to `/settings`.
- **`PersistGate loading={null}` causes flash** — Changed to `loading={<RouteLoadingScreen />}` (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.
+2 -1
View File
@@ -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);
}}>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<PersistGate loading={<RouteLoadingScreen />} persistor={persistor}>
<CoreStateProvider>
<SocketProvider>
<Router>
+2 -1
View File
@@ -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 = () => {
<Route path="memory-data" element={<MemoryDataPanel />} />
<Route path="memory-debug" element={<MemoryDebugPanel />} />
<Route path="recovery-phrase" element={<RecoveryPhrasePanel />} />
<Route path="*" element={<Navigate to="/settings" replace />} />
</Routes>
<div className="border-t border-stone-100 px-4 py-3 text-center text-[11px] text-stone-400">
Beta build - v{APP_VERSION}
+33 -5
View File
@@ -33,6 +33,7 @@ import {
} from '../utils/tauriCommands';
const POLL_MS = 3000;
const MAX_BOOTSTRAP_RETRIES = 5;
interface CoreStateContextValue extends CoreState {
refresh: () => Promise<void>;
@@ -76,6 +77,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
const snapshotRequestIdRef = useRef(0);
const teamsRequestIdRef = useRef(0);
const memoryTokenRef = useRef<string | null>(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 () => {