[eric] boot: bootstrap fetches retry until the backend answers and heal on focus, so a lost boot race stops bricking first paint

This commit is contained in:
ciregenz
2026-08-08 09:34:13 -07:00
parent 5c0d6d52b6
commit d60a99ddd1
5 changed files with 45 additions and 2 deletions
+24
View File
@@ -215,6 +215,25 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
useEffect(() => {
dispatch(fetchSettings());
dispatch(fetchModels());
// Boot race (ENG-207): the renderer can beat the backend up by many seconds, and a hidden
// window's timers get App-Napped, so a one-shot fetch that lost the race stayed lost until a
// manual reload. Retry the two bootstrap reads until settings answer, and re-kick on focus /
// visibility so a napped window heals the moment anyone looks at it.
let bootTimer: ReturnType<typeof setInterval> | null = null;
const bootRetry = () => {
const st = store.getState();
const settingsOk = st.settings.loaded;
const modelsOk = st.models.loaded && !st.models.failed;
if (settingsOk && modelsOk) {
if (bootTimer) { clearInterval(bootTimer); bootTimer = null; }
return;
}
if (!settingsOk) dispatch(fetchSettings());
if (!modelsOk) dispatch(fetchModels());
};
bootTimer = setInterval(bootRetry, 3000);
window.addEventListener('focus', bootRetry);
document.addEventListener('visibilitychange', bootRetry);
// Report the app launch with the browser's canonical tz/locale so the backend can emit analytics app_lifecycle.opened with values that work in packaged, dev, and open-source builds. Guarded once per page load; backend dedupes per process.
reportAppOpened();
// Connected subscriptions live in their own slice; without this the dashboard (and the onboarding gate) think no model is connected until the user opens Settings > Models, so a fresh launch shows a false "connect a model" empty state and the welcome cursor never fires. Refetched after sync + on focus below.
@@ -231,6 +250,11 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
// The backend arms server-side regardless of whether the browser can read the mint response (a transient boot-time CORS/timing miss makes `data` unreadable), so refetch unconditionally, the GET is the only reliable signal the UI gets that it armed.
.finally(() => { dispatch(fetchSettings()); dispatch(fetchSubscriptionStatus()); dispatch(markFreeTrialArmSettled()); });
});
return () => {
if (bootTimer) clearInterval(bootTimer);
window.removeEventListener('focus', bootRetry);
document.removeEventListener('visibilitychange', bootRetry);
};
}, [dispatch]);
useEffect(() => {
@@ -68,7 +68,7 @@ const AppShell: React.FC = () => {
};
}, []);
const modelsLoaded = useAppSelector((s) => s.models.loaded);
const modelsLoaded = useAppSelector((s) => s.models.loaded && !s.models.failed);
// The models list is marked loaded even when its fetch fails, so it alone can't tell "no model" from "couldn't ask". Settings is where the user's own key/sub lives, so the banner waits for it.
const settingsKnown = useAppSelector((s) => s.settings.loaded);
// "Connected" = the user's OWN model (key/sub/pro/custom), NOT a non-empty /models list: the free-trial Haiku is always in that list now, so a byProvider-length check would falsely read as connected and hide the out-of-runs banner.
@@ -33,9 +33,16 @@ const DashboardAutoEnter: React.FC = () => {
if (!cancelled) timer = setTimeout(enter, 3000);
};
enter();
// A hidden window's timers get App-Napped, so the 3s retry could stall forever while the
// backend finished booting behind it (ENG-207). Focus/visibility re-kick heals it on sight.
const rekick = (): void => { if (!timer) return; clearTimeout(timer); timer = null; enter(); };
window.addEventListener('focus', rekick);
document.addEventListener('visibilitychange', rekick);
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
window.removeEventListener('focus', rekick);
document.removeEventListener('visibilitychange', rekick);
};
}, [dispatch, navigate]);
+6 -1
View File
@@ -48,7 +48,12 @@ export async function refreshAuthToken(): Promise<string> {
/** Resolve auth token once; concurrent callers share the same promise. */
export function ensureAuthToken(): Promise<string> {
if (_authTokenPromise) return _authTokenPromise;
_authTokenPromise = refreshAuthToken();
_authTokenPromise = refreshAuthToken().then((tok) => {
// A boot race can resolve EMPTY (backend hadn't written the token file yet); memoizing that
// left the renderer auth-dead until a manual reload (ENG-207). Empty = not an answer; retry.
if (!tok) _authTokenPromise = null;
return tok;
});
return _authTokenPromise;
}
+7
View File
@@ -21,11 +21,13 @@ export interface ModelOption {
interface ModelsState {
byProvider: Record<string, ModelOption[]>;
loaded: boolean;
failed: boolean;
}
const initialState: ModelsState = {
byProvider: {},
loaded: false,
failed: false,
};
export const fetchModels = createAsyncThunk('models/fetchModels', async () => {
@@ -49,6 +51,11 @@ const modelsSlice = createSlice({
.addCase(fetchModels.rejected, (state) => {
// Mark loaded even on failure so callers fall back to hardcoded options.
state.loaded = true;
// ...but remember it FAILED: a boot-race miss used to read as "loaded with no models" and lit the red banner on a fully configured install (ENG-207).
state.failed = true;
})
.addCase(fetchModels.pending, (state) => {
state.failed = false;
});
},
});