diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx
index 28790de364c..62c9bdd0891 100644
--- a/desktop/src/app/App.tsx
+++ b/desktop/src/app/App.tsx
@@ -1,15 +1,21 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
-import { useQueryClient } from "@tanstack/react-query";
+import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
-import { useCallback, useEffect, useLayoutEffect } from "react";
+import {
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useState,
+} from "react";
import { router } from "@/app/router";
-import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
import { useAppOnboardingState } from "@/features/onboarding/hooks";
import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup";
+import { createSproutQueryClient } from "@/shared/api/queryClient";
import { listenForDeepLinks } from "@/shared/deep-link";
function AppLoadingGate() {
@@ -30,6 +36,14 @@ function AppLoadingGate() {
);
}
+function WorkspaceQueryProvider({ children }: { children: ReactNode }) {
+ const [queryClient] = useState(createSproutQueryClient);
+
+ return (
+ {children}
+ );
+}
+
function AppReady() {
const onboarding = useAppOnboardingState();
@@ -55,7 +69,6 @@ export function App() {
void getCurrentWindow().show();
}, []);
- const queryClient = useQueryClient();
const {
activeWorkspace,
reinitKey,
@@ -74,19 +87,10 @@ export function App() {
void unlisten.then((fn) => fn());
};
}, [addWorkspace, switchWorkspace, reconnectWorkspace]);
- const workspace = useWorkspaceInit(activeWorkspace);
-
// Composite key: changes when workspace ID changes OR when
// the active workspace's config is updated (relayUrl/token).
const workspaceKey = `${activeWorkspace?.id ?? "none"}-${reinitKey}`;
-
- // Clear stale React Query cache synchronously when workspace changes.
- // useLayoutEffect fires before child useEffect hooks, preventing stale
- // data from being served to the new workspace's components.
- // biome-ignore lint/correctness/useExhaustiveDependencies: workspaceKey drives the re-run intentionally
- useLayoutEffect(() => {
- queryClient.clear();
- }, [workspaceKey, queryClient]);
+ const workspace = useWorkspaceInit(activeWorkspace, workspaceKey);
const handleSetupComplete = useCallback(() => {
// Force a full reload so useWorkspaces re-initializes from localStorage.
@@ -104,15 +108,17 @@ export function App() {
);
}
- // Wait for workspace config to be applied to the backend before
- // rendering anything that connects to the relay.
- if (!workspace.isReady) {
+ // Wait for this exact workspace config to be applied to the backend before
+ // rendering anything that connects to the relay. The appliedKey check avoids
+ // a one-render race where React sees the new active workspace while the Tauri
+ // backend is still configured for the previous one.
+ if (!workspace.isReady || workspace.appliedKey !== workspaceKey) {
return ;
}
return (
-
+
-
+
);
}
diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts
index f15f7169d8c..26500448549 100644
--- a/desktop/src/features/workspaces/useWorkspaceInit.ts
+++ b/desktop/src/features/workspaces/useWorkspaceInit.ts
@@ -24,13 +24,13 @@ function resetWorkspaceState(): void {
}
type WorkspaceInitResult =
- | { isReady: true; needsSetup: false }
+ | { isReady: true; needsSetup: false; appliedKey: string }
| {
isReady: false;
needsSetup: true;
defaultRelayUrl: string;
}
- | { isReady: false; needsSetup: false };
+ | { isReady: false; needsSetup: false; appliedKey: string | null };
/**
* Applies the active workspace config to the Tauri backend and resets
@@ -42,10 +42,12 @@ type WorkspaceInitResult =
*/
export function useWorkspaceInit(
activeWorkspace: Workspace | null,
+ workspaceKey: string,
): WorkspaceInitResult {
const [result, setResult] = useState({
isReady: false,
needsSetup: false,
+ appliedKey: null,
});
// Track whether this is the initial mount or a workspace switch.
@@ -80,6 +82,16 @@ export function useWorkspaceInit(
return;
}
+ // Mark this workspace config as pending while it is applied to the
+ // backend. App.tsx also checks appliedKey against the active workspaceKey,
+ // which prevents rendering workspace-scoped UI for a new workspace until
+ // that exact config has finished applying.
+ setResult({
+ isReady: false,
+ needsSetup: false,
+ appliedKey: workspaceKey,
+ });
+
// On workspace switch (not initial mount), reset module singletons
// so the new tree starts with a clean slate.
if (hasInitializedRef.current) {
@@ -87,9 +99,6 @@ export function useWorkspaceInit(
}
hasInitializedRef.current = true;
- // Show loading gate while we apply the new workspace config
- setResult({ isReady: false, needsSetup: false });
-
// Apply workspace config to the Tauri backend.
//
// Note: we deliberately do NOT pass an nsec here. The persisted
@@ -110,7 +119,11 @@ export function useWorkspaceInit(
}
if (!cancelled) {
- setResult({ isReady: true, needsSetup: false });
+ setResult({
+ isReady: true,
+ needsSetup: false,
+ appliedKey: workspaceKey,
+ });
}
}
@@ -119,7 +132,12 @@ export function useWorkspaceInit(
return () => {
cancelled = true;
};
- }, [activeWorkspace?.id, activeWorkspace?.relayUrl, activeWorkspace?.token]);
+ }, [
+ activeWorkspace?.id,
+ activeWorkspace?.relayUrl,
+ activeWorkspace?.token,
+ workspaceKey,
+ ]);
return result;
}
diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx
index ff8988551b5..e706404ed06 100644
--- a/desktop/src/main.tsx
+++ b/desktop/src/main.tsx
@@ -1,8 +1,8 @@
import React from "react";
import ReactDOM from "react-dom/client";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App } from "@/app/App";
import "@/shared/styles/globals.css";
+import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
import { WorkspacesProvider } from "@/features/workspaces/useWorkspaces";
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
import { Toaster } from "@/shared/ui/sonner";
@@ -12,33 +12,19 @@ type E2eWindow = Window & {
__SPROUT_E2E__?: unknown;
};
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: 1,
- refetchOnWindowFocus: false,
- networkMode: "always",
- gcTime: 5 * 60 * 1_000,
- },
- mutations: {
- networkMode: "always",
- },
- },
-});
-
function renderApp() {
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
-
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
,
);
}
diff --git a/desktop/src/shared/api/queryClient.ts b/desktop/src/shared/api/queryClient.ts
new file mode 100644
index 00000000000..196998ccab3
--- /dev/null
+++ b/desktop/src/shared/api/queryClient.ts
@@ -0,0 +1,17 @@
+import { QueryClient } from "@tanstack/react-query";
+
+export function createSproutQueryClient() {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: 1,
+ refetchOnWindowFocus: false,
+ networkMode: "always",
+ gcTime: 5 * 60 * 1_000,
+ },
+ mutations: {
+ networkMode: "always",
+ },
+ },
+ });
+}