refactor: replace useEffectEvent polyfill with native React 19.2 hook (#24060)

This commit is contained in:
Danielle Maywood
2026-04-08 11:17:11 +01:00
committed by GitHub
parent 233343c010
commit 86b919e4f7
21 changed files with 149 additions and 285 deletions
@@ -1,5 +1,5 @@
import { SearchIcon, XIcon } from "lucide-react";
import { type Ref, useLayoutEffect, useRef } from "react";
import { type Ref, useEffectEvent, useLayoutEffect, useRef } from "react";
import {
InputGroup,
InputGroupAddon,
@@ -11,7 +11,6 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { useEffectEvent } from "#/hooks/hookPolyfills";
export type SearchFieldProps = {
value: string;
@@ -45,7 +44,7 @@ export const SearchField: React.FC<SearchFieldProps> = ({
});
useLayoutEffect(() => {
focusOnMount();
}, [focusOnMount]);
}, []);
const handleClear = () => {
if (onClear) {
-41
View File
@@ -1,41 +0,0 @@
import { renderHook } from "@testing-library/react";
import { useEffectEvent } from "./hookPolyfills";
describe(useEffectEvent.name, () => {
function renderEffectEvent<TArgs extends unknown[], TReturn = unknown>(
callbackArg: (...args: TArgs) => TReturn,
) {
type Callback = typeof callbackArg;
type Props = Readonly<{ callback: Callback }>;
return renderHook<Callback, Props>(
({ callback }) => useEffectEvent(callback),
{ initialProps: { callback: callbackArg } },
);
}
it("Should maintain a stable reference across all renders", () => {
const callback = vi.fn();
const { result, rerender } = renderEffectEvent(callback);
const firstResult = result.current;
for (let i = 0; i < 5; i++) {
rerender({ callback });
}
expect(result.current).toBe(firstResult);
expect.hasAssertions();
});
it("Should always call the most recent callback passed in", () => {
const mockCallback1 = vi.fn();
const mockCallback2 = vi.fn();
const { result, rerender } = renderEffectEvent(mockCallback1);
rerender({ callback: mockCallback2 });
result.current();
expect(mockCallback1).not.toBeCalled();
expect(mockCallback2).toBeCalledTimes(1);
});
});
-49
View File
@@ -1,49 +0,0 @@
/**
* @file For defining DIY versions of official React hooks that have not been
* released yet.
*
* These hooks should be deleted as soon as the official versions are available.
* They do not have the same ESLinter exceptions baked in that the official
* hooks do, especially for dependency arrays.
*/
import { useCallback, useLayoutEffect, useRef } from "react";
/**
* A DIY version of useEffectEvent.
*
* Works like useCallback, except that it doesn't take a dependency array, and
* always returns out the same function on every single render. The returned-out
* function is always able to "see" the most up-to-date version of the callback
* passed in (including its closure values).
*
* This is not a 1:1 replacement for useCallback. 99% of the time,
* useEffectEvent should be called in the same component/custom hook where you
* have a useEffect call. A useEffectEvent function probably shouldn't be a
* prop, unless you're trying to wrangle a weird library.
*
* Example uses of useEffectEvent:
* 1. Stabilizing a function that you don't have direct control over (because it
* comes from a library) without violating useEffect dependency arrays
* 2. Moving the burden of memoization from the parent to the custom hook (e.g.,
* making it so that you don't need your components to always use useCallback
* just to get things wired up properly. Similar example: the queryFn
* property on React Query's useQuery)
*
* @see {@link https://react.dev/reference/react/experimental_useEffectEvent}
*/
export function useEffectEvent<TArgs extends unknown[], TReturn = unknown>(
callback: (...args: TArgs) => TReturn,
) {
const callbackRef = useRef(callback);
// useLayoutEffect should be overkill here 99% of the time, but if this were
// defined as a regular effect, useEffectEvent would not be able to work with
// any layout effects at all; the callback sync here would fire *after* the
// layout effect that needs the useEffectEvent function
useLayoutEffect(() => {
callbackRef.current = callback;
}, [callback]);
return useCallback((...args: TArgs): TReturn => {
return callbackRef.current(...args);
}, []);
}
+3 -3
View File
@@ -2,9 +2,9 @@ import {
type KeyboardEventHandler,
type MouseEventHandler,
type RefObject,
useEffectEvent,
useRef,
} from "react";
import { useEffectEvent } from "./hookPolyfills";
// Literally any object (ideally an HTMLElement) that has a .click method
type ClickableElement = {
@@ -44,11 +44,11 @@ export const useClickable = <
role?: TRole,
): UseClickableResult<TElement, TRole> => {
const ref = useRef<TElement>(null);
const stableOnClick = useEffectEvent(onClick);
const onClickEvent = useEffectEvent(onClick);
return {
ref,
onClick: stableOnClick,
onClick: onClickEvent,
tabIndex: 0,
role: (role ?? "button") as TRole,
+27 -25
View File
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
useCallback,
useEffect,
useEffectEvent,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { useEffectEvent } from "./hookPolyfills";
const CLIPBOARD_TIMEOUT_MS = 1_000;
export const COPY_FAILED_MESSAGE = "Failed to copy text to clipboard";
@@ -55,7 +60,7 @@ export const useClipboard = (
return clearTimeoutOnUnmount;
}, []);
const stableOnError = useEffectEvent(() => onError(COPY_FAILED_MESSAGE));
const onErrorEvent = useEffectEvent(() => onError(COPY_FAILED_MESSAGE));
const handleSuccessfulCopy = useEffectEvent(() => {
setShowCopiedSuccess(true);
if (clearErrorOnSuccess) {
@@ -67,30 +72,27 @@ export const useClipboard = (
}, CLIPBOARD_TIMEOUT_MS);
});
const copyToClipboard = useCallback(
async (textToCopy: string) => {
try {
await window.navigator.clipboard.writeText(textToCopy);
const copyToClipboard = useCallback(async (textToCopy: string) => {
try {
await window.navigator.clipboard.writeText(textToCopy);
handleSuccessfulCopy();
} catch (err) {
const fallbackCopySuccessful = simulateClipboardWrite(textToCopy);
if (fallbackCopySuccessful) {
handleSuccessfulCopy();
} catch (err) {
const fallbackCopySuccessful = simulateClipboardWrite(textToCopy);
if (fallbackCopySuccessful) {
handleSuccessfulCopy();
return;
}
const wrappedErr = new Error(COPY_FAILED_MESSAGE);
if (err instanceof Error) {
wrappedErr.stack = err.stack;
}
console.error(wrappedErr);
setError(wrappedErr);
stableOnError();
return;
}
},
[stableOnError, handleSuccessfulCopy],
);
const wrappedErr = new Error(COPY_FAILED_MESSAGE);
if (err instanceof Error) {
wrappedErr.stack = err.stack;
}
console.error(wrappedErr);
setError(wrappedErr);
onErrorEvent();
}
}, []);
return { showCopiedSuccess, error, copyToClipboard };
};
+4 -5
View File
@@ -1,5 +1,5 @@
import clamp from "lodash/clamp";
import { useEffect } from "react";
import { useEffect, useEffectEvent } from "react";
import {
keepPreviousData,
type QueryFunctionContext,
@@ -10,7 +10,6 @@ import {
useQueryClient,
} from "react-query";
import { type SetURLSearchParams, useSearchParams } from "react-router";
import { useEffectEvent } from "./hookPolyfills";
const DEFAULT_RECORDS_PER_PAGE = 25;
@@ -201,13 +200,13 @@ export function usePaginatedQuery<
if (hasNextPage) {
void prefetchPage(currentPage + 1);
}
}, [prefetchPage, currentPage, hasNextPage]);
}, [currentPage, hasNextPage]);
useEffect(() => {
if (hasPreviousPage) {
void prefetchPage(currentPage - 1);
}
}, [prefetchPage, currentPage, hasPreviousPage]);
}, [currentPage, hasPreviousPage]);
// Mainly here to catch user if they navigate to a page directly via URL;
// totalPages parameterized to insulate function from fetch status changes
@@ -259,7 +258,7 @@ export function usePaginatedQuery<
) {
void updatePageIfInvalid(totalPages);
}
}, [updatePageIfInvalid, query.isFetching, totalPages, currentPage]);
}, [query.isFetching, totalPages, currentPage]);
const onPageChange = (newPage: number) => {
// Page 1 is the only page that can be safely navigated to without knowing
+2 -3
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from "react";
import { useEffectEvent } from "./hookPolyfills";
import { useEffect, useEffectEvent, useState } from "react";
interface UseTimeOptions {
/**
@@ -37,7 +36,7 @@ export function useTime<T>(func: () => T, options: UseTimeOptions = {}): T {
return () => {
clearInterval(handle);
};
}, [thunk, disabled, interval]);
}, [disabled, interval]);
return computedValue;
}
+5 -4
View File
@@ -2,7 +2,7 @@
* @fileoverview TODO: centralize navigation code here! URL constants, URL formatting, all of it
*/
import { useEffectEvent } from "#/hooks/hookPolyfills";
import { useCallback } from "react";
import type { DashboardValue } from "./dashboard/DashboardProvider";
import { useDashboard } from "./dashboard/useDashboard";
@@ -10,9 +10,10 @@ type LinkThunk = (state: DashboardValue) => string;
export function useLinks() {
const dashboard = useDashboard();
// Needs to be safe to call `get` from inside of a `useEffect` without causing
// excess triggers from adding it as a dependency.
const get = useEffectEvent((thunk: LinkThunk): string => thunk(dashboard));
const get = useCallback(
(thunk: LinkThunk): string => thunk(dashboard),
[dashboard],
);
return get;
}
@@ -1,4 +1,4 @@
import { type FC, useEffect } from "react";
import { type FC, useEffect, useEffectEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { toast } from "sonner";
import { watchInboxNotifications } from "#/api/api";
@@ -7,7 +7,6 @@ import type {
ListInboxNotificationsResponse,
UpdateInboxNotificationReadStatusResponse,
} from "#/api/typesGenerated";
import { useEffectEvent } from "#/hooks/hookPolyfills";
import { InboxPopover } from "./InboxPopover";
const NOTIFICATIONS_QUERY_KEY = ["notifications"];
@@ -86,7 +85,7 @@ export const NotificationsInbox: FC<NotificationsInboxProps> = ({
});
return () => socket.close();
}, [updateNotificationsCache]);
}, []);
const {
mutate: loadMoreNotifications,
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useEffectEvent } from "react";
import { useQuery, useQueryClient } from "react-query";
import { toast } from "sonner";
import { watchAgentContainers } from "#/api/api";
@@ -11,7 +11,6 @@ import type {
WorkspaceAgentDevcontainer,
WorkspaceAgentListContainersResponse,
} from "#/api/typesGenerated";
import { useEffectEvent } from "#/hooks/hookPolyfills";
export function useAgentContainers(
agent: WorkspaceAgent,
@@ -59,13 +58,7 @@ export function useAgentContainers(
});
return () => socket.close();
}, [
agent.id,
agent.status,
queryIsLoading,
queryError,
updateDevcontainersCache,
]);
}, [agent.id, agent.status, queryIsLoading, queryError]);
return devcontainers;
}
@@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
import { useEffect, useEffectEvent, useState } from "react";
import { watchBuildLogsByTemplateVersionId } from "#/api/api";
import type { ProvisionerJobLog, TemplateVersion } from "#/api/typesGenerated";
import { useEffectEvent } from "#/hooks/hookPolyfills";
export const useWatchVersionLogs = (
templateVersion: TemplateVersion | undefined,
options?: { onDone: () => Promise<unknown> },
@@ -14,7 +13,7 @@ export const useWatchVersionLogs = (
setLogs([]);
}
const stableOnDone = useEffectEvent(() => options?.onDone());
const onDoneEvent = useEffectEvent(() => options?.onDone());
const status = templateVersion?.job.status;
const canWatch = status === "running" || status === "pending";
useEffect(() => {
@@ -24,14 +23,14 @@ export const useWatchVersionLogs = (
const socket = watchBuildLogsByTemplateVersionId(templateVersionId, {
onError: (error) => console.error(error),
onDone: stableOnDone,
onDone: onDoneEvent,
onMessage: (newLog) => {
setLogs((current) => [...(current ?? []), newLog]);
},
});
return () => socket.close();
}, [stableOnDone, canWatch, templateVersionId]);
}, [canWatch, templateVersionId]);
return logs;
};
@@ -9,6 +9,7 @@ import {
type Ref,
useCallback,
useEffect,
useEffectEvent,
useId,
useImperativeHandle,
useRef,
@@ -20,7 +21,6 @@ import {
WebsocketBuilder,
WebsocketEvent,
} from "websocket-ts";
import { useEffectEvent } from "#/hooks/hookPolyfills";
import { useClipboard } from "#/hooks/useClipboard";
import { cn } from "#/utils/cn";
import { terminalWebsocketUrl } from "#/utils/terminal";
@@ -121,7 +121,7 @@ export const WorkspaceTerminal = ({
width: terminal.cols,
};
},
[reportTerminalError],
[],
);
const refit = useCallback(() => {
@@ -261,10 +261,8 @@ export const WorkspaceTerminal = ({
}, [
hasBeenVisible,
copyToClipboard,
handleOpenLink,
refit,
renderer,
reportTerminalError,
terminalFontFamily,
backgroundColor,
]);
@@ -462,13 +460,11 @@ export const WorkspaceTerminal = ({
containerUser,
errorMessage,
getTerminalDimensions,
handleStatusChange,
initialCommand,
loading,
operatingSystem,
reconnectionToken,
refit,
reportTerminalError,
terminal,
]);
@@ -1,9 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useEffectEvent, useRef, useState } from "react";
import { type InfiniteData, useQueryClient } from "react-query";
import { watchChat } from "#/api/api";
import { chatMessagesKey, updateInfiniteChatsCache } from "#/api/queries/chats";
import type * as TypesGen from "#/api/typesGenerated";
import { useEffectEvent } from "#/hooks/hookPolyfills";
import type { OneWayMessageEvent } from "#/utils/OneWayWebSocket";
import { createReconnectingWebSocket } from "#/utils/reconnectingWebSocket";
import type { ChatDetailError } from "../../utils/usageLimitMessage";
@@ -140,12 +139,10 @@ export const useChatStore = (
: undefined;
});
// Keep error-reason callbacks in refs so the WebSocket effect
// can call them without including them in its dependency array.
// This prevents the socket from tearing down when the parent
// re-renders with new callback identities.
const setChatErrorReasonStable = useEffectEvent(setChatErrorReason);
const clearChatErrorReasonStable = useEffectEvent(clearChatErrorReason);
// Wrap error-reason callbacks so the WebSocket effect can call
// them without including them in its dependency array.
const setChatErrorReasonEvent = useEffectEvent(setChatErrorReason);
const clearChatErrorReasonEvent = useEffectEvent(clearChatErrorReason);
// True once the initial REST page has resolved for the current
// chat. The WebSocket effect gates on this so that
@@ -538,7 +535,7 @@ export const useChatStore = (
store.clearRetryState();
}
if (nextStatus !== "error") {
clearChatErrorReasonStable(chatID);
clearChatErrorReasonEvent(chatID);
}
updateSidebarChat((chat) =>
chat.status === nextStatus
@@ -555,7 +552,7 @@ export const useChatStore = (
store.setChatStatus("error");
store.setStreamError(reason);
store.clearRetryState();
setChatErrorReasonStable(chatID, reason);
setChatErrorReasonEvent(chatID, reason);
updateSidebarChat((chat) =>
chat.status === "error" ? chat : { ...chat, status: "error" },
);
@@ -660,15 +657,7 @@ export const useChatStore = (
}
activeChatIDRef.current = null;
};
}, [
chatID,
initialDataLoaded,
queryClient,
store,
setChatErrorReasonStable,
clearChatErrorReasonStable,
upsertCacheMessages,
]);
}, [chatID, initialDataLoaded, queryClient, store]);
return {
store,
clearStreamError: () => {
@@ -4,12 +4,12 @@ import {
type RefCallback,
type RefObject,
useEffect,
useEffectEvent,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Button } from "#/components/Button/Button";
import { useEffectEvent } from "#/hooks/hookPolyfills";
import { cn } from "#/utils/cn";
// ===========================================================================
@@ -155,18 +155,18 @@ function useStickToBottom(): StickToBottomInstance {
}
});
const suppressNextResize = useEffectEvent(() => {
const suppressNextResize = () => {
stateRef.current.suppressNextResize = true;
});
};
const capturePrependSnapshot = useEffectEvent(() => {
const capturePrependSnapshot = () => {
const s = stateRef.current;
if (s.scrollElement) {
s.pendingPrepend = {
scrollHeight: s.scrollElement.scrollHeight,
};
}
});
};
// -----------------------------------------------------------------------
// Event handlers
@@ -430,63 +430,68 @@ function useStickToBottom(): StickToBottomInstance {
}
});
const scrollRef = useEffectEvent((el: HTMLDivElement | null) => {
// Ref callbacks must have stable identity — React cycles them
// on identity change, which leaks event listeners. Store the
// element in state and let a useEffect manage listeners.
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(
null,
);
useEffect(() => {
const s = stateRef.current;
const prev = s.scrollElement;
s.scrollElement = scrollElement;
if (!scrollElement) return;
if (prev) {
prev.removeEventListener("touchstart", handleTouchStart);
prev.removeEventListener("touchend", handleTouchEnd);
prev.removeEventListener("touchcancel", handleTouchEnd);
prev.removeEventListener("scroll", handleScroll);
prev.removeEventListener("wheel", handleWheel);
prev.removeEventListener("pointerdown", handlePointerDown);
}
s.lastClientHeight = scrollElement.clientHeight;
scrollElement.addEventListener("scroll", handleScroll, { passive: true });
scrollElement.addEventListener("wheel", handleWheel, { passive: true });
scrollElement.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
scrollElement.addEventListener("touchend", handleTouchEnd, {
passive: true,
});
scrollElement.addEventListener("touchcancel", handleTouchEnd, {
passive: true,
});
scrollElement.addEventListener("pointerdown", handlePointerDown);
if (s.viewportObserver) {
s.viewportObserver.disconnect();
s.viewportObserver = null;
}
const vo = new ResizeObserver(handleViewportResize);
vo.observe(scrollElement);
s.viewportObserver = vo;
s.scrollElement = el;
return () => {
scrollElement.removeEventListener("touchstart", handleTouchStart);
scrollElement.removeEventListener("touchend", handleTouchEnd);
scrollElement.removeEventListener("touchcancel", handleTouchEnd);
scrollElement.removeEventListener("scroll", handleScroll);
scrollElement.removeEventListener("wheel", handleWheel);
scrollElement.removeEventListener("pointerdown", handlePointerDown);
if (s.viewportObserver) {
s.viewportObserver.disconnect();
s.viewportObserver = null;
}
};
}, [scrollElement]);
if (el) {
s.lastClientHeight = el.clientHeight;
el.addEventListener("scroll", handleScroll, { passive: true });
el.addEventListener("wheel", handleWheel, { passive: true });
el.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
el.addEventListener("touchend", handleTouchEnd, {
passive: true,
});
el.addEventListener("touchcancel", handleTouchEnd, {
passive: true,
});
el.addEventListener("pointerdown", handlePointerDown);
const [contentElement, setContentElement] = useState<HTMLDivElement | null>(
null,
);
const vo = new ResizeObserver(handleViewportResize);
vo.observe(el);
s.viewportObserver = vo;
}
});
const contentRef = useEffectEvent((el: HTMLDivElement | null) => {
useEffect(() => {
const s = stateRef.current;
s.contentElement = contentElement;
if (!contentElement) return;
if (s.resizeObserver) {
s.resizeObserver.disconnect();
const ro = new ResizeObserver(handleContentResize);
ro.observe(contentElement);
s.resizeObserver = ro;
return () => {
ro.disconnect();
s.resizeObserver = null;
}
s.contentElement = el;
if (el) {
const ro = new ResizeObserver(handleContentResize);
ro.observe(el);
s.resizeObserver = ro;
}
});
};
}, [contentElement]);
// -----------------------------------------------------------------------
// Mouse tracking (instance-scoped)
@@ -527,33 +532,6 @@ function useStickToBottom(): StickToBottomInstance {
};
}, []);
// Cleanup on unmount.
useEffect(() => {
return () => {
const s = stateRef.current;
if (s.scrollElement) {
s.scrollElement.removeEventListener("scroll", handleScroll);
s.scrollElement.removeEventListener("wheel", handleWheel);
s.scrollElement.removeEventListener("touchstart", handleTouchStart);
s.scrollElement.removeEventListener("touchend", handleTouchEnd);
s.scrollElement.removeEventListener("touchcancel", handleTouchEnd);
s.scrollElement.removeEventListener("pointerdown", handlePointerDown);
}
if (s.resizeObserver) {
s.resizeObserver.disconnect();
}
if (s.viewportObserver) {
s.viewportObserver.disconnect();
}
};
}, [
handleScroll,
handleWheel,
handleTouchStart,
handleTouchEnd,
handlePointerDown,
]);
// Post-render consistency check. If we believe we're pinned
// to the bottom but the physical scroll position disagrees,
// correct it before the browser paints. This catches any
@@ -572,8 +550,8 @@ function useStickToBottom(): StickToBottomInstance {
});
return {
scrollRef,
contentRef,
scrollRef: setScrollElement,
contentRef: setContentElement,
scrollToBottom,
isAtBottom: isAtBottom || nearBottom,
suppressNextResize,
@@ -619,11 +597,11 @@ const ChatScrollContainer: FC<{
// Merge our callback ref with the external RefObject so both
// point at the same DOM node, and expose scrollToBottom to the
// parent via its imperative ref.
const mergedScrollRef = useEffectEvent((el: HTMLDivElement | null) => {
const mergedScrollRef = (el: HTMLDivElement | null) => {
scrollRef(el);
scrollContainerRef.current = el;
scrollToBottomRef.current = el ? () => scrollToBottom("instant") : null;
});
};
// -------------------------------------------------------------------
// Pagination sentinel (IntersectionObserver)
@@ -52,6 +52,7 @@ import {
type FC,
useContext,
useEffect,
useEffectEvent,
useRef,
useState,
} from "react";
@@ -84,7 +85,6 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { useEffectEvent } from "#/hooks/hookPolyfills";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { UserDropdownContent } from "#/modules/dashboard/Navbar/UserDropdown/UserDropdownContent";
import { useDashboard } from "#/modules/dashboard/useDashboard";
@@ -1419,7 +1419,7 @@ const LoadMoreSentinel: FC<{
isFetchingNextPage?: boolean;
}> = ({ onLoadMore, isFetchingNextPage }) => {
const sentinelRef = useRef<HTMLDivElement>(null);
const onLoadMoreStable = useEffectEvent(() => {
const onLoadMoreEvent = useEffectEvent(() => {
onLoadMore?.();
});
@@ -1438,14 +1438,14 @@ const LoadMoreSentinel: FC<{
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
onLoadMoreStable();
onLoadMoreEvent();
}
},
{ threshold: 0 },
);
observer.observe(el);
return () => observer.disconnect();
}, [isFetchingNextPage, onLoadMoreStable]);
}, [isFetchingNextPage]);
return (
<div ref={sentinelRef} className="flex items-center justify-center py-2">
@@ -2,6 +2,7 @@ import {
type FC,
useCallback,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
@@ -26,7 +27,6 @@ import type {
Workspace,
} from "#/api/typesGenerated";
import { Loader } from "#/components/Loader/Loader";
import { useEffectEvent } from "#/hooks/hookPolyfills";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { getInitialParameterValues } from "#/modules/workspaces/DynamicParameter/DynamicParameter";
import { generateWorkspaceName } from "#/modules/workspaces/generateWorkspaceName";
@@ -187,7 +187,7 @@ const CreateWorkspacePage: FC = () => {
return () => {
socket.close();
};
}, [realizedVersionId, onMessage, defaultOwner.id]);
}, [realizedVersionId, defaultOwner.id]);
const organizationId = templateQuery.data?.organization_id;
@@ -278,7 +278,7 @@ const CreateWorkspacePage: FC = () => {
if (autoCreateReady) {
void automateWorkspaceCreation();
}
}, [automateWorkspaceCreation, autoCreateReady]);
}, [autoCreateReady]);
const sortedParams = useMemo(() => {
if (!latestResponse?.parameters) {
@@ -1,5 +1,12 @@
import { CheckIcon, CopyIcon } from "lucide-react";
import { type FC, useEffect, useMemo, useRef, useState } from "react";
import {
type FC,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from "react";
import { API } from "#/api/api";
import { DetailedError } from "#/api/errors";
import type {
@@ -15,7 +22,6 @@ import { Label } from "#/components/Label/Label";
import { RadioGroup, RadioGroupItem } from "#/components/RadioGroup/RadioGroup";
import { Separator } from "#/components/Separator/Separator";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import { useEffectEvent } from "#/hooks/hookPolyfills";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { useClipboard } from "#/hooks/useClipboard";
import {
@@ -91,7 +97,7 @@ const TemplateEmbedPageExperimental: FC = () => {
return () => {
socket.close();
};
}, [template.active_version_id, onMessage, me]);
}, [template.active_version_id, me]);
const sortedParams = useMemo(() => {
if (!latestResponse?.parameters) {
@@ -1,4 +1,4 @@
import { type FC, useEffect } from "react";
import { type FC, useEffect, useEffectEvent } from "react";
import { useQuery, useQueryClient } from "react-query";
import { useParams } from "react-router";
import { toast } from "sonner";
@@ -13,7 +13,6 @@ import type { Workspace } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Loader } from "#/components/Loader/Loader";
import { Margins } from "#/components/Margins/Margins";
import { useEffectEvent } from "#/hooks/hookPolyfills";
import { WorkspaceReadyPage } from "./WorkspaceReadyPage";
const WorkspacePage: FC = () => {
@@ -99,7 +98,7 @@ const WorkspacePage: FC = () => {
});
return () => socket.close();
}, [updateWorkspaceData, workspaceId, workspaceName]);
}, [workspaceId, workspaceName]);
// Page statuses
const pageError =
@@ -1,6 +1,5 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useEffectEvent } from "react";
import type { WorkspaceResource } from "#/api/typesGenerated";
import { useEffectEvent } from "#/hooks/hookPolyfills";
import { useSearchParamsKey } from "#/hooks/useSearchParamsKey";
export const resourceOptionValue = (resource: WorkspaceResource) => {
return `${resource.type}_${resource.name}`;
@@ -35,7 +34,7 @@ export const useResourcesNav = (resources: WorkspaceResource[]) => {
);
useEffect(() => {
onResourceChanges(resources);
}, [onResourceChanges, resources]);
}, [resources]);
const select = useCallback(
(resource: WorkspaceResource) => {
@@ -1,6 +1,6 @@
import { CircleHelp } from "lucide-react";
import type { FC } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import { useMutation, useQuery } from "react-query";
import { useNavigate, useSearchParams } from "react-router";
import { API } from "#/api/api";
@@ -21,7 +21,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { useEffectEvent } from "#/hooks/hookPolyfills";
import { docs } from "#/utils/docs";
import { pageTitle } from "#/utils/page";
import type { AutofillBuildParameter } from "#/utils/richParameters";
@@ -146,7 +145,6 @@ const WorkspaceParametersPageExperimental: FC = () => {
}, [
templateVersionId,
workspace.latest_build.template_version_id,
onMessage,
workspace.owner_id,
]);
@@ -1,4 +1,4 @@
import { type FC, useMemo, useState } from "react";
import { type FC, useEffectEvent, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "react-query";
import { useSearchParams } from "react-router";
import { toast } from "sonner";
@@ -9,7 +9,6 @@ import { templates, templateVersionRoot } from "#/api/queries/templates";
import { workspaces } from "#/api/queries/workspaces";
import { useFilter } from "#/components/Filter/Filter";
import { useUserFilterMenu } from "#/components/Filter/UserFilter";
import { useEffectEvent } from "#/hooks/hookPolyfills";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { usePagination } from "#/hooks/usePagination";
import { useDashboard } from "#/modules/dashboard/useDashboard";
@@ -28,15 +27,14 @@ const ACTIVE_BUILDS_REFRESH_INTERVAL = 5_000;
const NO_ACTIVE_BUILDS_REFRESH_INTERVAL = 30_000;
function useSafeSearchParams() {
// Have to wrap setSearchParams because React Router doesn't make sure that
// the function's memory reference stays stable on each render, even though
// its logic never changes, and even though it has function update support
// Have to wrap setSearchParams because React Router doesn't guarantee
// a stable reference for setSearchParams across renders.
const [searchParams, setSearchParams] = useSearchParams();
const stableSetSearchParams = useEffectEvent(setSearchParams);
const setSearchParamsEvent = useEffectEvent(setSearchParams);
// Need this to be a tuple type, but can't use "as const", because that would
// make the whole array readonly and cause type mismatches downstream
return [searchParams, stableSetSearchParams] as ReturnType<
return [searchParams, setSearchParamsEvent] as ReturnType<
typeof useSearchParams
>;
}