chore: enforce exhaustive React hook dependencies

This commit is contained in:
Chris Arderne
2026-08-19 17:09:18 +01:00
parent 23c5619dd1
commit 43cca6740d
72 changed files with 878 additions and 771 deletions
+1 -1
View File
@@ -45,7 +45,7 @@
"typescript/consistent-type-imports": "error",
"import/no-duplicates": "error",
"import/namespace": "off",
"react/exhaustive-deps": "off",
"react/exhaustive-deps": "error",
"react/rules-of-hooks": "off",
"guard-for-in": "error",
"symbol-description": "error",
@@ -12,7 +12,7 @@ export function AnimatedHourglassIcon({
const [scope, animate] = useAnimate();
useEffect(() => {
animate(
const controls = animate(
[
[scope.current, { rotate: 0 }, { duration: 0.7 }],
[scope.current, { rotate: 180 }, { duration: 0.3 }],
@@ -21,7 +21,9 @@ export function AnimatedHourglassIcon({
],
{ repeat: Infinity, delay }
);
}, []);
return () => controls.stop();
}, [animate, delay, scope]);
return <HourglassIcon ref={scope} className={className} />;
}
+1 -1
View File
@@ -76,7 +76,7 @@ function useAskAIState() {
next.delete(ASK_AI_DEEP_LINK_PARAM);
setSearchParams(next);
}
}, [searchParams, openAskAI]);
}, [searchParams, setSearchParams, openAskAI]);
return { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI };
}
+1 -1
View File
@@ -80,7 +80,7 @@ export function DevPresenceProvider({ children, enabled = true }: DevPresencePro
// Calculate isConnected and memoize the context value
const contextValue = useMemo(() => {
return { isConnected };
}, [isConnected, enabled]);
}, [isConnected]);
return <DevPresenceContext.Provider value={contextValue}>{children}</DevPresenceContext.Provider>;
}
+2 -2
View File
@@ -70,7 +70,7 @@ export function Feedback({
) {
setOpen(false);
}
}, [navigation.formAction, navigation.state, form.allErrors]);
}, [navigation.formAction, navigation.state, form.allErrors, setOpen]);
// Handle URL param functionality
useEffect(() => {
@@ -83,7 +83,7 @@ export function Feedback({
next.delete("feedbackPanel");
setSearchParams(next);
}
}, [searchParams]);
}, [searchParams, setOpen, setSearchParams]);
// Reset the topic to the default once the dialog closes, so reopening always starts fresh. The
// dialog is now persistently mounted (hosted outside the popover), so without this it would keep
@@ -1,5 +1,5 @@
import { useFetcher } from "@remix-run/react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import stableStringify from "json-stable-stringify";
import {
Dialog,
@@ -54,6 +54,9 @@ export function FeatureFlagsDialog({
}: FeatureFlagsDialogProps) {
const loadFetcher = useFetcher<LoaderData>();
const saveFetcher = useFetcher<ActionData>();
const loadFeatureFlags = loadFetcher.load;
const onOpenChangeRef = useRef(onOpenChange);
onOpenChangeRef.current = onOpenChange;
const [overrides, setOverrides] = useState<Record<string, unknown>>({});
const [initialOverrides, setInitialOverrides] = useState<Record<string, unknown>>({});
@@ -67,9 +70,9 @@ export function FeatureFlagsDialog({
setSaveError(null);
setOverrides({});
setInitialOverrides({});
loadFetcher.load(`/admin/api/v2/orgs/${orgId}/feature-flags`);
loadFeatureFlags(`/admin/api/v2/orgs/${orgId}/feature-flags`);
}
}, [open, orgId]);
}, [loadFeatureFlags, open, orgId]);
useEffect(() => {
if (loadFetcher.data) {
@@ -81,7 +84,7 @@ export function FeatureFlagsDialog({
useEffect(() => {
if (saveFetcher.data?.success) {
onOpenChange(false);
onOpenChangeRef.current(false);
} else if (saveFetcher.data?.error) {
setSaveError(saveFetcher.data.error);
}
@@ -45,10 +45,11 @@ function DebugRunDialog({ friendlyId }: { friendlyId: string }) {
function DebugRunContent({ friendlyId }: { friendlyId: string }) {
const fetcher = useTypedFetcher<typeof loader>();
const isLoading = fetcher.state === "loading";
const load = fetcher.load;
useEffect(() => {
fetcher.load(`/resources/taskruns/${friendlyId}/debug`);
}, [friendlyId]);
load(`/resources/taskruns/${friendlyId}/debug`);
}, [friendlyId, load]);
return (
<>
@@ -65,6 +65,39 @@ export function AIQueryInput({
}
}, [mode, canEdit]);
const processStreamEvent = useCallback(
(event: StreamEventType) => {
switch (event.type) {
case "thinking":
setThinking((prev) => prev + event.content);
break;
case "tool_call":
// Tool calls are handled silently — no UI text needed
break;
case "time_filter":
// Apply time filter immediately when the AI sets it
onTimeFilterChange?.(event.filter);
break;
case "result":
if (event.success) {
// Apply time filter if included in result (backup in case time_filter event was missed)
if (event.timeFilter) {
onTimeFilterChange?.(event.timeFilter);
}
onQueryGenerated(event.query);
setPrompt("");
setLastResult("success");
// Keep thinking visible to show what happened
} else {
setError(event.error);
setLastResult("error");
}
break;
}
},
[onQueryGenerated, onTimeFilterChange]
);
const submitQuery = useCallback(
async (queryPrompt: string, submitMode: AIQueryMode = mode) => {
if (!queryPrompt.trim() || isLoading) return;
@@ -158,40 +191,7 @@ export function AIQueryInput({
setIsLoading(false);
}
},
[isLoading, resourcePath, mode, getCurrentQuery]
);
const processStreamEvent = useCallback(
(event: StreamEventType) => {
switch (event.type) {
case "thinking":
setThinking((prev) => prev + event.content);
break;
case "tool_call":
// Tool calls are handled silently — no UI text needed
break;
case "time_filter":
// Apply time filter immediately when the AI sets it
onTimeFilterChange?.(event.filter);
break;
case "result":
if (event.success) {
// Apply time filter if included in result (backup in case time_filter event was missed)
if (event.timeFilter) {
onTimeFilterChange?.(event.timeFilter);
}
onQueryGenerated(event.query);
setPrompt("");
setLastResult("success");
// Keep thinking visible to show what happened
} else {
setError(event.error);
setLastResult("error");
}
break;
}
},
[onQueryGenerated, onTimeFilterChange]
[getCurrentQuery, isLoading, mode, processStreamEvent, resourcePath]
);
const handleSubmit = useCallback(
@@ -281,7 +281,7 @@ export function DashboardAgent({
cancelled = true;
stop();
};
}, [hasAccess, watching, actionPath, setPanelOpen, openChat]);
}, [hasAccess, watching, actionPath, setPanelOpen, openChat, rememberToasted]);
// Zeroes the wake dot right away; the poll restores the truth if another chat has one. The
// work count is not touched here: the panel derives it from the chat list.
@@ -476,6 +476,7 @@ export function DashboardAgentPanel({
watchCard.requestId,
active?.chatId,
actionPath,
organization.id,
claimChatSlot,
loadHistory,
]);
@@ -46,7 +46,7 @@ import {
vercelResourcePath,
} from "~/utils/pathBuilder";
import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
import { useEffect, useState, useCallback, useRef } from "react";
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { usePostHogTracking } from "~/hooks/usePostHog";
import { TextLink } from "../primitives/TextLink";
@@ -126,9 +126,15 @@ export function VercelOnboardingModal({
const origin = searchParams.get("origin");
const fromMarketplaceContext = origin === "marketplace";
const availableProjects = onboardingData?.availableProjects || [];
const availableProjects = useMemo(
() => onboardingData?.availableProjects ?? [],
[onboardingData?.availableProjects]
);
const _hasProjectSelected = onboardingData?.hasProjectSelected ?? false;
const customEnvironments = onboardingData?.customEnvironments || [];
const customEnvironments = useMemo(
() => onboardingData?.customEnvironments ?? [],
[onboardingData?.customEnvironments]
);
const envVars = onboardingData?.environmentVariables || [];
const existingVars = onboardingData?.existingVariables || {};
const hasCustomEnvs = customEnvironments.length > 0 && hasStagingEnvironment;
@@ -42,60 +42,70 @@ export function NotificationPanel({
notifications: Notification[];
};
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
const dismissFetcher = useFetcher();
const { submit: submitDismiss } = useFetcher();
const seenIdsRef = useRef<Set<string>>(new Set());
const seenFetcher = useFetcher();
const { submit: submitSeen } = useFetcher();
const clickedIdsRef = useRef<Set<string>>(new Set());
const clickFetcher = useFetcher();
const { submit: submitClick } = useFetcher();
const visibleNotifications = notifications.filter((n) => !dismissedIds.has(n.id));
const notification = visibleNotifications[0] ?? null;
const notificationId = notification?.id;
const handleDismiss = useCallback((id: string) => {
setDismissedIds((prev) => new Set(prev).add(id));
const handleDismiss = useCallback(
(id: string) => {
setDismissedIds((prev) => new Set(prev).add(id));
dismissFetcher.submit(
{},
{
method: "POST",
action: `/resources/platform-notifications/${id}/dismiss`,
}
);
}, []);
submitDismiss(
{},
{
method: "POST",
action: `/resources/platform-notifications/${id}/dismiss`,
}
);
},
[submitDismiss]
);
const fireClickBeacon = useCallback((id: string) => {
if (clickedIdsRef.current.has(id)) return;
clickedIdsRef.current.add(id);
const fireClickBeacon = useCallback(
(id: string) => {
if (clickedIdsRef.current.has(id)) return;
clickedIdsRef.current.add(id);
clickFetcher.submit(
{},
{
method: "POST",
action: `/resources/platform-notifications/${id}/clicked`,
}
);
}, []);
submitClick(
{},
{
method: "POST",
action: `/resources/platform-notifications/${id}/clicked`,
}
);
},
[submitClick]
);
// Fire seen beacon
const fireSeenBeacon = useCallback((n: Notification) => {
if (seenIdsRef.current.has(n.id)) return;
seenIdsRef.current.add(n.id);
const fireSeenBeacon = useCallback(
(id: string) => {
if (seenIdsRef.current.has(id)) return;
seenIdsRef.current.add(id);
seenFetcher.submit(
{},
{
method: "POST",
action: `/resources/platform-notifications/${n.id}/seen`,
}
);
}, []);
submitSeen(
{},
{
method: "POST",
action: `/resources/platform-notifications/${id}/seen`,
}
);
},
[submitSeen]
);
// Beacon current notification on mount
useEffect(() => {
if (notification && !hasIncident) {
fireSeenBeacon(notification);
if (notificationId && !hasIncident) {
fireSeenBeacon(notificationId);
}
}, [notification?.id, hasIncident]);
}, [notificationId, hasIncident, fireSeenBeacon]);
if (!notification) {
return null;
@@ -1,5 +1,5 @@
import { useFetcher } from "@remix-run/react";
import { type Ref, useCallback, useEffect, useMemo, useState } from "react";
import { type Ref, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type Layout, useContainerWidth } from "react-grid-layout";
/**
@@ -33,9 +33,13 @@ export function useReorderableList<T>({
const orderFetcher = useFetcher();
const [order, setOrder] = useState<string[]>(() => initialOrder ?? items.map(itemKey));
const resetOrderRef = useRef({ initialOrder, items, itemKey });
resetOrderRef.current = { initialOrder, items, itemKey };
// Sync order when organizationId changes (component may not remount)
// Only an organization switch resets user-managed order. Keep the latest inputs in a ref so
// ordinary item or callback identity changes don't discard a drag reorder.
useEffect(() => {
const { initialOrder, items, itemKey } = resetOrderRef.current;
setOrder(initialOrder ?? items.map(itemKey));
}, [organizationId]);
@@ -288,6 +288,10 @@ export function AgentDotMatrix({
typeof palette === "string" ? DOT_MATRIX_PALETTES[palette] : palette;
const paletteKey = paletteObj.stops.join(",") + (paletteObj.glow ?? "");
const playlistKey = playlist.join(",");
const paletteObjRef = useRef(paletteObj);
const playlistRef = useRef(playlist);
paletteObjRef.current = paletteObj;
playlistRef.current = playlist;
useEffect(() => {
activeRef.current = active;
@@ -295,6 +299,10 @@ export function AgentDotMatrix({
}, [active]);
useEffect(() => {
// The serialized keys restart this animation when contents change; refs avoid restarting for
// equivalent array/object identities while still exposing the matching current values.
const paletteObj = paletteObjRef.current;
const playlist = playlistRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
@@ -65,7 +65,7 @@ export function AnimatedNumber({
duration,
ease: "easeInOut",
});
}, [value, duration]);
}, [motionValue, value, duration]);
return <motion.span>{display}</motion.span>;
}
@@ -80,12 +80,14 @@ export const CheckboxWithLabel = React.forwardRef<HTMLInputElement, CheckboxProp
disabled,
className,
labelClassName: externalLabelClassName,
onChange,
...props
},
ref
) => {
const [isChecked, setIsChecked] = useState<boolean>(defaultChecked ?? false);
const [isDisabled, setIsDisabled] = useState<boolean>(disabled ?? false);
const onChangeRef = React.useRef(onChange);
const generatedId = React.useId();
const inputId = id ?? generatedId;
const labelId = `${inputId}-label`;
@@ -105,9 +107,11 @@ export const CheckboxWithLabel = React.forwardRef<HTMLInputElement, CheckboxProp
}, [disabled]);
useEffect(() => {
if (props.onChange) {
props.onChange(isChecked);
}
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
onChangeRef.current?.(isChecked);
}, [isChecked]);
useEffect(() => {
@@ -80,20 +80,25 @@ export function DateField({
},
});
//if the passed in value changes, we should update the date
const stateValueRef = useRef(state.value);
stateValueRef.current = state.value;
// Sync only when the passed value or timezone mode changes. Depending on state.value directly
// would reset partially edited segments back to the default after every keystroke.
useEffect(() => {
if (state.value === undefined && defaultValue === undefined) return;
const stateValue = stateValueRef.current;
if (stateValue === undefined && defaultValue === undefined) return;
const calendarDate = utc
? utcDateToCalendarDate(defaultValue)
: dateToCalendarDate(defaultValue);
//unchanged
if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) {
// unchanged
if (stateValue?.toDate(utc ? "utc" : deviceTimezone).getTime() === defaultValue?.getTime()) {
return;
}
setValue(calendarDate);
}, [defaultValue]);
}, [defaultValue, utc]);
const ref = useRef<null | HTMLDivElement>(null);
const { labelProps: _labelProps, fieldProps } = useDateField(
@@ -243,12 +243,16 @@ const DateTimeAccurateInner = ({
const userTimeZone = useUserTimeZone();
// Use provided timeZone prop if available, otherwise fall back to user's preferred timezone
const displayTimeZone = timeZone ?? userTimeZone;
const realDate = typeof date === "string" ? new Date(date) : date;
const realPrevDate = previousDate
? typeof previousDate === "string"
? new Date(previousDate)
: previousDate
: null;
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
const realPrevDate = useMemo(
() =>
previousDate
? typeof previousDate === "string"
? new Date(previousDate)
: previousDate
: null,
[previousDate]
);
// Smart formatting based on whether date changed
const formattedDateTime = useMemo(() => {
@@ -259,7 +263,7 @@ const DateTimeAccurateInner = ({
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12);
}, [realDate, displayTimeZone, locales, hour12, hideDate, previousDate]);
}, [realDate, realPrevDate, displayTimeZone, locales, hour12, hideDate]);
if (!showTooltip)
return (
@@ -42,15 +42,15 @@ export function DurationPicker({
// Sync internal state with external value changes
useEffect(() => {
if (controlledValue !== undefined && controlledValue !== totalSeconds) {
const newHours = Math.floor(controlledValue / 3600);
const newMinutes = Math.floor((controlledValue % 3600) / 60);
const newSeconds = controlledValue % 60;
if (controlledValue === undefined) return;
setHours(newHours);
setMinutes(newMinutes);
setSeconds(newSeconds);
}
const newHours = Math.floor(controlledValue / 3600);
const newMinutes = Math.floor((controlledValue % 3600) / 60);
const newSeconds = controlledValue % 60;
setHours(newHours);
setMinutes(newMinutes);
setSeconds(newSeconds);
}, [controlledValue]);
useEffect(() => {
@@ -39,7 +39,7 @@ function AnimationDivider({ isLoading }: LoadingBarDividerProps) {
exitAnimation();
}
}, [isPresent, isLoading]);
}, [animate, isPresent, isLoading, safeToRemove, scope]);
return (
<AnimatePresence>
@@ -190,7 +190,7 @@ export function Select<TValue extends string | string[], TItem>({
}
return matchSorter(items, searchValue, filter);
}, [searchValue, items]);
}, [searchValue, items, filter]);
const enableItemShortcuts = allowItemShortcuts && matches.length === items?.length;
@@ -1,5 +1,5 @@
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { Fragment, createContext, useCallback, useContext, useRef, useState } from "react";
import { Fragment, createContext, useContext, useRef, useState } from "react";
import { inverseLerp, lerp } from "~/utils/lerp";
interface MousePosition {
@@ -11,26 +11,23 @@ function MousePositionProvider({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<MousePosition | undefined>(undefined);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!ref.current) {
setPosition(undefined);
return;
}
const handleMouseMove = (e: React.MouseEvent) => {
if (!ref.current) {
setPosition(undefined);
return;
}
const { top, left, width, height } = ref.current.getBoundingClientRect();
const x = (e.clientX - left) / width;
const y = (e.clientY - top) / height;
const { top, left, width, height } = ref.current.getBoundingClientRect();
const x = (e.clientX - left) / width;
const y = (e.clientY - top) / height;
if (x < 0 || x > 1 || y < 0 || y > 1) {
setPosition(undefined);
return;
}
if (x < 0 || x > 1 || y < 0 || y > 1) {
setPosition(undefined);
return;
}
setPosition({ x, y });
},
[ref.current]
);
setPosition({ x, y });
};
return (
<div
@@ -2,7 +2,7 @@ import type { VirtualItem, Virtualizer } from "@tanstack/react-virtual";
import { useVirtualizer } from "@tanstack/react-virtual";
import { motion } from "framer-motion";
import type { MutableRefObject, RefObject } from "react";
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
import { useEffect, useMemo, useReducer, useRef } from "react";
import { cn } from "~/utils/cn";
import type { NodeState, NodesState } from "./reducer";
import { reducer } from "./reducer";
@@ -43,7 +43,7 @@ export function TreeView<TData>({
if (autoFocus) {
parentRef?.current?.focus();
}
}, [autoFocus, parentRef?.current]);
}, [autoFocus, parentRef]);
const virtualItems = virtualizer.getVirtualItems();
@@ -57,21 +57,24 @@ export function TreeView<TData>({
return map;
}, [tree]);
const scrollCallback = useCallback(
(event: Event) => {
if (!onScroll) return;
const target = event.target as HTMLElement;
onScroll?.(target.scrollTop);
},
[onScroll]
);
const onScrollRef = useRef(onScroll);
useEffect(() => {
//subscribe to scrollRef scroll event
if (!scrollRef?.current || onScroll === undefined) return;
scrollRef.current.addEventListener("scroll", scrollCallback);
return () => scrollRef.current?.removeEventListener("scroll", scrollCallback);
}, [scrollRef?.current]);
onScrollRef.current = onScroll;
}, [onScroll]);
const hasOnScroll = onScroll !== undefined;
useEffect(() => {
const scrollElement = scrollRef?.current;
if (!scrollElement || !hasOnScroll) return;
const handleScroll = (event: Event) => {
const target = event.target as HTMLElement;
onScrollRef.current?.(target.scrollTop);
};
scrollElement.addEventListener("scroll", handleScroll);
return () => scrollElement.removeEventListener("scroll", handleScroll);
}, [hasOnScroll, scrollRef]);
return (
<motion.div
@@ -201,11 +204,16 @@ export function useTree<TData, TFilterValue>({
}: TreeStateHookProps<TData, TFilterValue>): UseTreeStateOutput {
const previousNodeCount = useRef(tree.length);
const previousSelectedId = useRef<string | undefined>(selectedId);
const previousExternalSelectedId = useRef(selectedId);
const onSelectedIdChangedRef = useRef(onSelectedIdChanged);
const latestTreeRef = useRef(tree);
latestTreeRef.current = tree;
const [state, dispatch] = useReducer(
reducer,
concreteStateFromInput({ tree, selectedId, collapsedIds, filter })
);
const currentSelectedId = selectedIdFromState(state.nodes);
// id -> index lookup so getNodeProps resolves in O(1) instead of scanning
// the whole tree per rendered row.
@@ -217,53 +225,56 @@ export function useTree<TData, TFilterValue>({
return map;
}, [tree]);
//sync external selectedId prop into internal state
// Sync external selectedId changes into internal state without turning the prop into
// a fully controlled value that immediately overrides internal keyboard selection.
useEffect(() => {
const internalSelectedId = selectedIdFromState(state.nodes);
if (selectedId !== internalSelectedId) {
if (selectedId === undefined) {
dispatch({ type: "DESELECT_ALL_NODES" });
} else {
dispatch({
type: "SELECT_NODE",
payload: { id: selectedId, scrollToNode: false, scrollToNodeFn },
});
}
if (selectedId === previousExternalSelectedId.current) return;
previousExternalSelectedId.current = selectedId;
if (selectedId === undefined) {
dispatch({ type: "DESELECT_ALL_NODES" });
} else {
dispatch({
type: "SELECT_NODE",
payload: { id: selectedId, scrollToNode: false, scrollToNodeFn: () => {} },
});
}
}, [selectedId]);
//fire onSelectedIdChanged()
useEffect(() => {
const selectedId = selectedIdFromState(state.nodes);
if (selectedId !== previousSelectedId.current) {
previousSelectedId.current = selectedId;
onSelectedIdChanged?.(selectedId);
}
}, [state.changes.selectedId]);
onSelectedIdChangedRef.current = onSelectedIdChanged;
}, [onSelectedIdChanged]);
//update tree when the number of nodes changes
// Fire onSelectedIdChanged() only when selection changes, not when the callback is recreated.
useEffect(() => {
if (tree.length !== previousNodeCount.current) {
previousNodeCount.current = tree.length;
dispatch({ type: "UPDATE_TREE", payload: { tree } });
if (currentSelectedId !== previousSelectedId.current) {
previousSelectedId.current = currentSelectedId;
onSelectedIdChangedRef.current?.(currentSelectedId);
}
}, [previousNodeCount.current, tree.length]);
}, [currentSelectedId]);
//update the filter, if it's changed
const previousFilter = useRef(filter);
const treeNodeCount = tree.length;
// Callers may recreate the tree array; preserve reducer state unless its shape changes.
useEffect(() => {
//check if the value (not reference) of the filter is the same
const previousValue = previousFilter.current
? JSON.stringify(previousFilter.current.value)
: undefined;
const newValue = filter ? JSON.stringify(filter.value) : undefined;
previousFilter.current = filter;
if (previousValue !== newValue) {
dispatch({ type: "UPDATE_FILTER", payload: { filter } });
if (treeNodeCount !== previousNodeCount.current) {
previousNodeCount.current = treeNodeCount;
dispatch({ type: "UPDATE_TREE", payload: { tree: latestTreeRef.current } });
}
}, [filter?.value]);
}, [treeNodeCount]);
const latestFilterRef = useRef(filter);
latestFilterRef.current = filter;
const serializedFilterValue = filter ? JSON.stringify(filter.value) : undefined;
const previousSerializedFilterValue = useRef(serializedFilterValue);
// Filter behavior is keyed by value; callers may recreate the filter function every render.
useEffect(() => {
if (serializedFilterValue === previousSerializedFilterValue.current) return;
previousSerializedFilterValue.current = serializedFilterValue;
dispatch({ type: "UPDATE_FILTER", payload: { filter: latestFilterRef.current } });
}, [serializedFilterValue]);
const virtualizer = useVirtualizer({
count: state.visibleNodeIds.length,
@@ -281,149 +292,98 @@ export function useTree<TData, TFilterValue>({
overscan: 50,
});
const scrollToNodeFn = useCallback(
(id: string) => {
const itemIndex = state.visibleNodeIds.findIndex((n) => n === id);
const scrollToNodeFn = (id: string) => {
const itemIndex = state.visibleNodeIds.findIndex((nodeId) => nodeId === id);
if (itemIndex !== -1) {
virtualizer.scrollToIndex(itemIndex, { align: "auto" });
}
},
[state]
);
if (itemIndex !== -1) {
virtualizer.scrollToIndex(itemIndex, { align: "auto" });
}
};
const selectNode = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "SELECT_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const selectNode = (id: string, scrollToNode = true) => {
dispatch({ type: "SELECT_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
};
const deselectNode = useCallback(
(id: string) => {
dispatch({ type: "DESELECT_NODE", payload: { id } });
},
[state]
);
const deselectNode = (id: string) => {
dispatch({ type: "DESELECT_NODE", payload: { id } });
};
const deselectAllNodes = useCallback(() => {
const deselectAllNodes = () => {
dispatch({ type: "DESELECT_ALL_NODES" });
}, [state]);
};
const toggleNodeSelection = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "TOGGLE_NODE_SELECTION", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const toggleNodeSelection = (id: string, scrollToNode = true) => {
dispatch({ type: "TOGGLE_NODE_SELECTION", payload: { id, scrollToNode, scrollToNodeFn } });
};
const expandNode = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const expandNode = (id: string, scrollToNode = true) => {
dispatch({ type: "EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
};
const collapseNode = useCallback(
(id: string) => {
dispatch({ type: "COLLAPSE_NODE", payload: { id } });
},
[state]
);
const collapseNode = (id: string) => {
dispatch({ type: "COLLAPSE_NODE", payload: { id } });
};
const toggleExpandNode = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "TOGGLE_EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const toggleExpandNode = (id: string, scrollToNode = true) => {
dispatch({ type: "TOGGLE_EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
};
const selectFirstVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_FIRST_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[tree, state]
);
const selectFirstVisibleNode = (scrollToNode = true) => {
dispatch({
type: "SELECT_FIRST_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
};
const selectLastVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_LAST_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[tree, state]
);
const selectLastVisibleNode = (scrollToNode = true) => {
dispatch({
type: "SELECT_LAST_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
};
const selectNextVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_NEXT_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[state]
);
const selectNextVisibleNode = (scrollToNode = true) => {
dispatch({
type: "SELECT_NEXT_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
};
const selectPreviousVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_PREVIOUS_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[state]
);
const selectPreviousVisibleNode = (scrollToNode = true) => {
dispatch({
type: "SELECT_PREVIOUS_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
};
const selectParentNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_PARENT_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[state]
);
const selectParentNode = (scrollToNode = true) => {
dispatch({
type: "SELECT_PARENT_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
};
const expandAllBelowDepth = useCallback(
(depth: number) => {
dispatch({ type: "EXPAND_ALL_BELOW_DEPTH", payload: { depth } });
},
[state]
);
const expandAllBelowDepth = (depth: number) => {
dispatch({ type: "EXPAND_ALL_BELOW_DEPTH", payload: { depth } });
};
const collapseAllBelowDepth = useCallback(
(depth: number) => {
dispatch({ type: "COLLAPSE_ALL_BELOW_DEPTH", payload: { depth } });
},
[state]
);
const collapseAllBelowDepth = (depth: number) => {
dispatch({ type: "COLLAPSE_ALL_BELOW_DEPTH", payload: { depth } });
};
const expandLevel = useCallback(
(level: number) => {
dispatch({ type: "EXPAND_LEVEL", payload: { level } });
},
[state]
);
const expandLevel = (level: number) => {
dispatch({ type: "EXPAND_LEVEL", payload: { level } });
};
const collapseLevel = useCallback(
(level: number) => {
dispatch({ type: "COLLAPSE_LEVEL", payload: { level } });
},
[state]
);
const collapseLevel = (level: number) => {
dispatch({ type: "COLLAPSE_LEVEL", payload: { level } });
};
const toggleExpandLevel = useCallback(
(level: number) => {
dispatch({ type: "TOGGLE_EXPAND_LEVEL", payload: { level } });
},
[state]
);
const toggleExpandLevel = (level: number) => {
dispatch({ type: "TOGGLE_EXPAND_LEVEL", payload: { level } });
};
const getTreeProps = useCallback(() => {
const getTreeProps = () => {
return {
role: "tree",
"aria-multiselectable": true,
@@ -514,26 +474,23 @@ export function useTree<TData, TFilterValue>({
}
},
};
}, [state]);
};
const getNodeProps = useCallback(
(id: string) => {
const node = state.nodes[id];
if (!node) return {};
const treeItemIndex = treeIndexById.get(id) ?? -1;
const treeItem = tree[treeItemIndex];
return {
"aria-expanded": node.expanded,
"aria-level": treeItem.level + 1,
role: "treeitem",
tabIndex: node.selected ? -1 : undefined,
};
},
[state, treeIndexById]
);
const getNodeProps = (id: string) => {
const node = state.nodes[id];
if (!node) return {};
const treeItemIndex = treeIndexById.get(id) ?? -1;
const treeItem = tree[treeItemIndex];
return {
"aria-expanded": node.expanded,
"aria-level": treeItem.level + 1,
role: "treeitem",
tabIndex: node.selected ? -1 : undefined,
};
};
return {
selected: selectedIdFromState(state.nodes),
selected: currentSelectedId,
nodes: state.nodes,
getTreeProps,
getNodeProps,
@@ -377,22 +377,25 @@ export function QueryEditor({
// Use defaultData as initial results, then switch to fetcher data once a query is run
const fetcherResults = fetcher.data;
const results =
fetcherResults ??
(defaultData
? {
error: null,
rows: defaultData.rows,
columns: defaultData.columns,
stats: null,
hiddenColumns: null,
reachedMaxRows: false,
explainOutput: null,
generatedSql: null,
queryId: null,
periodClipped: null,
}
: null);
const results = useMemo(
() =>
fetcherResults ??
(defaultData
? {
error: null,
rows: defaultData.rows,
columns: defaultData.columns,
stats: null,
hiddenColumns: null,
reachedMaxRows: false,
explainOutput: null,
generatedSql: null,
queryId: null,
periodClipped: null,
}
: null),
[defaultData, fetcherResults]
);
const organization = useOrganization();
const project = useProject();
@@ -1255,13 +1258,13 @@ function ResultsBigNumber({
accessory?: ReactNode;
}) {
// Auto-select first numeric column if none selected
const numericColumns = columns.filter((c) => isNumericColumnType(c.type));
const firstNumericColumn = columns.find((column) => isNumericColumnType(column.type));
useEffect(() => {
if (!bigNumberConfig.column && numericColumns.length > 0) {
onBigNumberConfigChange({ ...bigNumberConfig, column: numericColumns[0].name });
if (!bigNumberConfig.column && firstNumericColumn) {
onBigNumberConfigChange({ ...bigNumberConfig, column: firstNumericColumn.name });
}
}, [columns]);
}, [bigNumberConfig, firstNumericColumn, onBigNumberConfigChange]);
return (
<ResizablePanelGroup className="overflow-hidden">
@@ -53,7 +53,7 @@ export function AIFilterInput() {
inputRef.current.focus();
}
}
}, [fetcher.data, navigate]);
}, [fetcher.data, fetcher.state, navigate]);
const isLoading = fetcher.state === "submitting";
@@ -23,7 +23,7 @@ export function LiveTimer({
}, updateInterval);
return () => clearInterval(interval);
}, [startTime, endTime]);
}, [startTime, endTime, updateInterval]);
return (
<>
@@ -56,7 +56,7 @@ export function LiveCountdown({
}, updateInterval);
return () => clearInterval(interval);
}, [endTime]);
}, [endTime, updateInterval]);
return (
<>
@@ -54,8 +54,10 @@ export function ReplayRunDialog({ runFriendlyId, failedRedirect }: ReplayRunDial
function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) {
const replayDataFetcher = useTypedFetcher<typeof loader>();
const { load: loadReplayData } = replayDataFetcher;
const isLoading = replayDataFetcher.state === "loading";
const queueFetcher = useTypedFetcher<typeof queuesLoader>();
const { load: loadQueues } = queueFetcher;
const [environmentIdOverride, setEnvironmentIdOverride] = useState<string | undefined>(undefined);
@@ -65,34 +67,35 @@ function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps)
searchParams.set("environmentIdOverride", environmentIdOverride);
}
replayDataFetcher.load(
`/resources/taskruns/${runFriendlyId}/replay?${searchParams.toString()}`
);
}, [runFriendlyId, environmentIdOverride]);
loadReplayData(`/resources/taskruns/${runFriendlyId}/replay?${searchParams.toString()}`);
}, [environmentIdOverride, loadReplayData, runFriendlyId]);
const params = useParams();
const environmentOverrideSlug = environmentIdOverride
? replayDataFetcher.data?.environments.find((env) => env.id === environmentIdOverride)?.slug
: undefined;
useEffect(() => {
if (params.organizationSlug && params.projectParam && params.envParam) {
const searchParams = new URLSearchParams();
searchParams.set("type", "custom");
searchParams.set("per_page", "100");
let envSlug = params.envParam;
const envSlug = environmentOverrideSlug ?? params.envParam;
if (environmentIdOverride) {
const environmentOverride = replayDataFetcher.data?.environments.find(
(env) => env.id === environmentIdOverride
);
envSlug = environmentOverride?.slug ?? envSlug;
}
queueFetcher.load(
loadQueues(
`/resources/orgs/${params.organizationSlug}/projects/${
params.projectParam
}/env/${envSlug}/queues?${searchParams.toString()}`
);
}
}, [params.organizationSlug, params.projectParam, params.envParam, environmentIdOverride]);
}, [
environmentOverrideSlug,
loadQueues,
params.envParam,
params.organizationSlug,
params.projectParam,
]);
const customQueues = useMemo(() => {
return queueFetcher.data?.queues ?? [];
@@ -1012,11 +1012,14 @@ function TagsDropdown({
from: value("from"),
to: value("to"),
});
const fromTimestamp = from?.getTime();
const toTimestamp = to?.getTime();
const tagValues = values("tags").filter((v) => v !== "");
const selected = tagValues.length > 0 ? tagValues : undefined;
const fetcher = useFetcher<typeof tagsLoader>();
const { load } = fetcher;
useEffect(() => {
const searchParams = new URLSearchParams();
@@ -1026,14 +1029,14 @@ function TagsDropdown({
if (period) {
searchParams.set("period", period);
}
if (from) {
searchParams.set("from", from.getTime().toString());
if (fromTimestamp !== undefined) {
searchParams.set("from", fromTimestamp.toString());
}
if (to) {
searchParams.set("to", to.getTime().toString());
if (toTimestamp !== undefined) {
searchParams.set("to", toTimestamp.toString());
}
fetcher.load(`/resources/environments/${environment.id}/runs/tags?${searchParams}`);
}, [environment.id, searchValue, period, from?.getTime(), to?.getTime()]);
load(`/resources/environments/${environment.id}/runs/tags?${searchParams}`);
}, [environment.id, fromTimestamp, load, period, searchValue, toTimestamp]);
const filtered = useMemo(() => {
let items: string[] = [];
@@ -1171,32 +1174,31 @@ function QueuesDropdown({
250
);
const filtered = useMemo(() => {
let items: { name: string; type: "custom" | "task"; value: string }[] = [];
const items: { name: string; type: "custom" | "task"; value: string }[] = [];
for (const queueName of selected ?? []) {
const queueItem = fetcher.data?.queues.find((q) => q.name === queueName);
if (!queueItem) {
if (queueName.startsWith("task/")) {
items.push({
name: queueName.replace("task/", ""),
type: "task",
value: queueName,
});
} else {
items.push({
name: queueName,
type: "custom",
value: queueName,
});
}
for (const queueName of selected ?? []) {
const queueItem = fetcher.data?.queues.find((q) => q.name === queueName);
if (!queueItem) {
if (queueName.startsWith("task/")) {
items.push({
name: queueName.replace("task/", ""),
type: "task",
value: queueName,
});
} else {
items.push({
name: queueName,
type: "custom",
value: queueName,
});
}
}
}
if (fetcher.data === undefined) {
return matchSorter(items, searchValue);
}
let filtered: typeof items;
if (fetcher.data === undefined) {
filtered = matchSorter(items, searchValue);
} else {
items.push(
...fetcher.data.queues.map((q) => ({
name: q.name,
@@ -1205,10 +1207,10 @@ function QueuesDropdown({
}))
);
return matchSorter(Array.from(new Set(items)), searchValue, {
filtered = matchSorter(Array.from(new Set(items)), searchValue, {
keys: ["name"],
});
}, [searchValue, fetcher.data]);
}
return (
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
@@ -1318,29 +1320,27 @@ function RegionsDropdown({
const selected = values("regions").filter((v) => v !== "");
const filtered = useMemo(() => {
type RegionItem = { masterQueue: string; name: string; location?: string };
const items: RegionItem[] = [];
type RegionItem = { masterQueue: string; name: string; location?: string };
const items: RegionItem[] = [];
for (const masterQueue of selected) {
const known = regions.find((r) => r.masterQueue === masterQueue);
if (!known) {
items.push({ masterQueue, name: masterQueue });
}
for (const masterQueue of selected) {
const known = regions.find((r) => r.masterQueue === masterQueue);
if (!known) {
items.push({ masterQueue, name: masterQueue });
}
}
for (const region of regions) {
if (!items.some((i) => i.masterQueue === region.masterQueue)) {
items.push({
masterQueue: region.masterQueue,
name: region.name,
location: region.location,
});
}
for (const region of regions) {
if (!items.some((i) => i.masterQueue === region.masterQueue)) {
items.push({
masterQueue: region.masterQueue,
name: region.name,
location: region.location,
});
}
}
return matchSorter(items, searchValue, { keys: ["name", "masterQueue"] });
}, [searchValue, regions, selected.join(",")]);
const filtered = matchSorter(items, searchValue, { keys: ["name", "masterQueue"] });
return (
<SelectProvider value={selected} setValue={handleChange} virtualFocus={true}>
@@ -1566,33 +1566,31 @@ export function VersionsDropdown({
250
);
const filtered = useMemo(() => {
let items: { version: string; isCurrent: boolean }[] = [];
const items: { version: string; isCurrent: boolean }[] = [];
for (const version of selected ?? []) {
const versionItem = fetcher.data?.versions.find((v) => v.version === version);
if (!versionItem) {
items.push({
version,
isCurrent: false,
});
}
}
if (fetcher.data === undefined) {
return matchSorter(items, searchValue);
for (const version of selected ?? []) {
const versionItem = fetcher.data?.versions.find((v) => v.version === version);
if (!versionItem) {
items.push({
version,
isCurrent: false,
});
}
}
let filtered: typeof items;
if (fetcher.data === undefined) {
filtered = matchSorter(items, searchValue);
} else {
items.push(...fetcher.data.versions);
if (searchValue === "") {
return items;
}
return matchSorter(Array.from(new Set(items)), searchValue, {
keys: ["version"],
});
}, [searchValue, fetcher.data]);
filtered =
searchValue === ""
? items
: matchSorter(Array.from(new Set(items)), searchValue, {
keys: ["version"],
});
}
return (
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
@@ -495,7 +495,6 @@ function TimeDropdown({
const organization = useOptionalOrganization();
const [open, setOpen] = useState<boolean | undefined>();
const { replace } = useSearchParams();
const extraCleared = Object.fromEntries((clearParams ?? []).map((key) => [key, undefined]));
const [fromValue, setFromValue] = useState(from);
const [toValue, setToValue] = useState(to);
@@ -569,7 +568,7 @@ function TimeDropdown({
onValueChange(values);
} else {
replace({
...extraCleared,
...Object.fromEntries((clearParams ?? []).map((key) => [key, undefined])),
period: periodToApply,
cursor: undefined,
direction: undefined,
@@ -583,7 +582,7 @@ function TimeDropdown({
setOpen(false);
onApply?.(values);
},
[maxPeriodDays, onValueChange, replace, onApply]
[clearParams, maxPeriodDays, onValueChange, replace, onApply]
);
const applySelection = useCallback(() => {
@@ -629,7 +628,7 @@ function TimeDropdown({
} else {
// URL mode - navigate
replace({
...extraCleared,
...Object.fromEntries((clearParams ?? []).map((key) => [key, undefined])),
period: undefined,
cursor: undefined,
direction: undefined,
@@ -643,6 +642,7 @@ function TimeDropdown({
}
}, [
activeSection,
clearParams,
selectedPeriod,
isCustomDurationValid,
customValue,
@@ -78,22 +78,20 @@ export function TaskRunsList({
}
);
const onClickShowNewRuns = () => {
const isPaginated = has("cursor") || has("direction");
dismissNewRuns();
if (isPaginated) {
replace({ cursor: undefined, direction: undefined });
return;
}
revalidator.revalidate();
};
// Surface the banner to the top-bar button rendered by the page: keep the
// ref's action current, mirror the count up, and clear it when this boundary
// unmounts (e.g. the table re-suspends on a filter change).
useEffect(() => {
showNewRunsRef.current = onClickShowNewRuns;
}, [onClickShowNewRuns, showNewRunsRef]);
showNewRunsRef.current = () => {
const isPaginated = has("cursor") || has("direction");
dismissNewRuns();
if (isPaginated) {
replace({ cursor: undefined, direction: undefined });
return;
}
revalidator.revalidate();
};
}, [dismissNewRuns, has, replace, revalidator, showNewRunsRef]);
useEffect(() => {
onNewRunsCountChange(newRunsCount);
}, [newRunsCount, onNewRunsCountChange]);
@@ -155,7 +155,7 @@ export function TaskRunsTable({
}
}
},
[checkboxes, runs]
[checkboxes, runs, select]
);
return (
@@ -264,31 +264,30 @@ function TagsDropdown({
};
const fetcher = useFetcher<typeof tagsLoader>();
const { load } = fetcher;
useEffect(() => {
const searchParams = new URLSearchParams();
if (searchValue) {
searchParams.set("name", searchValue);
}
fetcher.load(
load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/tags?${searchParams}`
);
}, [searchValue]);
}, [environment.slug, load, organization.slug, project.slug, searchValue]);
const filtered = useMemo(() => {
let items: string[] = [];
if (searchValue === "") {
items = values("tags");
}
if (fetcher.data === undefined) {
return matchSorter(items, searchValue);
}
let items: string[] = [];
if (searchValue === "") {
items = values("tags");
}
let filtered: string[];
if (fetcher.data === undefined) {
filtered = matchSorter(items, searchValue);
} else {
items.push(...fetcher.data.tags.map((t) => t.name));
return matchSorter(Array.from(new Set(items)), searchValue);
}, [searchValue, fetcher.data]);
filtered = matchSorter(Array.from(new Set(items)), searchValue);
}
return (
<SelectProvider value={values("tags")} setValue={handleChange} virtualFocus={true}>
@@ -288,8 +288,8 @@ export function ToolUseRow({ tool }: { tool: ToolUse }) {
// Auto-select input tab when input arrives after initial render (e.g. streaming tool calls)
useEffect(() => {
if (!hasSubAgent && hasInput && activeTab === null) {
setActiveTab("input");
if (!hasSubAgent && hasInput) {
setActiveTab((current) => current ?? "input");
}
}, [hasInput, hasSubAgent]);
@@ -40,12 +40,12 @@ export function SampleSourcePicker({
}, [bodyFetcher.data, onLoad]);
const manifest = listFetcher.data?.kind === "manifest" ? listFetcher.data : undefined;
const providers = manifest?.providers ?? [];
const samples = manifest?.samples ?? [];
const providers = manifest?.providers;
const samples = manifest?.samples;
const listLoading = listFetcher.data === undefined;
useEffect(() => {
if (providers.length === 0) return;
if (!providers || providers.length === 0) return;
setSelectedProvider((current) => {
if (current && providers.some((p) => p.id === current)) return current;
if (endpointSource && providers.some((p) => p.id === endpointSource)) return endpointSource;
@@ -54,6 +54,8 @@ export function SampleSourcePicker({
}, [providers, endpointSource]);
const filteredProviders = useMemo(() => {
if (!providers) return [];
const query = producerQuery.trim().toLowerCase();
if (!query) return providers;
return providers.filter(
@@ -72,7 +74,7 @@ export function SampleSourcePicker({
return [...groups.entries()];
}, [filteredProviders]);
const events = samples
const events = (samples ?? [])
.filter((item) => item.provider === selectedProvider)
.filter((item) => {
const query = topicQuery.trim().toLowerCase();
+7 -5
View File
@@ -1,5 +1,5 @@
import { useRevalidator } from "@remix-run/react";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
type UseAutoRevalidateOptions = {
interval?: number; // in milliseconds
@@ -10,15 +10,17 @@ type UseAutoRevalidateOptions = {
export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) {
const { interval = 5000, onFocus = true, disabled = false } = options;
const revalidator = useRevalidator();
const revalidatorRef = useRef(revalidator);
revalidatorRef.current = revalidator;
useEffect(() => {
if (!interval || interval <= 0 || disabled) return;
const intervalId = setInterval(() => {
if (revalidator.state === "loading") {
if (revalidatorRef.current.state === "loading") {
return;
}
revalidator.revalidate();
revalidatorRef.current.revalidate();
}, interval);
return () => clearInterval(intervalId);
@@ -28,8 +30,8 @@ export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) {
if (!onFocus || disabled) return;
const handleFocus = () => {
if (document.visibilityState === "visible" && revalidator.state !== "loading") {
revalidator.revalidate();
if (document.visibilityState === "visible" && revalidatorRef.current.state !== "loading") {
revalidatorRef.current.revalidate();
}
};
+15 -11
View File
@@ -7,20 +7,24 @@ export function useChanged<T extends { id: string }>(
sendInitialUndefined = true
) {
const previousItemId = useRef<string | undefined>();
const isInitialRender = useRef(true);
const actionRef = useRef(action);
const itemRef = useRef<T | undefined>();
const item = getItem();
const itemId = item?.id;
actionRef.current = action;
itemRef.current = item;
//when the value changes, call the action
useEffect(() => {
if (previousItemId.current !== item?.id) {
action(item);
const shouldSendInitialUndefined =
isInitialRender.current && itemId === undefined && sendInitialUndefined;
if (previousItemId.current !== itemId || shouldSendInitialUndefined) {
actionRef.current(itemRef.current);
}
previousItemId.current = item?.id;
}, [item]);
//if sendInitialUndefined is true, call the action when the component first renders
useEffect(() => {
if (item !== undefined || sendInitialUndefined === false) return;
action(item);
}, []);
previousItemId.current = itemId;
isInitialRender.current = false;
}, [itemId, sendInitialUndefined]);
}
+6 -2
View File
@@ -206,6 +206,8 @@ export function useDashboardEditor({
const layoutDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isInitializedRef = useRef(false);
const currentLayoutJsonRef = useRef<string>(JSON.stringify(initialData.layout));
const initialDataRef = useRef(initialData);
initialDataRef.current = initialData;
// Sync queue to prevent race conditions
const syncQueueRef = useRef<SyncTask[]>([]);
@@ -217,6 +219,8 @@ export function useDashboardEditor({
widgets: initialData.widgets,
});
useEffect(() => {
const { layout, widgets } = initialDataRef.current;
// Cancel any pending layout save
if (layoutDebounceRef.current) {
clearTimeout(layoutDebounceRef.current);
@@ -229,11 +233,11 @@ export function useDashboardEditor({
// Reset state to new initial data
dispatch({
type: "RESET_STATE",
payload: { layout: initialData.layout, widgets: initialData.widgets },
payload: { layout, widgets },
});
// Update refs
currentLayoutJsonRef.current = JSON.stringify(initialData.layout);
currentLayoutJsonRef.current = JSON.stringify(layout);
isInitializedRef.current = false;
// Allow saves after a short delay to skip initial mount callbacks
+1 -1
View File
@@ -55,7 +55,7 @@ export function useFuzzyFilter<T extends object>({
}),
items
);
}, [items, filterText]);
}, [items, keys, filterText]);
return {
filterText,
+1 -1
View File
@@ -38,7 +38,7 @@ export const usePostHog = (
},
});
postHogInitialized.current = true;
}, [apiKey, uiHost, logging, user]);
}, [apiKey, uiHost, logging, debug, user]);
useUserChanged((user) => {
if (postHogInitialized.current === false) return;
@@ -20,7 +20,7 @@ export function useReplaceSearchParams() {
return s;
}, navigateOpts);
},
[searchParams]
[setSearchParams]
);
return { searchParams, setSearchParams, replaceSearchParam };
@@ -214,6 +214,7 @@ const TASK_TYPE_SEGMENTS: {
];
const PAGE_SIZE = 25;
const TASK_FILTER_KEYS = ["slug", "filePath", "triggerSource"];
export default function Page() {
const organization = useOrganization();
@@ -266,7 +267,7 @@ export default function Page() {
const { filteredItems } = useFuzzyFilter<UnifiedTaskListItem>({
items,
keys: ["slug", "filePath", "triggerSource"],
keys: TASK_FILTER_KEYS,
filterText: value("search") ?? "",
});
@@ -505,17 +505,20 @@ export function BranchFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const { showArchived } = BranchesOptions.parse(Object.fromEntries(searchParams.entries()));
const handleArchivedChange = useCallback((checked: boolean) => {
setSearchParams((s) => {
if (checked) {
s.set("showArchived", "true");
} else {
s.delete("showArchived");
}
s.delete("page");
return s;
});
}, []);
const handleArchivedChange = useCallback(
(checked: boolean) => {
setSearchParams((s) => {
if (checked) {
s.set("showArchived", "true");
} else {
s.delete("showArchived");
}
s.delete("page");
return s;
});
},
[setSearchParams]
);
return (
<div className="flex w-full items-center justify-between gap-2">
@@ -644,17 +644,17 @@ function PurchaseConcurrencyModal({
// Close the panel, when we've succeeded
// This is required because a redirect to the same path doesn't clear state
const [searchParams, setSearchParams] = useSearchParams();
const purchaseSucceeded = Boolean(searchParams.get("success"));
const [open, setOpen] = useState(false);
useEffect(() => {
const success = searchParams.get("success");
if (success) {
if (purchaseSucceeded) {
setOpen(false);
setSearchParams((s) => {
s.delete("success");
return s;
});
}
}, [searchParams.get("success")]);
}, [purchaseSucceeded, setSearchParams]);
const state = updateState({
value: amountValue,
@@ -285,7 +285,13 @@ export default function Page() {
return () => {
abortController.abort();
};
}, [eventStream?.s2?.basin, eventStream?.s2?.stream, eventStream?.s2?.accessToken, isPending]);
}, [
eventStream?.s2?.basin,
eventStream?.s2?.stream,
eventStream?.s2?.accessToken,
isPending,
logsDisabled,
]);
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
@@ -190,15 +190,24 @@ export default function Page() {
useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });
const selectedDeploymentShortCode = selectedDeployment?.shortCode;
// If we have a selected deployment from the version param, show it
useEffect(() => {
if (selectedDeployment && !deploymentParam) {
if (selectedDeploymentShortCode && !deploymentParam) {
const searchParams = new URLSearchParams(location.search);
searchParams.delete("version");
searchParams.set("page", currentPage.toString());
navigate(`${location.pathname}/${selectedDeployment.shortCode}?${searchParams.toString()}`);
navigate(`${location.pathname}/${selectedDeploymentShortCode}?${searchParams.toString()}`);
}
}, [selectedDeployment, deploymentParam, location.search]);
}, [
selectedDeploymentShortCode,
deploymentParam,
location.search,
location.pathname,
currentPage,
navigate,
]);
const currentDeployment = deployments.find((d) => d.isCurrent);
@@ -311,17 +311,20 @@ export function BranchFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const { showArchived } = BranchesOptions.parse(Object.fromEntries(searchParams.entries()));
const handleArchivedChange = useCallback((checked: boolean) => {
setSearchParams((s) => {
if (checked) {
s.set("showArchived", "true");
} else {
s.delete("showArchived");
}
s.delete("page");
return s;
});
}, []);
const handleArchivedChange = useCallback(
(checked: boolean) => {
setSearchParams((s) => {
if (checked) {
s.set("showArchived", "true");
} else {
s.delete("showArchived");
}
s.delete("page");
return s;
});
},
[setSearchParams]
);
return (
<div className="flex w-full items-center justify-between gap-2">
@@ -10,7 +10,7 @@ import {
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
import { json } from "@remix-run/server-runtime";
import dotenv from "dotenv";
import { useCallback, useState } from "react";
import { useState } from "react";
import { redirect } from "remix-typedjson";
import invariant from "tiny-invariant";
import { z } from "zod";
@@ -570,7 +570,7 @@ function VariableFields({
insertAfter,
} = useList<Variable>([{ key: "", value: "" }]);
const handlePaste = useCallback((index: number, e: React.ClipboardEvent<HTMLInputElement>) => {
const handlePaste = (index: number, e: React.ClipboardEvent<HTMLInputElement>) => {
const clipboardData = e.clipboardData;
if (!clipboardData) return;
@@ -593,7 +593,7 @@ function VariableFields({
form.insert({ name: variablesFields.name });
}
insertAfter(index, rest);
}, []);
};
const fields = variablesFields.getFieldList();
@@ -100,6 +100,8 @@ export const meta = pageMeta(({ params }) => [
"Errors",
]);
const ERROR_CHART_COLORS = ["#6c5ce7", "#ec4899"];
const emptyStringToUndefined = z.preprocess(
(v) => (v === "" ? undefined : v),
z.coerce.number().positive().optional()
@@ -327,9 +329,9 @@ export default function Page() {
} = useTypedLoaderData<typeof loader>();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const errorsPath = useMemo(() => {
const searchParams = new URLSearchParams(location.search);
const base = v3ErrorsPath(
{ slug: organizationSlug },
{ slug: projectParam },
@@ -347,7 +349,7 @@ export default function Page() {
}
const qs = carry.toString();
return qs ? `${base}?${qs}` : base;
}, [organizationSlug, projectParam, envParam, searchParams.toString()]);
}, [organizationSlug, projectParam, envParam, location.search]);
const alertsHref = useMemo(() => {
const params = new URLSearchParams(location.search);
@@ -856,7 +858,6 @@ function ActivityChart({
activity: ErrorGroupActivity;
versions: ErrorGroupActivityVersions;
}) {
const ERROR_CHART_COLORS = ["#6c5ce7", "#ec4899"];
const colors = useMemo(
() => versions.map((_, i) => ERROR_CHART_COLORS[i % ERROR_CHART_COLORS.length]),
[versions]
@@ -567,9 +567,9 @@ function ErrorGroupRow({
envParam: string;
}) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const errorPath = useMemo(() => {
const searchParams = new URLSearchParams(location.search);
const base = v3ErrorPath(
{ slug: organizationSlug },
{ slug: projectParam },
@@ -588,7 +588,7 @@ function ErrorGroupRow({
}
const qs = carry.toString();
return qs ? `${base}?${qs}` : base;
}, [organizationSlug, projectParam, envParam, errorGroup.fingerprint, searchParams.toString()]);
}, [organizationSlug, projectParam, envParam, errorGroup.fingerprint, location.search]);
const errorMessage = `${errorGroup.errorMessage}`;
@@ -756,6 +756,14 @@ function CompareDialog({
const project = useProject();
const environment = useEnvironment();
const fetcher = useFetcher<typeof compareLoader>();
const loadComparison = fetcher.load;
const wasOpenRef = useRef(false);
const canCompare = models.length >= 2;
const comparisonPath = `${v3ModelComparePath(
organization,
project,
environment
)}?models=${models.join(",")}`;
const comparison = (fetcher.data as { comparison?: ModelComparisonItem[] } | undefined)
?.comparison;
@@ -764,13 +772,14 @@ function CompareDialog({
[comparison, models, catalogModels]
);
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only fires on open; other deps are stable per dialog mount
useEffect(() => {
if (open && models.length >= 2) {
const params = models.join(",");
fetcher.load(`${v3ModelComparePath(organization, project, environment)}?models=${params}`);
const wasOpen = wasOpenRef.current;
wasOpenRef.current = open;
if (open && !wasOpen && canCompare) {
loadComparison(comparisonPath);
}
}, [open]);
}, [open, canCompare, comparisonPath, loadComparison]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -22,7 +22,7 @@ import {
} from "@trigger.dev/core/v3";
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { redirect } from "remix-typedjson";
import { ChevronExtraSmallDown } from "~/assets/icons/ChevronExtraSmallDown";
@@ -1966,10 +1966,10 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) {
onChange(text);
}, 250);
const updateValue = useCallback((next: string) => {
const updateValue = (next: string) => {
setValue(next);
updateFilterText(next);
}, []);
};
return <SearchInput placeholder="Search logs…" value={value} onValueChange={updateValue} />;
}
@@ -220,7 +220,14 @@ export default function IntegrationsSettingsPage() {
const nextUrl = searchParams.get("next");
const [isModalOpen, setIsModalOpen] = useState(false);
const vercelFetcher = useTypedFetcher<typeof vercelLoader>();
const loadVercelOnboarding = vercelFetcher.load;
const onboardingData = vercelFetcher.data?.onboardingData ?? null;
const hasVercelFetcherData = vercelFetcher.data !== undefined;
const vercelOnboardingPath = `${vercelResourcePath(
organization.slug,
project.slug,
environment.slug
)}?vercelOnboarding=true`;
// Helper to open modal and ensure query param is present
const openVercelOnboarding = useCallback(() => {
@@ -256,15 +263,9 @@ export default function IntegrationsSettingsPage() {
if (!isModalOpen) {
openVercelOnboarding();
}
} else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) {
} else if (vercelFetcher.state === "idle" && !hasVercelFetcherData) {
// Load onboarding data
vercelFetcher.load(
`${vercelResourcePath(
organization.slug,
project.slug,
environment.slug
)}?vercelOnboarding=true`
);
loadVercelOnboarding(vercelOnboardingPath);
}
} else if (!hasQueryParam && isModalOpen) {
// Query param removed but modal is open, close modal
@@ -273,14 +274,13 @@ export default function IntegrationsSettingsPage() {
}, [
hasQueryParam,
vercelIntegrationEnabled,
organization.slug,
project.slug,
environment.slug,
onboardingData,
vercelFetcher.data,
hasVercelFetcherData,
vercelFetcher.state,
isModalOpen,
openVercelOnboarding,
loadVercelOnboarding,
vercelOnboardingPath,
]);
// Ensure modal stays open when query param is present (even after data reloads)
@@ -301,7 +301,7 @@ export default function IntegrationsSettingsPage() {
openVercelOnboarding();
}
}
}, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
}, [hasQueryParam, onboardingData, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
// Track if we're waiting for data from button click (not query param)
const waitingForButtonClickRef = useRef(false);
@@ -322,19 +322,11 @@ export default function IntegrationsSettingsPage() {
} else {
// Need to load data first, mark that we're waiting for button click
waitingForButtonClickRef.current = true;
vercelFetcher.load(
`${vercelResourcePath(
organization.slug,
project.slug,
environment.slug
)}?vercelOnboarding=true`
);
loadVercelOnboarding(vercelOnboardingPath);
}
}, [
organization.slug,
project.slug,
environment.slug,
vercelFetcher,
loadVercelOnboarding,
vercelOnboardingPath,
onboardingData,
setSearchParams,
hasQueryParam,
@@ -415,12 +407,8 @@ export default function IntegrationsSettingsPage() {
nextUrl={nextUrl ?? undefined}
vercelManageAccessUrl={vercelFetcher.data?.vercelManageAccessUrl}
onDataReload={(vercelEnvironmentId) => {
vercelFetcher.load(
`${vercelResourcePath(
organization.slug,
project.slug,
environment.slug
)}?vercelOnboarding=true${
loadVercelOnboarding(
`${vercelOnboardingPath}${
vercelEnvironmentId
? `&vercelEnvironmentId=${encodeURIComponent(vercelEnvironmentId)}`
: ""
@@ -553,6 +553,7 @@ function CreateScheduleSheet({
onClose: () => void;
}) {
const fetcher = useTypedFetcher<typeof scheduleNewLoader>();
const loadScheduleForm = fetcher.load;
// Embedded create — stays on this page via `_format=json`.
const createFetcher = useFetcher<{ ok: boolean; message?: string }>();
const toast = useToast();
@@ -563,8 +564,8 @@ function CreateScheduleSheet({
const newPath = v3NewSchedulePath(organization, project, environment);
useEffect(() => {
if (open) fetcher.load(newPath);
}, [open, newPath]);
if (open) loadScheduleForm(newPath);
}, [open, newPath, loadScheduleForm]);
// Toast + close + revalidate so the new schedule appears.
useEffect(() => {
@@ -624,7 +625,9 @@ function ScheduleSheet({
onClose: () => void;
}) {
const detailFetcher = useTypedFetcher<typeof scheduleDetailLoader>();
const loadScheduleDetail = detailFetcher.load;
const editFetcher = useTypedFetcher<typeof scheduleEditLoader>();
const loadScheduleEditor = editFetcher.load;
// Embedded enable/disable — stays in the sheet via `_format=json`.
const activeToggleFetcher = useFetcher<{ ok: boolean; active?: boolean; message?: string }>();
// Embedded update submission — same idea.
@@ -652,12 +655,12 @@ function ScheduleSheet({
}, [openScheduleId]);
useEffect(() => {
if (detailPath) detailFetcher.load(detailPath);
}, [detailPath]);
if (detailPath) loadScheduleDetail(detailPath);
}, [detailPath, loadScheduleDetail]);
useEffect(() => {
if (mode === "edit" && editPath) editFetcher.load(editPath);
}, [mode, editPath]);
if (mode === "edit" && editPath) loadScheduleEditor(editPath);
}, [mode, editPath, loadScheduleEditor]);
// Reload inspector data so Enable/Disable label flips; revalidate the
// route loader so the sidebar's list/Overview stay in sync; toast on error.
@@ -667,12 +670,19 @@ function ScheduleSheet({
if (handledToggleRef.current === data) return;
handledToggleRef.current = data;
if (data.ok) {
if (detailPath) detailFetcher.load(detailPath);
if (detailPath) loadScheduleDetail(detailPath);
revalidator.revalidate();
} else if (data.message) {
toast.error(data.message);
}
}, [activeToggleFetcher.state, activeToggleFetcher.data, detailPath, toast, revalidator]);
}, [
activeToggleFetcher.state,
activeToggleFetcher.data,
detailPath,
toast,
revalidator,
loadScheduleDetail,
]);
// Toast + back to inspect + reload + revalidate so both the inspector
// and the sidebar reflect the update.
@@ -684,12 +694,12 @@ function ScheduleSheet({
if (data.ok) {
toast.success(data.message ?? "Schedule updated");
setMode("inspect");
if (detailPath) detailFetcher.load(detailPath);
if (detailPath) loadScheduleDetail(detailPath);
revalidator.revalidate();
} else if (data.message) {
toast.error(data.message);
}
}, [updateFetcher.state, updateFetcher.data, detailPath, toast, revalidator]);
}, [updateFetcher.state, updateFetcher.data, detailPath, toast, revalidator, loadScheduleDetail]);
// Toast + close + revalidate so the deleted row disappears.
useEffect(() => {
@@ -792,7 +802,7 @@ function ScheduledTaskDetailSidebar({
if (a.type === b.type) return 0;
return a.type === "DECLARATIVE" ? -1 : 1;
});
}, [scheduleList?.schedules]);
}, [scheduleList]);
const firstSchedule = sortedSchedules[0];
const [activeTab, setActiveTab] = useState<"overview" | "schedules">("overview");
return (
@@ -63,6 +63,25 @@ export function AIPayloadTabContent({
const submitGeneration = useCallback(
async (queryPrompt: string) => {
const processStreamEvent = (event: StreamEventType) => {
switch (event.type) {
case "thinking":
setThinking((prev) => prev + event.content);
break;
case "result":
if (event.success) {
onPayloadGenerated(event.payload);
setLastPayload(event.payload);
setPrompt("");
setLastResult("success");
} else {
setError(event.error);
setLastResult("error");
}
break;
}
};
if (!queryPrompt.trim() || isLoadingRef.current) return;
isLoadingRef.current = true;
@@ -168,31 +187,10 @@ export function AIPayloadTabContent({
isAgent,
payloadKind,
providerSource,
onPayloadGenerated,
]
);
const processStreamEvent = useCallback(
(event: StreamEventType) => {
switch (event.type) {
case "thinking":
setThinking((prev) => prev + event.content);
break;
case "result":
if (event.success) {
onPayloadGenerated(event.payload);
setLastPayload(event.payload);
setPrompt("");
setLastResult("success");
} else {
setError(event.error);
setLastResult("error");
}
break;
}
},
[onPayloadGenerated]
);
const handleSubmit = useCallback(
(e?: React.FormEvent) => {
e?.preventDefault();
@@ -297,6 +297,7 @@ export default function Page() {
const params = useParams();
const queueFetcher = useFetcher<typeof queuesLoader>();
const { load: loadQueues } = queueFetcher;
useEffect(() => {
if (result.foundTask && params.organizationSlug && params.projectParam && params.envParam) {
@@ -304,13 +305,13 @@ export default function Page() {
searchParams.set("type", "custom");
searchParams.set("per_page", "100");
queueFetcher.load(
loadQueues(
`/resources/orgs/${params.organizationSlug}/projects/${params.projectParam}/env/${
params.envParam
}/queues?${searchParams.toString()}`
);
}
}, [result.foundTask, params.organizationSlug, params.projectParam, params.envParam]);
}, [result.foundTask, params.organizationSlug, params.projectParam, params.envParam, loadQueues]);
const defaultTaskQueue = result.foundTask && "queue" in result ? result.queue : undefined;
const queues = useMemo(() => {
@@ -1758,6 +1759,7 @@ function CreateTemplateModal({
}) {
const submit = useSubmit();
const [isModalOpen, setIsModalOpen] = useState(false);
const successMessageTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const actionData = useActionData<typeof action>();
const lastSubmission =
@@ -1772,11 +1774,19 @@ function CreateTemplateModal({
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
setIsModalOpen(false);
setShowCreatedSuccessMessage(true);
setTimeout(() => {
clearTimeout(successMessageTimeoutRef.current);
successMessageTimeoutRef.current = setTimeout(() => {
setShowCreatedSuccessMessage(false);
}, 2000);
}
}, [lastSubmission]);
}, [lastSubmission, setShowCreatedSuccessMessage]);
useEffect(() => {
return () => {
clearTimeout(successMessageTimeoutRef.current);
setShowCreatedSuccessMessage(false);
};
}, [setShowCreatedSuccessMessage]);
const [
form,
@@ -41,6 +41,8 @@ import { testAgentPageContext } from "~/components/dashboard-agent/suggested-pro
import { WhenAgentUnavailable } from "~/components/dashboard-agent/WhenAgentUnavailable";
import type { Handle } from "~/utils/handle";
const TASK_FILTER_KEYS = ["taskIdentifier", "friendlyId", "id", "filePath", "triggerSource"];
export const handle: Handle = {
agentPageContext: (data) => testAgentPageContext(data),
};
@@ -138,7 +140,7 @@ function TaskSelector({
}) {
const { filterText, setFilterText, filteredItems } = useFuzzyFilter<TaskListItem>({
items: tasks,
keys: ["taskIdentifier", "friendlyId", "id", "filePath", "triggerSource"],
keys: TASK_FILTER_KEYS,
});
const hasTaskInEnvironment = activeTaskIdentifier
? tasks.some((t) => t.taskIdentifier === activeTaskIdentifier)
@@ -178,7 +178,7 @@ export default function AdminFeatureFlagsRoute() {
// Only track editable flags in state
const editable: Record<string, unknown> = {};
for (const [key, value] of Object.entries(loaded)) {
if (!isLocked(key)) {
if (unlocked || !GLOBAL_LOCKED_FLAGS.includes(key)) {
editable[key] = value;
}
}
+14 -8
View File
@@ -1,6 +1,6 @@
import { useFetcher, useRevalidator } from "@remix-run/react";
import { json } from "@remix-run/server-runtime";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { Button } from "~/components/primitives/Buttons";
@@ -57,11 +57,12 @@ export const action = dashboardAction(
export default function AdminQueueMetricsRoute() {
const { controls, streams } = useTypedLoaderData<typeof loader>();
const saveFetcher = useFetcher<{ success?: boolean; error?: string }>();
const revalidator = useRevalidator();
const { revalidate, state: revalidatorState } = useRevalidator();
const [enabled, setEnabled] = useState(controls.enabled);
const [sampleRate, setSampleRate] = useState(String(controls.sampleRate));
const [error, setError] = useState<string | null>(null);
const handledSaveDataRef = useRef(saveFetcher.data);
useEffect(() => {
setEnabled(controls.enabled);
@@ -69,13 +70,18 @@ export default function AdminQueueMetricsRoute() {
}, [controls.enabled, controls.sampleRate]);
useEffect(() => {
if (saveFetcher.data?.success) {
if (!saveFetcher.data || handledSaveDataRef.current === saveFetcher.data) {
return;
}
handledSaveDataRef.current = saveFetcher.data;
if (saveFetcher.data.success) {
setError(null);
revalidator.revalidate();
} else if (saveFetcher.data?.error) {
revalidate();
} else if (saveFetcher.data.error) {
setError(saveFetcher.data.error);
}
}, [saveFetcher.data]);
}, [saveFetcher.data, revalidate]);
const isSaving = saveFetcher.state === "submitting";
@@ -142,8 +148,8 @@ export default function AdminQueueMetricsRoute() {
<Header2>Stream health{totalLag > 0 ? ` (lag ${totalLag})` : ""}</Header2>
<Button
variant="tertiary/small"
onClick={() => revalidator.revalidate()}
disabled={revalidator.state === "loading"}
onClick={revalidate}
disabled={revalidatorState === "loading"}
>
Refresh
</Button>
@@ -41,26 +41,27 @@ const POLL_INTERVAL_MS = 60_000;
export function useIncidentStatus() {
const { isManagedCloud } = useFeatures();
const fetcher = useFetcher<typeof loader>();
const { load, state } = fetcher;
const hasInitiallyFetched = useRef(false);
useEffect(() => {
if (!isManagedCloud) return;
// Initial fetch on mount
if (!hasInitiallyFetched.current && fetcher.state === "idle") {
if (!hasInitiallyFetched.current && state === "idle") {
hasInitiallyFetched.current = true;
fetcher.load("/resources/incidents");
load("/resources/incidents");
}
// Poll every 60 seconds
const interval = setInterval(() => {
if (fetcher.state === "idle") {
fetcher.load("/resources/incidents");
if (state === "idle") {
load("/resources/incidents");
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [isManagedCloud]);
}, [isManagedCloud, load, state]);
return {
status: fetcher.data?.status ?? "operational",
+29 -26
View File
@@ -202,12 +202,23 @@ export function MetricWidget({
const [isLoading, setIsLoading] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const isDirtyRef = useRef(false);
const submitRef = useRef<() => void>(() => {});
// Track the latest props so the submit callback always uses fresh values
// without needing to be recreated (which would cause useInterval to re-register listeners).
const propsRef = useRef(props);
propsRef.current = props;
// Track visibility so we only fetch for on-screen widgets.
// When a widget scrolls into view and has no data yet, trigger a load.
const { ref: visibilityRef, isVisibleRef } = useElementVisibility({
onVisibilityChange: (visible) => {
if (visible && (!response || isDirtyRef.current)) {
submitRef.current();
}
},
});
const submit = useCallback(() => {
if (!isVisibleRef.current) {
isDirtyRef.current = true;
@@ -251,17 +262,8 @@ export function MetricWidget({
setIsLoading(false);
}
});
}, []);
// Track visibility so we only fetch for on-screen widgets.
// When a widget scrolls into view and has no data yet, trigger a load.
const { ref: visibilityRef, isVisibleRef } = useElementVisibility({
onVisibilityChange: (visible) => {
if (visible && (!response || isDirtyRef.current)) {
submit();
}
},
});
}, [isVisibleRef]);
submitRef.current = submit;
// Clean up on unmount
useEffect(() => {
@@ -273,24 +275,25 @@ export function MetricWidget({
// Reload periodically and on focus (onLoad: false — the useEffect below handles initial load)
useInterval({ interval: refreshIntervalMs, callback: submit, onLoad: false });
const reloadKey = JSON.stringify({
query: props.query,
from: props.from,
to: props.to,
period: props.period,
scope: props.scope,
taskIdentifiers: props.taskIdentifiers,
queues: props.queues,
responseModels: props.responseModels,
promptSlugs: props.promptSlugs,
promptVersions: props.promptVersions,
operations: props.operations,
providers: props.providers,
});
// Reload on mount and when query, time period, or filters change
useEffect(() => {
submit();
}, [
submit,
props.query,
props.from,
props.to,
props.period,
props.scope,
JSON.stringify(props.taskIdentifiers),
JSON.stringify(props.queues),
JSON.stringify(props.responseModels),
JSON.stringify(props.promptSlugs),
JSON.stringify(props.promptVersions),
JSON.stringify(props.operations),
JSON.stringify(props.providers),
]);
}, [submit, reloadKey]);
const data = response?.success
? { rows: response.data.rows, columns: response.data.columns }
@@ -1077,6 +1077,7 @@ export function GitHubSettingsPanel({
layout?: "settings" | "compact";
}) {
const fetcher = useTypedFetcher<typeof loader>();
const { load } = fetcher;
const location = useLocation();
// Preserve current search params (e.g. origin=marketplace, next=...) but strip
@@ -1088,8 +1089,8 @@ export function GitHubSettingsPanel({
return search ? `${location.pathname}?${search}` : location.pathname;
})();
useEffect(() => {
fetcher.load(gitHubResourcePath(organizationSlug, projectSlug, environmentSlug));
}, [organizationSlug, projectSlug, environmentSlug]);
load(gitHubResourcePath(organizationSlug, projectSlug, environmentSlug));
}, [organizationSlug, projectSlug, environmentSlug, load]);
const data = fetcher.data;
@@ -229,14 +229,15 @@ export function SpanView({
const project = useProject();
const environment = useEnvironment();
const fetcher = useTypedFetcher<typeof loader>();
const { load } = fetcher;
useEffect(() => {
if (spanId === undefined) return;
const url = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
environment.slug
}/runs/${runParam}/spans/${spanId}${linkedRunId ? `?linkedRunId=${linkedRunId}` : ""}`;
fetcher.load(url);
}, [organization.slug, project.slug, environment.slug, runParam, spanId, linkedRunId]);
load(url);
}, [organization.slug, project.slug, environment.slug, runParam, spanId, linkedRunId, load]);
if (spanId === undefined) {
return null;
@@ -255,22 +255,17 @@ export function CreateBulkActionInspector({
const project = useProject();
const environment = useEnvironment();
const fetcher = useTypedFetcher<typeof loader>();
const { load } = fetcher;
const { value, replace, del } = useSearchParams();
const [action, setAction] = useState<BulkActionAction>(
bulkActionActionFromString(value("action"))
);
const action = bulkActionActionFromString(value("action"));
const location = useOptimisticLocation();
const user = useUser();
useEffect(() => {
fetcher.load(
load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/bulkaction${location.search}`
);
}, [organization.id, project.id, environment.id, location.search]);
useEffect(() => {
setAction(bulkActionActionFromString(value("action")));
}, [value("action")]);
}, [organization.slug, project.slug, environment.slug, location.search, load]);
const mode = bulkActionModeFromString(value("mode"));
@@ -695,7 +695,6 @@ function ConnectedVercelProjectForm({
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
const [hasConfigChanges, setHasConfigChanges] = useState(false);
const [configValues, setConfigValues] = useState({
atomicBuilds: connectedProject.integrationData.config.atomicBuilds ?? [],
pullEnvVarsBeforeBuild: connectedProject.integrationData.config.pullEnvVarsBeforeBuild ?? [],
@@ -712,35 +711,24 @@ function ConnectedVercelProjectForm({
connectedProject.integrationData.config.vercelStagingEnvironment ?? null;
const originalAutoPromote = connectedProject.integrationData.config.autoPromote ?? true;
useEffect(() => {
const atomicBuildsChanged =
JSON.stringify([...configValues.atomicBuilds].sort()) !==
JSON.stringify([...originalAtomicBuilds].sort());
const pullEnvVarsChanged =
JSON.stringify([...configValues.pullEnvVarsBeforeBuild].sort()) !==
JSON.stringify([...originalPullEnvVars].sort());
const discoverEnvVarsChanged =
JSON.stringify([...configValues.discoverEnvVars].sort()) !==
JSON.stringify([...originalDiscoverEnvVars].sort());
const stagingEnvChanged =
configValues.vercelStagingEnvironment?.environmentId !== originalStagingEnv?.environmentId;
const autoPromoteChanged = configValues.autoPromote !== originalAutoPromote;
setHasConfigChanges(
atomicBuildsChanged ||
pullEnvVarsChanged ||
discoverEnvVarsChanged ||
stagingEnvChanged ||
autoPromoteChanged
);
}, [
configValues,
originalAtomicBuilds,
originalPullEnvVars,
originalDiscoverEnvVars,
originalStagingEnv,
originalAutoPromote,
]);
const atomicBuildsChanged =
JSON.stringify([...configValues.atomicBuilds].sort()) !==
JSON.stringify([...originalAtomicBuilds].sort());
const pullEnvVarsChanged =
JSON.stringify([...configValues.pullEnvVarsBeforeBuild].sort()) !==
JSON.stringify([...originalPullEnvVars].sort());
const discoverEnvVarsChanged =
JSON.stringify([...configValues.discoverEnvVars].sort()) !==
JSON.stringify([...originalDiscoverEnvVars].sort());
const stagingEnvChanged =
configValues.vercelStagingEnvironment?.environmentId !== originalStagingEnv?.environmentId;
const autoPromoteChanged = configValues.autoPromote !== originalAutoPromote;
const hasConfigChanges =
atomicBuildsChanged ||
pullEnvVarsChanged ||
discoverEnvVarsChanged ||
stagingEnvChanged ||
autoPromoteChanged;
const [configForm, _fields] = useForm({
id: "update-vercel-config",
@@ -1121,6 +1109,7 @@ function VercelSettingsPanel({
isLoadingVercelData?: boolean;
}) {
const fetcher = useTypedFetcher<typeof loader>();
const { load } = fetcher;
const _location = useLocation();
const data = fetcher.data;
const [hasError, _setHasError] = useState(false);
@@ -1128,7 +1117,7 @@ function VercelSettingsPanel({
useEffect(() => {
if (!data?.authInvalid && !hasError && !data && !hasFetched) {
fetcher.load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug));
load(vercelResourcePath(organizationSlug, projectSlug, environmentSlug));
setHasFetched(true);
}
}, [
@@ -1139,6 +1128,7 @@ function VercelSettingsPanel({
hasError,
data,
hasFetched,
load,
]);
if (hasError) {
@@ -5,7 +5,7 @@ import type { WaitpointTokenStatus } from "@trigger.dev/core/v3";
import { stringifyIO, timeoutError } from "@trigger.dev/core/v3";
import { WaitpointId } from "@trigger.dev/core/v3/isomorphic";
import type { Waitpoint } from "@trigger.dev/database";
import { useCallback, useRef } from "react";
import { useRef } from "react";
import { z } from "zod";
import { AnimatedHourglassIcon } from "~/assets/icons/AnimatedHourglassIcon";
import { JSONEditor } from "~/components/code/JSONEditor";
@@ -321,25 +321,22 @@ function CompleteManualWaitpointForm({ waitpoint }: { waitpoint: { id: string }
const currentJson = useRef<string>("{\n\n}");
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/${waitpoint.id}/complete`;
const submitForm = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
const formData = new FormData(e.currentTarget);
const data: Record<string, string> = {
type: formData.get("type") as string,
failureRedirect: formData.get("failureRedirect") as string,
successRedirect: formData.get("successRedirect") as string,
};
const submitForm = (e: React.FormEvent<HTMLFormElement>) => {
const formData = new FormData(e.currentTarget);
const data: Record<string, string> = {
type: formData.get("type") as string,
failureRedirect: formData.get("failureRedirect") as string,
successRedirect: formData.get("successRedirect") as string,
};
data.payload = currentJson.current;
data.payload = currentJson.current;
submit(data, {
action: formAction,
method: "post",
});
e.preventDefault();
},
[currentJson]
);
submit(data, {
action: formAction,
method: "post",
});
e.preventDefault();
};
return (
<Form
@@ -1,7 +1,7 @@
import { useFetcher } from "@remix-run/react";
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { useCallback, useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { Button } from "~/components/primitives/Buttons";
@@ -58,6 +58,8 @@ type AIGeneratedCronFieldProps = {
export function AIGeneratedCronField({ onSuccess }: AIGeneratedCronFieldProps) {
const fetcher = useFetcher<typeof action>();
const [text, setText] = useState<string>("");
const onSuccessRef = useRef(onSuccess);
onSuccessRef.current = onSuccess;
const organization = useOrganization();
const project = useProject();
const isLoading = fetcher.state !== "idle";
@@ -66,11 +68,11 @@ export function AIGeneratedCronField({ onSuccess }: AIGeneratedCronFieldProps) {
useEffect(() => {
if (resultData?.cron !== undefined) {
onSuccess(resultData.cron);
onSuccessRef.current(resultData.cron);
}
}, [resultData?.cron]);
const submit = useCallback(async (value: string) => {
const submit = (value: string) => {
fetcher.submit(
{ message: value },
{
@@ -79,7 +81,7 @@ export function AIGeneratedCronField({ onSuccess }: AIGeneratedCronFieldProps) {
encType: "application/json",
}
);
}, []);
};
return (
<div className="max-w-md">
@@ -42,6 +42,7 @@ const POLL_INTERVAL_MS = 60_000;
export function useRecentChangelogs(organizationId?: string, projectId?: string) {
const fetcher = useFetcher<typeof loader>();
const { load, state } = fetcher;
const lastLoadedUrl = useRef<string | null>(null);
useEffect(() => {
@@ -51,19 +52,19 @@ export function useRecentChangelogs(organizationId?: string, projectId?: string)
const qs = params.toString();
const url = `/resources/platform-changelogs${qs ? `?${qs}` : ""}`;
if (lastLoadedUrl.current !== url && fetcher.state === "idle") {
if (lastLoadedUrl.current !== url && state === "idle") {
lastLoadedUrl.current = url;
fetcher.load(url);
load(url);
}
const interval = setInterval(() => {
if (fetcher.state === "idle") {
fetcher.load(url);
if (state === "idle") {
load(url);
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [organizationId, projectId]);
}, [organizationId, projectId, load, state]);
return {
changelogs: fetcher.data?.changelogs ?? [],
@@ -41,24 +41,25 @@ const POLL_INTERVAL_MS = 60000; // 1 minute
export function usePlatformNotifications(organizationId: string, projectId: string) {
const fetcher = useFetcher<typeof loader>();
const { load, state } = fetcher;
const lastLoadedUrl = useRef<string | null>(null);
useEffect(() => {
const url = `/resources/platform-notifications?organizationId=${encodeURIComponent(organizationId)}&projectId=${encodeURIComponent(projectId)}`;
if (lastLoadedUrl.current !== url && fetcher.state === "idle") {
if (lastLoadedUrl.current !== url && state === "idle") {
lastLoadedUrl.current = url;
fetcher.load(url);
load(url);
}
const interval = setInterval(() => {
if (fetcher.state === "idle") {
fetcher.load(url);
if (state === "idle") {
load(url);
}
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [organizationId, projectId]);
}, [organizationId, projectId, load, state]);
return {
notifications: fetcher.data?.notifications ?? [],
@@ -795,7 +795,6 @@ function AgentOrb({
colors = AGENT_ORB_PALETTE,
restColor = "#ffffff",
colored = true,
restShape = "triangle",
dotCount = 21,
orbitCount = 3,
particlesPerOrbit = 3,
@@ -824,7 +823,7 @@ function AgentOrb({
() => buildDotSpecs(effDotCount, orbitCount, effParticles),
[effDotCount, orbitCount, effParticles]
);
const restPoints = useMemo(() => triangleOutline(effDotCount), [restShape, effDotCount]);
const restPoints = useMemo(() => triangleOutline(effDotCount), [effDotCount]);
useEffect(() => {
activeRef.current = active;
@@ -846,7 +845,7 @@ function AgentOrb({
restPoints,
dotSpecs,
orbitGeoms,
paletteRgb: colors.map(hexToRgb),
paletteRgb: colorsKey.split(",").map(hexToRgb),
restRgb: hexToRgb(restColor),
colored,
radiusScale,
@@ -1129,7 +1128,7 @@ function AgentLogoMorph({
outline: logoOutlinePoints(dotCount),
dotSpecs: buildDotSpecs(dotCount, orbitCount, particlesPerOrbit),
orbitGeoms: buildOrbitGeoms(orbitCount),
paletteRgb: colors.map(hexToRgb),
paletteRgb: colorsKey.split(",").map(hexToRgb),
logoRgb: hexToRgb(logoColor),
};
@@ -149,10 +149,13 @@ const statuses = allTaskRunStatuses.map((status) => ({
function Statuses({ trigger, clearSearchValue, shortcut, searchValue, setFilterType }: MenuProps) {
const { values, replace } = useSearchParams();
const handleChange = useCallback((values: string[]) => {
clearSearchValue();
replace({ status: values });
}, []);
const handleChange = useCallback(
(values: string[]) => {
clearSearchValue();
replace({ status: values });
},
[clearSearchValue, replace]
);
const filtered = useMemo(() => {
return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase()));
@@ -205,10 +208,13 @@ function Environments({
}: MenuProps) {
const { values, replace } = useSearchParams();
const handleChange = useCallback((values: string[]) => {
clearSearchValue();
replace({ environment: values });
}, []);
const handleChange = useCallback(
(values: string[]) => {
clearSearchValue();
replace({ environment: values });
},
[clearSearchValue, replace]
);
const filtered = useMemo(() => {
return environments.filter((item) =>
@@ -1,6 +1,5 @@
import { CircleStackIcon } from "@heroicons/react/20/solid";
import { Form, useNavigate } from "@remix-run/react";
import { useCallback } from "react";
import { LogoIcon } from "~/components/LogoIcon";
import { Button } from "~/components/primitives/Buttons";
import {
@@ -141,13 +140,13 @@ function Statuses() {
const location = useOptimisticLocation();
const search = new URLSearchParams(location.search);
const handleChange = useCallback((values: string[]) => {
const handleChange = (values: string[]) => {
search.delete("status");
for (const value of values) {
search.append("status", value);
}
navigate(`${location.pathname}?${search.toString()}`, { replace: true });
}, []);
};
return (
<Select
+84 -34
View File
@@ -16,6 +16,17 @@ import type { UseApiClientOptions } from "./useApiClient.js";
import { useApiClient } from "./useApiClient.js";
import { createThrottledQueue } from "../utils/throttle.js";
// Keep subscription lifecycles controlled by their effects while using the latest request inputs.
function useStableRequestCallback(callback: () => Promise<void>) {
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
return useCallback(() => callbackRef.current(), []);
}
export type UseRealtimeRunOptions = UseApiClientOptions & {
id?: string;
enabled?: boolean;
@@ -111,6 +122,9 @@ export function useRealtimeRun<TTask extends AnyTask>(
}, []);
const apiClient = useApiClient(options);
const onComplete = options?.onComplete;
const skipColumns = options?.skipColumns;
const stopOnCompletion = options?.stopOnCompletion;
const triggerRequest = useCallback(async () => {
try {
@@ -123,12 +137,12 @@ export function useRealtimeRun<TTask extends AnyTask>(
await processRealtimeRun(
runId,
{ skipColumns: options?.skipColumns },
{ skipColumns },
apiClient,
mutateRun,
setError,
abortControllerRef,
typeof options?.stopOnCompletion === "boolean" ? options.stopOnCompletion : true
typeof stopOnCompletion === "boolean" ? stopOnCompletion : true
);
} catch (err) {
// Ignore abort errors as they are expected.
@@ -146,7 +160,8 @@ export function useRealtimeRun<TTask extends AnyTask>(
// Mark the subscription as complete
setIsComplete(true);
}
}, [runId, mutateRun, abortControllerRef, apiClient, setError]);
}, [runId, apiClient, mutateRun, setError, setIsComplete, skipColumns, stopOnCompletion]);
const requestSubscription = useStableRequestCallback(triggerRequest);
const hasCalledOnCompleteRef = useRef(false);
@@ -154,11 +169,11 @@ export function useRealtimeRun<TTask extends AnyTask>(
// Only call onComplete when the run has actually finished (has finishedAt),
// not just when the subscription stream ends (which can happen due to network issues)
useEffect(() => {
if (isComplete && run?.finishedAt && options?.onComplete && !hasCalledOnCompleteRef.current) {
options.onComplete(run, error);
if (isComplete && run?.finishedAt && onComplete && !hasCalledOnCompleteRef.current) {
onComplete(run, error);
hasCalledOnCompleteRef.current = true;
}
}, [isComplete, run, error, options?.onComplete]);
}, [isComplete, run, error, onComplete]);
useEffect(() => {
if (typeof options?.enabled === "boolean" && !options.enabled) {
@@ -169,18 +184,18 @@ export function useRealtimeRun<TTask extends AnyTask>(
return;
}
triggerRequest().finally(() => {});
requestSubscription().finally(() => {});
return () => {
stop();
};
}, [runId, stop, options?.enabled]);
}, [runId, stop, options?.enabled, requestSubscription]);
useEffect(() => {
if (run?.finishedAt) {
setIsComplete(true);
}
}, [run]);
}, [run, setIsComplete]);
return { run, error, stop };
}
@@ -274,6 +289,10 @@ export function useRealtimeRunWithStreams<
}, []);
const apiClient = useApiClient(options);
const onComplete = options?.onComplete;
const skipColumns = options?.skipColumns;
const stopOnCompletion = options?.stopOnCompletion;
const throttleInMs = options?.throttleInMs;
const triggerRequest = useCallback(async () => {
try {
@@ -286,15 +305,15 @@ export function useRealtimeRunWithStreams<
await processRealtimeRunWithStreams(
runId,
{ skipColumns: options?.skipColumns },
{ skipColumns },
apiClient,
mutateRun,
mutateStreams,
streamsRef,
setError,
abortControllerRef,
typeof options?.stopOnCompletion === "boolean" ? options.stopOnCompletion : true,
options?.throttleInMs ?? 16
typeof stopOnCompletion === "boolean" ? stopOnCompletion : true,
throttleInMs ?? 16
);
} catch (err) {
// Ignore abort errors as they are expected.
@@ -312,7 +331,18 @@ export function useRealtimeRunWithStreams<
// Mark the subscription as complete
setIsComplete(true);
}
}, [runId, mutateRun, mutateStreams, streamsRef, abortControllerRef, apiClient, setError]);
}, [
runId,
apiClient,
mutateRun,
mutateStreams,
setError,
setIsComplete,
skipColumns,
stopOnCompletion,
throttleInMs,
]);
const requestSubscription = useStableRequestCallback(triggerRequest);
const hasCalledOnCompleteRef = useRef(false);
@@ -320,11 +350,11 @@ export function useRealtimeRunWithStreams<
// Only call onComplete when the run has actually finished (has finishedAt),
// not just when the subscription stream ends (which can happen due to network issues)
useEffect(() => {
if (isComplete && run?.finishedAt && options?.onComplete && !hasCalledOnCompleteRef.current) {
options.onComplete(run, error);
if (isComplete && run?.finishedAt && onComplete && !hasCalledOnCompleteRef.current) {
onComplete(run, error);
hasCalledOnCompleteRef.current = true;
}
}, [isComplete, run, error, options?.onComplete]);
}, [isComplete, run, error, onComplete]);
useEffect(() => {
if (typeof options?.enabled === "boolean" && !options.enabled) {
@@ -335,18 +365,18 @@ export function useRealtimeRunWithStreams<
return;
}
triggerRequest().finally(() => {});
requestSubscription().finally(() => {});
return () => {
stop();
};
}, [runId, stop, options?.enabled]);
}, [runId, stop, options?.enabled, requestSubscription]);
useEffect(() => {
if (run?.finishedAt) {
setIsComplete(true);
}
}, [run]);
}, [run, setIsComplete]);
return { run, streams: streams ?? initialStreamsFallback, error, stop };
}
@@ -442,6 +472,8 @@ export function useRealtimeRunsWithTag<TTask extends AnyTask>(
}, []);
const apiClient = useApiClient(options);
const createdAt = options?.createdAt;
const skipColumns = options?.skipColumns;
const triggerRequest = useCallback(async () => {
try {
@@ -454,7 +486,7 @@ export function useRealtimeRunsWithTag<TTask extends AnyTask>(
await processRealtimeRunsWithTag(
tag,
{ createdAt: options?.createdAt, skipColumns: options?.skipColumns },
{ createdAt, skipColumns },
apiClient,
mutateRuns,
runsRef,
@@ -474,19 +506,20 @@ export function useRealtimeRunsWithTag<TTask extends AnyTask>(
abortControllerRef.current = null;
}
}
}, [normalizedTag, mutateRuns, runsRef, abortControllerRef, apiClient, setError]);
}, [tag, createdAt, skipColumns, apiClient, mutateRuns, setError]);
const requestSubscription = useStableRequestCallback(triggerRequest);
useEffect(() => {
if (typeof options?.enabled === "boolean" && !options.enabled) {
return;
}
triggerRequest().finally(() => {});
requestSubscription().finally(() => {});
return () => {
stop();
};
}, [normalizedTag, stop, options?.enabled]);
}, [normalizedTag, stop, options?.enabled, requestSubscription]);
return { runs: runs ?? [], error, stop };
}
@@ -572,18 +605,19 @@ export function useRealtimeBatch<TTask extends AnyTask>(
}
}
}, [batchId, mutateRuns, runsRef, abortControllerRef, apiClient, setError]);
const requestSubscription = useStableRequestCallback(triggerRequest);
useEffect(() => {
if (typeof options?.enabled === "boolean" && !options.enabled) {
return;
}
triggerRequest().finally(() => {});
requestSubscription().finally(() => {});
return () => {
stop();
};
}, [batchId, stop, options?.enabled]);
}, [batchId, stop, options?.enabled, requestSubscription]);
return { runs: runs ?? [], error, stop };
}
@@ -821,16 +855,20 @@ function useRealtimeStreamImplementation<TPart>(
}
}, []);
const onDataCallback = options?.onData;
const onData = useCallback(
(data: TPart) => {
if (options?.onData) {
options.onData(data);
if (onDataCallback) {
onDataCallback(data);
}
},
[options?.onData]
[onDataCallback]
);
const apiClient = useApiClient(options);
const timeoutInSeconds = options?.timeoutInSeconds;
const startIndex = options?.startIndex;
const throttleInMs = options?.throttleInMs;
const triggerRequest = useCallback(async () => {
try {
@@ -850,9 +888,9 @@ function useRealtimeStreamImplementation<TPart>(
setError,
onData,
abortControllerRef,
options?.timeoutInSeconds,
options?.startIndex,
options?.throttleInMs ?? 16
timeoutInSeconds,
startIndex,
throttleInMs ?? 16
);
} catch (err) {
// Ignore abort errors as they are expected.
@@ -870,7 +908,19 @@ function useRealtimeStreamImplementation<TPart>(
// Mark the subscription as complete
setIsComplete(true);
}
}, [runId, streamKey, mutateParts, partsRef, abortControllerRef, apiClient, setError]);
}, [
runId,
streamKey,
apiClient,
mutateParts,
setError,
setIsComplete,
onData,
timeoutInSeconds,
startIndex,
throttleInMs,
]);
const requestSubscription = useStableRequestCallback(triggerRequest);
useEffect(() => {
if (typeof options?.enabled === "boolean" && !options.enabled) {
@@ -881,12 +931,12 @@ function useRealtimeStreamImplementation<TPart>(
return;
}
triggerRequest().finally(() => {});
requestSubscription().finally(() => {});
return () => {
stop();
};
}, [runId, stop, options?.enabled]);
}, [runId, stop, options?.enabled, requestSubscription]);
return { parts: parts ?? initialPartsFallback, error, stop };
}