Skip to content

Commit 731372b

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
fix(webapp): keep dashboard visible during network interruptions
Keep the dashboard and its last-loaded data visible during temporary network interruptions instead of replacing it with a full-page error. Show a disconnected banner with a Refresh button and retry failed loader reads automatically. Only same-origin GET loader requests are retried. Mutations, server errors, and canceled requests retain their existing behavior. Refresh revalidates page data without reloading the document. Mono-RevId: e1dfee3bf53edcae410ee0b482ff127345ec802c
1 parent 0ba0541 commit 731372b

9 files changed

Lines changed: 535 additions & 75 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Keep dashboard pages visible during network interruptions, with a disconnected banner and a Refresh button instead of a full-page error.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { ArrowPathIcon } from "@heroicons/react/20/solid";
2+
import { useRevalidator } from "@remix-run/react";
3+
import { useLoaderDisconnected } from "~/hooks/useLoaderDisconnected";
4+
import { Button } from "~/components/primitives/Buttons";
5+
6+
export function DisconnectedBanner() {
7+
const revalidator = useRevalidator();
8+
const disconnected = useLoaderDisconnected();
9+
10+
if (!disconnected) return null;
11+
12+
return (
13+
<div
14+
role="status"
15+
className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded border border-warning/30 bg-background-bright px-4 py-2 text-sm text-text-bright shadow-lg"
16+
>
17+
<span>Connection lost. Your data may be out of date.</span>
18+
<Button
19+
type="button"
20+
variant="minimal/small"
21+
LeadingIcon={ArrowPathIcon}
22+
aria-label="Refresh"
23+
tooltip="Refresh"
24+
onClick={() => revalidator.revalidate()}
25+
/>
26+
</div>
27+
);
28+
}

‎apps/webapp/app/components/runs/v3/TaskRunsTable.tsx‎

Lines changed: 8 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
type NextRunListItem,
3333
} from "~/presenters/v3/NextRunListPresenter.server";
3434
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
35-
import { docsPath, v3RunSpanPath, v3TestPath, v3TestTaskPath } from "~/utils/pathBuilder";
35+
import { docsPath, v3RunSpanPath } from "~/utils/pathBuilder";
3636
import { DateTime } from "../../primitives/DateTime";
3737
import { Paragraph } from "../../primitives/Paragraph";
3838
import { Spinner } from "../../primitives/Spinner";
@@ -62,7 +62,8 @@ import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
6262
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
6363
import { useSearchParams } from "~/hooks/useSearchParam";
6464
import type { TaskTriggerSource } from "@trigger.dev/database";
65-
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
65+
import { BlankState, NoRuns } from "./TaskRunsTableBlankState";
66+
import { useLoaderDisconnected } from "~/hooks/useLoaderDisconnected";
6667
import { SmartColumnIcon } from "~/assets/icons/SmartColumnIcon";
6768
import {
6869
parseColumnParams,
@@ -604,6 +605,8 @@ export function TaskRunsTable({
604605
canCancelRuns = true,
605606
canReplayRuns = true,
606607
}: RunsTableProps) {
608+
const disconnected = useLoaderDisconnected();
609+
const showLoading = isLoading && !disconnected;
607610
const regions = useRegions();
608611
const regionByMasterQueue = new Map(regions.map((r) => [r.masterQueue, r] as const));
609612
const organization = useOrganization();
@@ -727,10 +730,10 @@ export function TaskRunsTable({
727730
<TableBody>
728731
{total === 0 && !hasFilters ? (
729732
<TableBlankRow colSpan={totalColSpan}>
730-
{!isLoading && <NoRuns title="No runs found" />}
733+
{!showLoading && <NoRuns title="No runs found" />}
731734
</TableBlankRow>
732735
) : runs.length === 0 ? (
733-
<BlankState isLoading={isLoading} filters={filters} colSpan={totalColSpan} />
736+
<BlankState isLoading={showLoading} filters={filters} colSpan={totalColSpan} />
734737
) : (
735738
runs.map((run, index) => {
736739
const searchParams = new URLSearchParams();
@@ -781,7 +784,7 @@ export function TaskRunsTable({
781784
);
782785
})
783786
)}
784-
{isLoading && (
787+
{showLoading && (
785788
<TableBlankRow
786789
colSpan={totalColSpan}
787790
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-background-dimmed"
@@ -948,73 +951,3 @@ function RunActionsCell({
948951
/>
949952
);
950953
}
951-
952-
function NoRuns({ title }: { title: string }) {
953-
return (
954-
<div className="flex items-center justify-center">
955-
<Paragraph className="w-auto">{title}</Paragraph>
956-
</div>
957-
);
958-
}
959-
960-
function BlankState({
961-
isLoading,
962-
filters,
963-
colSpan,
964-
}: Pick<RunsTableProps, "isLoading" | "filters"> & { colSpan: number }) {
965-
const organization = useOrganization();
966-
const project = useProject();
967-
const environment = useEnvironment();
968-
if (isLoading) return <TableBlankRow colSpan={colSpan} />;
969-
970-
const { tasks, from, to, ...otherFilters } = filters;
971-
const singleTaskFromFilters = filters.tasks.length === 1 ? filters.tasks[0] : null;
972-
const testPath = singleTaskFromFilters
973-
? v3TestTaskPath(organization, project, environment, { taskIdentifier: singleTaskFromFilters })
974-
: v3TestPath(organization, project, environment);
975-
976-
if (
977-
filters.tasks.length === 1 &&
978-
filters.from === undefined &&
979-
filters.to === undefined &&
980-
Object.values(otherFilters).every((filterArray) => filterArray.length === 0)
981-
) {
982-
return (
983-
<TableBlankRow colSpan={colSpan}>
984-
<Paragraph className="w-auto" variant="base/bright" spacing>
985-
There are no runs for {filters.tasks[0]}
986-
</Paragraph>
987-
</TableBlankRow>
988-
);
989-
}
990-
991-
return (
992-
<TableBlankRow colSpan={colSpan}>
993-
<div className="flex flex-col items-center justify-center gap-6">
994-
<Paragraph className="w-auto" variant="base/bright">
995-
No runs match your filters. Try refreshing, modifying your filters or run a test.
996-
</Paragraph>
997-
<div className="flex items-center gap-2">
998-
<Button
999-
LeadingIcon={ArrowPathIcon}
1000-
variant="secondary/medium"
1001-
onClick={() => {
1002-
window.location.reload();
1003-
}}
1004-
>
1005-
Refresh
1006-
</Button>
1007-
<Paragraph>or</Paragraph>
1008-
<LinkButton
1009-
LeadingIcon={BeakerIcon}
1010-
leadingIconClassName="text-tests"
1011-
variant="secondary/medium"
1012-
to={testPath}
1013-
>
1014-
Run a test
1015-
</LinkButton>
1016-
</div>
1017-
</div>
1018-
</TableBlankRow>
1019-
);
1020-
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { ArrowPathIcon } from "@heroicons/react/20/solid";
2+
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
3+
import { Button, LinkButton } from "~/components/primitives/Buttons";
4+
import { Paragraph } from "~/components/primitives/Paragraph";
5+
import { TableBlankRow } from "~/components/primitives/Table";
6+
import { useEnvironment } from "~/hooks/useEnvironment";
7+
import { useOrganization } from "~/hooks/useOrganizations";
8+
import { useProject } from "~/hooks/useProject";
9+
import type { NextRunListAppliedFilters } from "~/presenters/v3/NextRunListPresenter.server";
10+
import { v3TestPath, v3TestTaskPath } from "~/utils/pathBuilder";
11+
12+
export function NoRuns({ title }: { title: string }) {
13+
return (
14+
<div className="flex items-center justify-center">
15+
<Paragraph className="w-auto">{title}</Paragraph>
16+
</div>
17+
);
18+
}
19+
20+
export function BlankState({
21+
isLoading,
22+
filters,
23+
colSpan,
24+
}: {
25+
isLoading?: boolean;
26+
filters: NextRunListAppliedFilters;
27+
colSpan: number;
28+
}) {
29+
const organization = useOrganization();
30+
const project = useProject();
31+
const environment = useEnvironment();
32+
if (isLoading) return <TableBlankRow colSpan={colSpan} />;
33+
34+
const { tasks, from, to, ...otherFilters } = filters;
35+
const singleTaskFromFilters = filters.tasks.length === 1 ? filters.tasks[0] : null;
36+
const testPath = singleTaskFromFilters
37+
? v3TestTaskPath(organization, project, environment, { taskIdentifier: singleTaskFromFilters })
38+
: v3TestPath(organization, project, environment);
39+
40+
if (
41+
filters.tasks.length === 1 &&
42+
filters.from === undefined &&
43+
filters.to === undefined &&
44+
Object.values(otherFilters).every((filterArray) => filterArray.length === 0)
45+
) {
46+
return (
47+
<TableBlankRow colSpan={colSpan}>
48+
<Paragraph className="w-auto" variant="base/bright" spacing>
49+
There are no runs for {filters.tasks[0]}
50+
</Paragraph>
51+
</TableBlankRow>
52+
);
53+
}
54+
55+
return (
56+
<TableBlankRow colSpan={colSpan}>
57+
<div className="flex flex-col items-center justify-center gap-6">
58+
<Paragraph className="w-auto" variant="base/bright">
59+
No runs match your filters. Try refreshing, modifying your filters or run a test.
60+
</Paragraph>
61+
<div className="flex items-center gap-2">
62+
<Button
63+
LeadingIcon={ArrowPathIcon}
64+
variant="secondary/medium"
65+
onClick={() => {
66+
window.location.reload();
67+
}}
68+
>
69+
Refresh
70+
</Button>
71+
<Paragraph>or</Paragraph>
72+
<LinkButton
73+
LeadingIcon={BeakerIcon}
74+
leadingIconClassName="text-tests"
75+
variant="secondary/medium"
76+
to={testPath}
77+
>
78+
Run a test
79+
</LinkButton>
80+
</div>
81+
</div>
82+
</TableBlankRow>
83+
);
84+
}

‎apps/webapp/app/entry.client.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { RemixBrowser } from "@remix-run/react";
22
import { hydrateRoot } from "react-dom/client";
3+
import { installLoaderConnectionRecovery } from "./utils/loaderConnection";
34
import { clientBeforeFirstRender } from "./clientBeforeFirstRender";
45
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
56
import { OperatingSystemContextProvider } from "./components/primitives/OperatingSystemProvider";
67

8+
installLoaderConnectionRecovery();
79
clientBeforeFirstRender();
810

911
hydrateRoot(
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { useSyncExternalStore } from "react";
2+
import { isLoaderDisconnected, subscribeToLoaderConnection } from "~/utils/loaderConnection";
3+
4+
export function useLoaderDisconnected() {
5+
return useSyncExternalStore(subscribeToLoaderConnection, isLoaderDisconnected, () => false);
6+
}

‎apps/webapp/app/root.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import "non.geist";
1111
import "non.geist/mono";
1212
import tailwindStylesheetUrl from "~/tailwind.css?url";
1313
import { RouteErrorDisplay } from "./components/ErrorDisplay";
14+
import { DisconnectedBanner } from "./components/DisconnectedBanner";
1415
import { GlobalShortcuts } from "./components/GlobalShortcuts";
1516
import { StaleAssetRecovery } from "./components/StaleAssetRecovery";
1617
import { AppContainer, MainCenteredContainer } from "./components/layout/AppLayout";
@@ -236,6 +237,7 @@ export default function App() {
236237
<TimezoneSetter />
237238
<GlobalShortcuts />
238239
<Outlet />
240+
<DisconnectedBanner />
239241
<Toast />
240242
</ShortcutsProvider>
241243
<ScrollRestoration />

0 commit comments

Comments
 (0)