mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,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 () => {
|
||||
|
||||
Reference in New Issue
Block a user