fix(webapp,react-hooks): enforce stable hook ordering (#4688)

## Summary

Enforce stable React hook ordering in the dashboard and React hooks
package.

Conditional hook calls now keep a consistent order, and overloaded
realtime stream arguments are resolved before entering the shared hook
implementation.

Base: `main`
This commit is contained in:
Chris Arderne
2026-08-19 16:35:41 +01:00
committed by GitHub
parent a302f650b9
commit 108f43ee9b
6 changed files with 54 additions and 60 deletions
+9 -2
View File
@@ -45,8 +45,8 @@
"typescript/consistent-type-imports": "error",
"import/no-duplicates": "error",
"import/namespace": "off",
"react-hooks/exhaustive-deps": "off",
"react-hooks/rules-of-hooks": "off",
"react/exhaustive-deps": "off",
"react/rules-of-hooks": "off",
"guard-for-in": "error",
"symbol-description": "error",
"no-unneeded-ternary": "error",
@@ -115,10 +115,17 @@
{
"files": ["apps/webapp/app/**/*.ts", "apps/webapp/app/**/*.tsx"],
"rules": {
"react/rules-of-hooks": "error",
"trigger-runops/no-control-plane-run-graph-access": "error",
"trigger-runops/no-control-plane-in-runops-slot": "error"
}
},
{
"files": ["packages/react-hooks/src/**/*.ts", "packages/react-hooks/src/**/*.tsx"],
"rules": {
"react/rules-of-hooks": "error"
}
},
{
"files": ["apps/webapp/app/**/*.test.ts", "apps/webapp/app/**/*.test.tsx"],
"rules": {
@@ -68,17 +68,15 @@ export const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.
const { container, root, thumb, text } = variations[variant];
if (props.shortcut) {
useShortcutKeys({
shortcut: props.shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
disabled: props.disabled,
});
}
useShortcutKeys({
shortcut: props.shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
disabled: props.disabled,
});
const labelElement = label ? (
<label
@@ -46,16 +46,14 @@ export function TextLink({
const innerRef = useRef<HTMLAnchorElement>(null);
const classes = variations[variant];
if (shortcut) {
useShortcutKeys({
shortcut: shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
});
}
useShortcutKeys({
shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
});
const renderShortcutKey = () =>
shortcut &&
@@ -602,12 +602,11 @@ function shouldLiveReload({
return true;
}
function TraceView({
run,
trace,
maximumLiveReloadingSetting,
resizable,
}: Pick<LoaderData, "run" | "trace" | "maximumLiveReloadingSetting" | "resizable">) {
type TraceViewProps = Pick<LoaderData, "run" | "maximumLiveReloadingSetting" | "resizable"> & {
trace: NonNullable<LoaderData["trace"]>;
};
function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: TraceViewProps) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
@@ -616,10 +615,6 @@ function TraceView({
const frozenSpanId = useFrozenValue(selectedSpanId);
const displaySpanId = selectedSpanId ?? frozenSpanId;
if (!trace) {
return <></>;
}
const {
events,
duration,
@@ -295,15 +295,11 @@ export const handle: Handle = {
export default function Page() {
const result = useTypedLoaderData<typeof loader>();
if (!result.foundTask) {
return <div />;
}
const params = useParams();
const queueFetcher = useFetcher<typeof queuesLoader>();
useEffect(() => {
if (params.organizationSlug && params.projectParam && params.envParam) {
if (result.foundTask && params.organizationSlug && params.projectParam && params.envParam) {
const searchParams = new URLSearchParams();
searchParams.set("type", "custom");
searchParams.set("per_page", "100");
@@ -314,9 +310,9 @@ export default function Page() {
}/queues?${searchParams.toString()}`
);
}
}, [params.organizationSlug, params.projectParam, params.envParam]);
}, [result.foundTask, params.organizationSlug, params.projectParam, params.envParam]);
const defaultTaskQueue = "queue" in result ? result.queue : undefined;
const defaultTaskQueue = result.foundTask && "queue" in result ? result.queue : undefined;
const queues = useMemo(() => {
const customQueues = queueFetcher.data?.queues ?? [];
@@ -325,6 +321,10 @@ export default function Page() {
: customQueues;
}, [queueFetcher.data?.queues, defaultTaskQueue]);
if (!result.foundTask) {
return <div />;
}
const { triggerSource } = result;
switch (triggerSource) {
+16 -20
View File
@@ -750,33 +750,29 @@ export function useRealtimeStream<TPart>(
streamKeyOrOptionsOrRunId?: string | UseRealtimeStreamOptions<TPart>,
options?: UseRealtimeStreamOptions<TPart>
): UseRealtimeStreamInstance<TPart> {
let runId: string;
let streamKey: string;
let resolvedOptions: UseRealtimeStreamOptions<TPart> | undefined;
if (typeof runIdOrDefinedStream === "string") {
if (typeof streamKeyOrOptionsOrRunId === "string") {
return useRealtimeStreamImplementation(
runIdOrDefinedStream,
streamKeyOrOptionsOrRunId,
options
);
} else {
return useRealtimeStreamImplementation(
runIdOrDefinedStream,
"default",
streamKeyOrOptionsOrRunId
);
}
runId = runIdOrDefinedStream;
streamKey =
typeof streamKeyOrOptionsOrRunId === "string" ? streamKeyOrOptionsOrRunId : "default";
resolvedOptions =
typeof streamKeyOrOptionsOrRunId === "string" ? options : streamKeyOrOptionsOrRunId;
} else {
if (typeof streamKeyOrOptionsOrRunId === "string") {
return useRealtimeStreamImplementation(
streamKeyOrOptionsOrRunId,
runIdOrDefinedStream.id,
options
);
} else {
if (typeof streamKeyOrOptionsOrRunId !== "string") {
throw new Error(
"Invalid second argument to useRealtimeStream. When using a defined stream instance, the second argument to useRealtimeStream must be a run ID."
);
}
runId = streamKeyOrOptionsOrRunId;
streamKey = runIdOrDefinedStream.id;
resolvedOptions = options;
}
return useRealtimeStreamImplementation(runId, streamKey, resolvedOptions);
}
function useRealtimeStreamImplementation<TPart>(