Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function HelpAndFeedback({
button={
<PopoverTrigger
className={cn(
"group flex h-8 items-center gap-1.5 rounded pl-[0.4375rem] pr-2 transition-colors hover:bg-charcoal-750",
"group flex h-8 items-center gap-1.5 rounded pl-[0.4375rem] pr-2 transition-colors hover:bg-charcoal-750 focus-custom",
isCollapsed ? "w-full" : "w-full justify-between"
)}
>
Expand Down
121 changes: 62 additions & 59 deletions apps/webapp/app/routes/resources.incidents.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,87 @@
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { json } from "@remix-run/node";
import { useFetcher } from "@remix-run/react";
import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react";
import { motion } from "framer-motion";
import { useCallback, useEffect } from "react";
import { useEffect, useRef } from "react";
import { LinkButton } from "~/components/primitives/Buttons";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useFeatures } from "~/hooks/useFeatures";
import { BetterStackClient } from "~/services/betterstack/betterstack.server";
import { BetterStackClient, type AggregateState } from "~/services/betterstack/betterstack.server";

// Prevent Remix from revalidating this route when other fetchers submit
export const shouldRevalidate: ShouldRevalidateFunction = () => false;

export type IncidentLoaderData = {
status: AggregateState;
title: string | null;
};

export async function loader() {
const client = new BetterStackClient();
const result = await client.getIncidents();
const result = await client.getIncidentStatus();

if (!result.success) {
return json({ operational: true });
return json<IncidentLoaderData>({ status: "operational", title: null });
}

return json({
operational: result.data.attributes.aggregate_state === "operational",
return json<IncidentLoaderData>({
status: result.data.status,
title: result.data.title,
});
}

export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boolean }) {
const DEFAULT_MESSAGE =
"Our team is working on resolving the issue. Check our status page for more information.";

const POLL_INTERVAL_MS = 60_000;

/** Hook to fetch and poll incident status */
export function useIncidentStatus() {
const { isManagedCloud } = useFeatures();
const fetcher = useFetcher<typeof loader>();

const fetchIncidents = useCallback(() => {
if (fetcher.state === "idle") {
fetcher.load("/resources/incidents");
}
}, []);
const hasInitiallyFetched = useRef(false);

useEffect(() => {
if (!isManagedCloud) return;

fetchIncidents();
// Initial fetch on mount
if (!hasInitiallyFetched.current && fetcher.state === "idle") {
hasInitiallyFetched.current = true;
fetcher.load("/resources/incidents");
}

const interval = setInterval(fetchIncidents, 60 * 1000); // 1 minute
// Poll every 60 seconds
const interval = setInterval(() => {
if (fetcher.state === "idle") {
fetcher.load("/resources/incidents");
}
}, POLL_INTERVAL_MS);
Comment on lines +56 to +60

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Stale closure over fetcher.state in polling interval bypasses idle-guard

The fetcher.state === "idle" check inside the setInterval callback reads a stale value, making the guard ineffective.

Root Cause

The useEffect at resources.incidents.tsx:46 has a dependency array of [isManagedCloud], but references fetcher.state and fetcher.load inside the interval callback. In Remix 2.x, useFetcher() returns a new object on each render with updated state/data values. Because the effect never re-runs after mount (unless isManagedCloud changes), the fetcher captured in the setInterval closure is the one from the initial render — where fetcher.state is permanently "idle".

This means the guard if (fetcher.state === "idle") on line 57 always evaluates to true, so fetcher.load is called every 60 seconds regardless of whether a previous request is still in flight. If a request happens to be in progress when the interval fires, calling load() again will abort the in-flight request and start a new one.

The practical impact is limited because the 60-second interval is far longer than typical API response times, but the guard is functionally broken and could cause unnecessary request churn under slow network conditions.

Fix: Store fetcher (or fetcher.state) in a useRef that is updated each render, and read from the ref inside the interval callback. For example:

const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;

useEffect(() => {
  // ...
  const interval = setInterval(() => {
    if (fetcherRef.current.state === "idle") {
      fetcherRef.current.load("/resources/incidents");
    }
  }, POLL_INTERVAL_MS);
  return () => clearInterval(interval);
}, [isManagedCloud]);
Prompt for agents
In apps/webapp/app/routes/resources.incidents.tsx, the useIncidentStatus hook captures a stale fetcher object inside the setInterval closure because useEffect depends only on [isManagedCloud]. To fix this:

1. Add a useRef to track the latest fetcher object. After the existing line 44 (const hasInitiallyFetched = useRef(false)), add:
   const fetcherRef = useRef(fetcher);

2. Before the useEffect (between line 44 and line 46), add a line to keep the ref updated on every render:
   fetcherRef.current = fetcher;

3. Inside the useEffect, change the setInterval callback (lines 56-59) to use fetcherRef.current instead of fetcher:
   const interval = setInterval(() => {
     if (fetcherRef.current.state === "idle") {
       fetcherRef.current.load("/resources/incidents");
     }
   }, POLL_INTERVAL_MS);

4. Similarly update the initial fetch block (lines 50-53) to use fetcherRef.current:
   if (!hasInitiallyFetched.current && fetcherRef.current.state === "idle") {
     hasInitiallyFetched.current = true;
     fetcherRef.current.load("/resources/incidents");
   }
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


return () => clearInterval(interval);
}, [isManagedCloud, fetchIncidents]);
}, [isManagedCloud]);

return {
status: fetcher.data?.status ?? "operational",
title: fetcher.data?.title ?? null,
hasIncident: (fetcher.data?.status ?? "operational") !== "operational",
isManagedCloud,
};
}

const operational = fetcher.data?.operational ?? true;
export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boolean }) {
const { title, hasIncident, isManagedCloud } = useIncidentStatus();

if (!isManagedCloud || operational) {
if (!isManagedCloud || !hasIncident) {
return null;
}

const message = title || DEFAULT_MESSAGE;

return (
<Popover>
<div className="p-1">
{/* Expanded panel - animated height and opacity */}
<motion.div
initial={false}
animate={{
Expand All @@ -62,35 +91,9 @@ export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boo
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="flex flex-col gap-2 rounded border border-warning/20 bg-warning/5 p-2 pt-1.5">
{/* Header */}
<div className="flex items-center gap-1 border-b border-warning/20 pb-1 text-warning">
<ExclamationTriangleIcon className="size-4" />
<Paragraph variant="small/bright" className="text-warning">
Active incident
</Paragraph>
</div>

{/* Description */}
<Paragraph variant="extra-small/bright" className="text-warning/80">
Our team is working on resolving the issue. Check our status page for more
information.
</Paragraph>

{/* Button */}
<LinkButton
variant="secondary/small"
to="https://status.trigger.dev"
target="_blank"
fullWidth
className="border-warning/20 bg-warning/10 hover:!border-warning/30 hover:!bg-warning/20"
>
<span className="text-warning">View status page</span>
</LinkButton>
</div>
<IncidentPanelContent message={message} />
</motion.div>

{/* Collapsed button - animated height and opacity */}
<motion.div
initial={false}
animate={{
Expand All @@ -102,8 +105,8 @@ export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boo
>
<SimpleTooltip
button={
<PopoverTrigger className="flex !h-8 w-full items-center justify-center rounded border border-warning/20 bg-warning/10 transition-colors hover:border-warning/30 hover:bg-warning/20">
<ExclamationTriangleIcon className="size-5 text-warning" />
<PopoverTrigger className="flex !h-8 w-full items-center justify-center rounded border border-yellow-500/30 bg-yellow-500/15 transition-colors hover:border-yellow-500/50 hover:bg-yellow-500/25">
<ExclamationTriangleIcon className="size-5 text-yellow-400" />
</PopoverTrigger>
}
content="Active incident"
Expand All @@ -115,32 +118,32 @@ export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boo
</motion.div>
</div>
<PopoverContent side="right" sideOffset={8} align="start" className="!min-w-0 w-52 p-0">
<IncidentPopoverContent />
<IncidentPanelContent message={message} />
</PopoverContent>
</Popover>
);
}

function IncidentPopoverContent() {
function IncidentPanelContent({ message }: { message: string }) {
return (
<div className="flex flex-col gap-2 rounded border border-warning/20 bg-warning/5 p-2 pt-1.5">
<div className="flex items-center gap-1 border-b border-warning/20 pb-1 text-warning">
<ExclamationTriangleIcon className="size-4" />
<Paragraph variant="small/bright" className="text-warning">
<div className="flex flex-col gap-2 rounded border border-yellow-500/30 bg-yellow-500/10 p-2 pt-1.5">
<div className="flex items-center gap-1 border-b border-yellow-500/30 pb-1">
<ExclamationTriangleIcon className="size-4 text-yellow-400" />
<Paragraph variant="small/bright" className="text-yellow-300">
Active incident
</Paragraph>
</div>
<Paragraph variant="extra-small/bright" className="text-warning/80">
Our team is working on resolving the issue. Check our status page for more information.
<Paragraph variant="extra-small/bright" className="text-yellow-300">
{message}
</Paragraph>
<LinkButton
variant="secondary/small"
to="https://status.trigger.dev"
target="_blank"
fullWidth
className="border-warning/20 bg-warning/10 hover:!border-warning/30 hover:!bg-warning/20"
className="border-yellow-500/30 bg-yellow-500/15 hover:!border-yellow-500/50 hover:!bg-yellow-500/25"
>
<span className="text-warning">View status page</span>
<span className="text-yellow-300">View status page</span>
</LinkButton>
</div>
);
Expand Down
Loading