From 6ac02449607155511200d0a8a5157d4b070c05f2 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 11 Feb 2026 00:12:57 +1100 Subject: [PATCH] fix: implement debounce `` (#22029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 🙂 --- .../WorkspaceParametersPageExperimental.tsx | 15 +++-- ...orkspaceParametersPageViewExperimental.tsx | 64 ++++++++++++------- 2 files changed, 52 insertions(+), 27 deletions(-) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx index 8350a35d01..0178a4e6a2 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx @@ -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 ; @@ -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={() => diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx index bdb6bfd382..9be64b2cc3 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx @@ -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 = {}; + 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 = {}; - 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) => (

{diagnostic.summary}

@@ -248,6 +265,7 @@ export const WorkspaceParametersPageViewExperimental: FC< disabled={ isSubmitting || disabled || + hasUnsyncedParameters || diagnostics.some( (diagnostic) => diagnostic.severity === "error", ) ||