Skip to content

Commit e9988d8

Browse files
fix: ensure react query client is stable between renders (#1009)
The `QueryClient` constructed in `Sdk.tsx` was subject to being recreated during development mode hot reloads and it was causing unnecessary query cache invalidations. This change refactors the code so that the `QueryClient` is only created once per client-side session. This includes the error handling logic which it depends on. Error handling functions did not need to be threaded through React context so that was all removed in favor of simpler exported functions from `@/lib/errors`.
1 parent c039dc0 commit e9988d8

File tree

12 files changed

+120
-190
lines changed

12 files changed

+120
-190
lines changed

.changeset/yellow-days-guess.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"dashboard": patch
3+
---
4+
5+
Ensure stable QueryClient is used for lifetime of web app especially during
6+
development mode hot reloads.

client/dashboard/src/components/content-error-boundary.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
import { Button } from "@speakeasy-api/moonshine";
22
import { Card } from "@/components/ui/card";
33
import { Spinner } from "@/components/ui/spinner";
4-
import { useErrorHandler } from "@/contexts/ErrorHandler";
54
import { Icon, Stack } from "@speakeasy-api/moonshine";
65
import { ReactNode, Suspense } from "react";
76
import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary";
7+
import { handleError } from "@/lib/errors";
88

99
interface ContentErrorFallbackProps {
1010
error: Error;
1111
}
1212

1313
function ContentErrorFallback({ error }: ContentErrorFallbackProps) {
14-
const { handleError } = useErrorHandler();
15-
1614
// Log error to our error handler for consistent logging
1715
handleError(error, { silent: true });
1816

client/dashboard/src/contexts/ErrorHandler.tsx

Lines changed: 0 additions & 99 deletions
This file was deleted.

client/dashboard/src/contexts/Sdk.tsx

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { useErrorHandler } from "@/contexts/ErrorHandler";
21
import { getServerURL } from "@/lib/utils";
32
import { datadogRum } from "@datadog/browser-rum";
43
import { Gram } from "@gram/client";
@@ -8,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
87
import { createContext, useContext, useEffect, useMemo, useRef } from "react";
98
import { useLocation, useParams } from "react-router";
109
import { useTelemetry } from "./Telemetry";
10+
import { handleError } from "@/lib/errors";
1111

1212
export const SdkContext = createContext<Gram>({} as Gram);
1313

@@ -16,39 +16,34 @@ export const useSdkClient = () => {
1616
return client;
1717
};
1818

19+
const queryClient = new QueryClient({
20+
defaultOptions: {
21+
queries: {
22+
throwOnError: true,
23+
retry: (failureCount, error: Error) => {
24+
// Don't retry on 4xx errors
25+
if (error && typeof error === "object" && "status" in error) {
26+
const status = (error as unknown as { status: number }).status;
27+
if (status >= 400 && status < 500) {
28+
return false;
29+
}
30+
}
31+
// Default retry logic for other errors
32+
return failureCount < 3;
33+
},
34+
},
35+
mutations: {
36+
onError: (error: Error) => {
37+
handleError(error, { title: "Request failed" });
38+
},
39+
},
40+
},
41+
});
42+
1943
export const SdkProvider = ({ children }: { children: React.ReactNode }) => {
2044
const { projectSlug } = useSlugs();
21-
const { handleError } = useErrorHandler();
2245
const telemetry = useTelemetry();
2346

24-
const queryClient = useMemo(
25-
() =>
26-
new QueryClient({
27-
defaultOptions: {
28-
queries: {
29-
throwOnError: true,
30-
retry: (failureCount, error: Error) => {
31-
// Don't retry on 4xx errors
32-
if (error && typeof error === "object" && "status" in error) {
33-
const status = (error as unknown as { status: number }).status;
34-
if (status >= 400 && status < 500) {
35-
return false;
36-
}
37-
}
38-
// Default retry logic for other errors
39-
return failureCount < 3;
40-
},
41-
},
42-
mutations: {
43-
onError: (error: Error) => {
44-
handleError(error, { title: "Request failed" });
45-
},
46-
},
47-
},
48-
}),
49-
[handleError],
50-
);
51-
5247
const previousProjectSlug = useRef(projectSlug);
5348

5449
// Memoize the httpClient and gram instances

client/dashboard/src/hooks/useApiError.ts

Lines changed: 0 additions & 30 deletions
This file was deleted.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Type } from "@/components/ui/type";
2+
import { Stack } from "@speakeasy-api/moonshine";
3+
import { toast } from "sonner";
4+
5+
interface ErrorHandlerOptions {
6+
title?: string;
7+
persist?: boolean;
8+
customAction?: {
9+
label: string;
10+
onClick: () => void;
11+
};
12+
silent?: boolean;
13+
}
14+
15+
export function handleError(
16+
error: Error | string,
17+
options: ErrorHandlerOptions = {},
18+
) {
19+
const {
20+
title = "Error",
21+
persist = false,
22+
customAction,
23+
silent = false,
24+
} = options;
25+
26+
const errorMessage = typeof error === "string" ? error : error.message;
27+
28+
// Log error for debugging
29+
console.error("Error handled:", error);
30+
31+
// Show toast notification unless silent
32+
if (!silent) {
33+
toast.error(
34+
<Stack gap={1}>
35+
<Type variant="subheading" className="text-destructive!">
36+
{title}
37+
</Type>
38+
<Type small muted>
39+
{errorMessage}
40+
</Type>
41+
</Stack>,
42+
{
43+
duration: persist ? Infinity : 5000,
44+
action: customAction
45+
? {
46+
label: customAction.label,
47+
onClick: customAction.onClick,
48+
}
49+
: undefined,
50+
},
51+
);
52+
}
53+
}
54+
55+
export function handleAPIError(error: unknown, defaultMessage?: string) {
56+
let errorMessage = defaultMessage || "An unexpected error occurred";
57+
58+
if (error instanceof Error) {
59+
errorMessage = error.message;
60+
} else if (typeof error === "string") {
61+
errorMessage = error;
62+
} else if (error && typeof error === "object") {
63+
// Handle API response errors
64+
if ("message" in error && typeof error.message === "string") {
65+
errorMessage = error.message;
66+
} else if ("error" in error && typeof error.error === "string") {
67+
errorMessage = error.error;
68+
}
69+
}
70+
71+
handleError(errorMessage);
72+
}

client/dashboard/src/main.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { StrictMode } from "react";
22
import { createRoot } from "react-dom/client";
33
import { Toaster } from "@/components/ui/sonner";
4-
import { ErrorHandlerProvider } from "@/contexts/ErrorHandler";
54
import App from "./App.tsx";
65

76
createRoot(document.getElementById("root")!).render(
@@ -10,9 +9,7 @@ createRoot(document.getElementById("root")!).render(
109
@import
1110
url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Mona+Sans:ital,wght@0,200..900;1,200..900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap');
1211
</style>
13-
<ErrorHandlerProvider>
14-
<App />
15-
<Toaster />
16-
</ErrorHandlerProvider>
12+
<App />
13+
<Toaster />
1714
</StrictMode>,
1815
);

client/dashboard/src/pages/environments/Environments.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { Card, Cards } from "@/components/ui/card";
55
import { UpdatedAt } from "@/components/updated-at";
66
import { useSession } from "@/contexts/Auth";
77
import { useTelemetry } from "@/contexts/Telemetry";
8-
import { useApiError } from "@/hooks/useApiError";
98
import { useRoutes } from "@/routes";
109
import { Environment } from "@gram/client/models/components/environment.js";
1110
import {
@@ -16,6 +15,7 @@ import { Plus } from "lucide-react";
1615
import { useState } from "react";
1716
import { Outlet } from "react-router";
1817
import { Button } from "@speakeasy-api/moonshine";
18+
import { handleAPIError } from "@/lib/errors";
1919
export function EnvironmentsRoot() {
2020
return <Outlet />;
2121
}
@@ -35,7 +35,6 @@ export default function Environments() {
3535
const session = useSession();
3636
const routes = useRoutes();
3737
const telemetry = useTelemetry();
38-
const { handleApiError } = useApiError();
3938

4039
const [createEnvironmentDialogOpen, setCreateEnvironmentDialogOpen] =
4140
useState(false);
@@ -50,7 +49,7 @@ export default function Environments() {
5049
routes.environments.environment.goTo(data.slug);
5150
},
5251
onError: (error) => {
53-
handleApiError(error, "Failed to create environment");
52+
handleAPIError(error, "Failed to create environment");
5453
telemetry.capture("environment_event", {
5554
action: "environment_creation_failed",
5655
error: error.message,

0 commit comments

Comments
 (0)