Skip to content

Commit 5d99457

Browse files
authored
fix(webapp): stop hydration errors on the Tasks page (#4058)
The Tasks page logged a burst of React hydration errors (#421, "this Suspense boundary received an update before it finished hydrating") on every load, one per task row for the Running and Activity cells. The page still worked, but it spammed the console. The Running and Activity (24h) cells stream in via Remix `defer()` + `<Suspense>`/`<Await>`, two boundaries per row. A streamed boundary stays in React's "hydrating" state until its data arrives; if the backing queries are slow enough that the data is still in flight after the page loads, a normal background re-render (a server-sent-events update, a panel layout effect, a revalidation) hits the boundary and React bails it to client rendering and throws #421. With N rows that is 2N errors. It never reproduced locally because those queries return instantly there. Fix: wrap the two cells in `ClientOnly` so they mount after hydration. The stats still load asynchronously (the task list renders immediately), but there is no longer an SSR boundary to bail. In the slow-query case those cells already client-rendered (that was the bail); this just makes it explicit and silent. Verified by simulating slow stat queries against a local build: the errors go from 2-per-row to zero, and the cells render correctly once the data resolves.
1 parent c430195 commit 5d99457

2 files changed

Lines changed: 34 additions & 17 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+
Stop the Tasks page from logging React hydration errors for the per-row running and activity stats.

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx‎

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/ser
55
import type { TaskRunStatus } from "@trigger.dev/database";
66
import type { PanelHandle } from "@window-splitter/react";
77
import { Fragment, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
8+
import { ClientOnly } from "remix-utils/client-only";
89
import { Bar, BarChart, ReferenceLine, Tooltip, type TooltipProps, YAxis } from "recharts";
910
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
1011
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
@@ -442,27 +443,37 @@ function TaskRow({
442443
<TaskFileName fileName={item.filePath} variant="extra-extra-small" />
443444
</TableCell>
444445
<TableCell to={rowPath}>
445-
<Suspense fallback={<Spinner color="blue" className="size-3" />}>
446-
<TypedAwait resolve={runningStates} errorElement={<FailedToLoadStats />}>
447-
{(data) => <RunningCell state={data[item.slug]} />}
448-
</TypedAwait>
449-
</Suspense>
446+
{/* Render the deferred stats client-side. A streamed Suspense boundary still pending at
447+
hydration otherwise bails to client rendering and throws React #421. */}
448+
<ClientOnly fallback={<Spinner color="blue" className="size-3" />}>
449+
{() => (
450+
<Suspense fallback={<Spinner color="blue" className="size-3" />}>
451+
<TypedAwait resolve={runningStates} errorElement={<FailedToLoadStats />}>
452+
{(data) => <RunningCell state={data[item.slug]} />}
453+
</TypedAwait>
454+
</Suspense>
455+
)}
456+
</ClientOnly>
450457
</TableCell>
451458
<TableCell to={rowPath} actionClassName="py-1.5">
452459
<div style={{ width: ACTIVITY_CELL_WIDTH, height: ACTIVITY_CHART_HEIGHT }}>
453460
<div hidden={isPanelAnimating}>
454-
<Suspense fallback={<TaskActivityBlankState />}>
455-
<TypedAwait resolve={hourlyActivity} errorElement={<FailedToLoadStats />}>
456-
{(data) => {
457-
const taskData = data[item.slug];
458-
return taskData && taskData.length > 0 ? (
459-
<TaskActivityGraph activity={taskData} />
460-
) : (
461-
<TaskActivityBlankState />
462-
);
463-
}}
464-
</TypedAwait>
465-
</Suspense>
461+
<ClientOnly fallback={<TaskActivityBlankState />}>
462+
{() => (
463+
<Suspense fallback={<TaskActivityBlankState />}>
464+
<TypedAwait resolve={hourlyActivity} errorElement={<FailedToLoadStats />}>
465+
{(data) => {
466+
const taskData = data[item.slug];
467+
return taskData && taskData.length > 0 ? (
468+
<TaskActivityGraph activity={taskData} />
469+
) : (
470+
<TaskActivityBlankState />
471+
);
472+
}}
473+
</TypedAwait>
474+
</Suspense>
475+
)}
476+
</ClientOnly>
466477
</div>
467478
</div>
468479
</TableCell>

0 commit comments

Comments
 (0)