fix: implement debounce <WorkspaceParametersPageViewExperimental /> (#22029)

Closes #22028

This pull-request simply takes debounces the message sent to our
web-socket backend and debounces it to ensure we're not overwriting the
users input as they type. As an added bonus this will debounce message
spam if people are going crazy on Radio Items or similar.

An extra flavour bit of flavour with resolving a good use-case for
`cn()` in diagnostic errors 🙂
This commit is contained in:
Jake Howell
2026-02-11 00:12:57 +11:00
committed by GitHub
parent 6338be3b30
commit 6ac0244960
2 changed files with 52 additions and 27 deletions
@@ -96,11 +96,18 @@ const WorkspaceParametersPageExperimental: FC = () => {
return;
}
if (!initialParamsSentRef.current && response.parameters?.length > 0) {
sendInitialParameters();
// Skip stale responses. If we've already sent a newer request,
// this response contains outdated parameter values that would
// overwrite the user's more recent input.
if (response.id < wsResponseId.current) {
return;
}
setLatestResponse(response);
if (!initialParamsSentRef.current && response.parameters?.length > 0) {
sendInitialParameters();
}
});
useEffect(() => {
@@ -197,7 +204,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
if (
latestBuildParametersLoading ||
!latestResponse ||
(!latestResponse && !wsError) ||
(ws.current && ws.current.readyState === WebSocket.CONNECTING)
) {
return <Loader />;
@@ -244,7 +251,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
autofillParameters={autofillParameters}
canChangeVersions={canChangeVersions}
parameters={sortedParams}
diagnostics={latestResponse.diagnostics}
diagnostics={latestResponse?.diagnostics ?? []}
isSubmitting={updateParameters.isPending}
onSubmit={handleSubmit}
onCancel={() =>
@@ -9,6 +9,7 @@ import { Label } from "components/Label/Label";
import { Link } from "components/Link/Link";
import { Spinner } from "components/Spinner/Spinner";
import { useFormik } from "formik";
import { useDebouncedFunction } from "hooks/debounce";
import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters";
import {
DynamicParameter,
@@ -16,6 +17,7 @@ import {
useValidationSchemaForDynamicParameters,
} from "modules/workspaces/DynamicParameter/DynamicParameter";
import type { FC } from "react";
import { cn } from "utils/cn";
import { docs } from "utils/docs";
import type { AutofillBuildParameter } from "utils/richParameters";
@@ -67,6 +69,23 @@ export const WorkspaceParametersPageViewExperimental: FC<
workspace.template_require_active_version &&
!canChangeVersions;
// Debounce websocket sends to avoid stale responses overwriting
// the form while the user is still typing.
const { debounced: sendDynamicParamsRequest } = useDebouncedFunction(
(parameter: PreviewParameter, value: string) => {
const formInputs: Record<string, string> = {};
const formParameters = form.values.rich_parameter_values ?? [];
for (const param of formParameters) {
if (param?.name && param?.value) {
formInputs[param.name] = param.value;
}
}
formInputs[parameter.name] = value;
sendMessage(formInputs);
},
500,
);
const handleChange = async (
parameter: PreviewParameter,
parameterField: string,
@@ -79,29 +98,27 @@ export const WorkspaceParametersPageViewExperimental: FC<
sendDynamicParamsRequest(parameter, value);
};
const sendDynamicParamsRequest = (
parameter: PreviewParameter,
value: string,
) => {
const formInputs: Record<string, string> = {};
const parameters = form.values.rich_parameter_values ?? [];
for (const param of parameters) {
if (param?.name && param?.value) {
formInputs[param.name] = param.value;
}
}
formInputs[parameter.name] = value;
sendMessage(formInputs);
};
useSyncFormParameters({
parameters,
formValues: form.values.rich_parameter_values ?? [],
setFieldValue: form.setFieldValue,
});
// True when the form holds values the backend hasn't evaluated
// yet (debounce pending or WS round-trip in flight).
const hasUnsyncedParameters = (form.values.rich_parameter_values ?? []).some(
(formParam) => {
const responseParam = parameters.find((p) => p.name === formParam.name);
if (!responseParam) {
return true;
}
const responseValue = responseParam.value.valid
? responseParam.value.value
: "";
return formParam.value !== responseValue;
},
);
const hasIncompatibleParameters = parameters.some((parameter) => {
if (!parameter.mutable && parameter.diagnostics.length > 0) {
return true;
@@ -155,12 +172,12 @@ export const WorkspaceParametersPageViewExperimental: FC<
{diagnostics.map((diagnostic, index) => (
<div
key={`diagnostic-${diagnostic.summary}-${index}`}
className={`text-xs flex flex-col rounded-md border px-4 pb-3 border-solid
${
diagnostic.severity === "error"
? " text-content-destructive border-border-destructive"
: " text-content-warning border-border-warning"
}`}
className={cn(
"text-xs flex flex-col rounded-md border px-4 pb-3 border-solid",
diagnostic.severity === "error"
? " text-content-destructive border-border-destructive"
: " text-content-warning border-border-warning",
)}
>
<div className="flex items-center m-0">
<p className="font-medium">{diagnostic.summary}</p>
@@ -248,6 +265,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
disabled={
isSubmitting ||
disabled ||
hasUnsyncedParameters ||
diagnostics.some(
(diagnostic) => diagnostic.severity === "error",
) ||