Compare commits

...

1 Commits

Author SHA1 Message Date
35C4n0r ec979f1aea fix(site): prevent stale WebSocket responses from overwriting textarea input
When typing in a textarea parameter on the Create Workspace page, the
useSyncFormParameters hook would overwrite in-progress user input with
stale WebSocket responses. The sequence: user types 'abc', the debounced
request sends 'abc' to the server, user continues typing 'abcde', the
server responds with 'abc', and the sync hook overwrites the form back
to 'abc' — losing the characters typed after the request was sent.

Fix: track the last values sent to the WebSocket in a ref and pass it
to useSyncFormParameters. When the server echoes back the exact value
we sent but the form has already moved on (user kept typing), preserve
the form value. Server-side transformations (where the response differs
from what was sent) are still applied correctly.
2026-03-23 13:46:24 +00:00
4 changed files with 305 additions and 2 deletions
@@ -0,0 +1,270 @@
import { renderHook } from "@testing-library/react";
import type {
PreviewParameter,
WorkspaceBuildParameter,
} from "api/typesGenerated";
import type { RefObject } from "react";
import { useSyncFormParameters } from "./useSyncFormParameters";
/**
* Creates a minimal PreviewParameter with the given name and value.
* Other required fields are filled with sensible defaults.
*/
function makeParam(
name: string,
value: string,
valid: boolean,
): PreviewParameter {
return {
name,
display_name: name,
description: "",
type: "string",
form_type: "input",
styling: {},
mutable: true,
default_value: { value: "", valid: true },
icon: "",
options: [],
validations: [],
required: false,
order: 0,
ephemeral: false,
value: { value, valid },
diagnostics: [],
};
}
type Props = {
parameters: readonly PreviewParameter[];
formValues: readonly WorkspaceBuildParameter[];
setFieldValue: (field: string, value: WorkspaceBuildParameter[]) => void;
lastSentValues?: RefObject<Map<string, string>>;
};
function renderSyncHook(initialProps: Props) {
return renderHook((props: Props) => useSyncFormParameters(props), {
initialProps,
});
}
describe("useSyncFormParameters", () => {
it("updates form values when server returns new valid values", () => {
const setFieldValue = vi.fn();
const parameters = [
makeParam("region", "us-east-1", true),
makeParam("size", "large", true),
];
const formValues: WorkspaceBuildParameter[] = [
{ name: "region", value: "us-west-2" },
{ name: "size", value: "small" },
];
renderSyncHook({ parameters, formValues, setFieldValue });
expect(setFieldValue).toHaveBeenCalledTimes(1);
expect(setFieldValue).toHaveBeenCalledWith("rich_parameter_values", [
{ name: "region", value: "us-east-1" },
{ name: "size", value: "large" },
]);
});
it("preserves existing form value when param.value.valid is false", () => {
const setFieldValue = vi.fn();
const parameters = [makeParam("region", "", false)];
const formValues: WorkspaceBuildParameter[] = [
{ name: "region", value: "us-west-2" },
];
renderSyncHook({ parameters, formValues, setFieldValue });
// The form value should stay at "us-west-2" since the server
// value is invalid. Because the form already holds "us-west-2",
// the hook should not call setFieldValue (no change detected).
expect(setFieldValue).not.toHaveBeenCalled();
});
it("uses empty string for invalid value when no existing form value exists", () => {
const setFieldValue = vi.fn();
const parameters = [makeParam("new_param", "", false)];
// The form has no entry for "new_param" yet.
const formValues: WorkspaceBuildParameter[] = [];
renderSyncHook({ parameters, formValues, setFieldValue });
expect(setFieldValue).toHaveBeenCalledWith("rich_parameter_values", [
{ name: "new_param", value: "" },
]);
});
it("preserves form value when server echoes back stale sent value", () => {
const setFieldValue = vi.fn();
// The user typed "hello w" and the server echoes "hello"
// (the value we previously sent). The form has already
// moved on to "hello w", so the hook should keep it.
const lastSentValues: RefObject<Map<string, string>> = {
current: new Map([["greeting", "hello"]]),
};
const parameters = [makeParam("greeting", "hello", true)];
const formValues: WorkspaceBuildParameter[] = [
{ name: "greeting", value: "hello w" },
];
renderSyncHook({
parameters,
formValues,
setFieldValue,
lastSentValues,
});
// The form already holds the desired value "hello w", so
// setFieldValue should not be called (no change).
expect(setFieldValue).not.toHaveBeenCalled();
});
it("applies server transformation even when form has diverged", () => {
const setFieldValue = vi.fn();
// The server returned "HELLO" (a transformation of the
// sent value "hello"). Even though the form has "hello w",
// the server value differs from what we sent, so it should
// be applied.
const lastSentValues: RefObject<Map<string, string>> = {
current: new Map([["greeting", "hello"]]),
};
const parameters = [makeParam("greeting", "HELLO", true)];
const formValues: WorkspaceBuildParameter[] = [
{ name: "greeting", value: "hello w" },
];
renderSyncHook({
parameters,
formValues,
setFieldValue,
lastSentValues,
});
// The server transformed the value, so the hook applies it.
expect(setFieldValue).toHaveBeenCalledWith("rich_parameter_values", [
{ name: "greeting", value: "HELLO" },
]);
});
it("applies server value when form matches lastSentValues (no user divergence)", () => {
const setFieldValue = vi.fn();
// The user has not typed anything new since we sent "hello",
// so the server echo should be applied as-is.
const lastSentValues: RefObject<Map<string, string>> = {
current: new Map([["greeting", "hello"]]),
};
const parameters = [makeParam("greeting", "hello", true)];
const formValues: WorkspaceBuildParameter[] = [
{ name: "greeting", value: "hello" },
];
renderSyncHook({
parameters,
formValues,
setFieldValue,
lastSentValues,
});
// No change because form already matches the server value.
expect(setFieldValue).not.toHaveBeenCalled();
});
it("behaves identically to basic sync when lastSentValues is not provided", () => {
const setFieldValue = vi.fn();
const parameters = [makeParam("region", "us-east-1", true)];
const formValues: WorkspaceBuildParameter[] = [
{ name: "region", value: "us-west-2" },
];
// No lastSentValues provided — backward-compatible path.
renderSyncHook({ parameters, formValues, setFieldValue });
expect(setFieldValue).toHaveBeenCalledWith("rich_parameter_values", [
{ name: "region", value: "us-east-1" },
]);
});
it("does not call setFieldValue when form values already match parameters", () => {
const setFieldValue = vi.fn();
const parameters = [makeParam("region", "us-east-1", true)];
const formValues: WorkspaceBuildParameter[] = [
{ name: "region", value: "us-east-1" },
];
renderSyncHook({ parameters, formValues, setFieldValue });
expect(setFieldValue).not.toHaveBeenCalled();
});
it("re-syncs when parameters change across renders", () => {
const setFieldValue = vi.fn();
const formValues: WorkspaceBuildParameter[] = [
{ name: "region", value: "us-west-2" },
];
const initialParams = [makeParam("region", "us-west-2", true)];
const { rerender } = renderSyncHook({
parameters: initialParams,
formValues,
setFieldValue,
});
// Initial render: no change because form matches.
expect(setFieldValue).not.toHaveBeenCalled();
// Server pushes a new parameter value.
const updatedParams = [makeParam("region", "eu-west-1", true)];
rerender({
parameters: updatedParams,
formValues,
setFieldValue,
});
expect(setFieldValue).toHaveBeenCalledWith("rich_parameter_values", [
{ name: "region", value: "eu-west-1" },
]);
});
it("handles multiple parameters with mixed stale and fresh responses", () => {
const setFieldValue = vi.fn();
const lastSentValues: RefObject<Map<string, string>> = {
current: new Map([
["greeting", "hello"],
["farewell", "bye"],
]),
};
const parameters = [
// Stale: server echoes "hello" but form has "hello w".
makeParam("greeting", "hello", true),
// Fresh transformation: server returns "GOODBYE" (not "bye").
makeParam("farewell", "GOODBYE", true),
];
const formValues: WorkspaceBuildParameter[] = [
{ name: "greeting", value: "hello w" },
{ name: "farewell", value: "bye" },
];
renderSyncHook({
parameters,
formValues,
setFieldValue,
lastSentValues,
});
expect(setFieldValue).toHaveBeenCalledWith("rich_parameter_values", [
// Stale response: form value preserved.
{ name: "greeting", value: "hello w" },
// Server transformation: applied.
{ name: "farewell", value: "GOODBYE" },
]);
});
});
@@ -1,6 +1,6 @@
import type * as TypesGen from "api/typesGenerated";
import type { PreviewParameter } from "api/typesGenerated";
import { useEffect, useRef } from "react";
import { type RefObject, useEffect, useRef } from "react";
type UseSyncFormParametersProps = {
parameters: readonly PreviewParameter[];
@@ -9,12 +9,19 @@ type UseSyncFormParametersProps = {
field: string,
value: TypesGen.WorkspaceBuildParameter[],
) => void;
// A ref holding the most recent parameter values sent to the
// WebSocket. Used to detect stale responses: when the server
// echoes back the same value we sent but the form has already
// moved on (the user kept typing), we preserve the form value
// instead of overwriting it.
lastSentValues?: RefObject<Map<string, string>>;
};
export function useSyncFormParameters({
parameters,
formValues,
setFieldValue,
lastSentValues,
}: UseSyncFormParametersProps) {
// Form values only needs to be updated when parameters change
// Keep track of form values in a ref to avoid unnecessary updates to rich_parameter_values
@@ -22,6 +29,7 @@ export function useSyncFormParameters({
formValuesRef.current = formValues;
// biome-ignore lint/correctness/useExhaustiveDependencies: lastSentValues is a stable ref whose .current is read lazily inside the effect.
useEffect(() => {
if (!parameters) return;
const currentFormValues = formValuesRef.current;
@@ -43,6 +51,24 @@ export function useSyncFormParameters({
}
}
// Detect stale WebSocket responses. If the server
// returned the exact value we last sent but the form
// already holds something newer (the user kept typing
// after the request was fired), preserve the form value
// to avoid overwriting in-progress input.
if (param.value.valid && lastSentValues?.current) {
const sentValue = lastSentValues.current.get(param.name);
const formValue = currentFormValuesMap.get(param.name);
if (
sentValue !== undefined &&
param.value.value === sentValue &&
formValue !== undefined &&
formValue !== sentValue
) {
return { name: param.name, value: formValue };
}
}
return {
name: param.name,
value: param.value.valid ? param.value.value : "",
@@ -114,6 +114,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
const [showPresetParameters, setShowPresetParameters] = useState(false);
const id = useId();
const workspaceNameInputRef = useRef<HTMLInputElement>(null);
const lastSentValuesRef = useRef<Map<string, string>>(new Map());
const rerollSuggestedName = useCallback(() => {
setSuggestedName(() => generateWorkspaceName());
}, []);
@@ -236,6 +237,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
}
}
lastSentValuesRef.current = new Map(Object.entries(formInputs));
sendMessage(formInputs, ownerId);
},
500,
@@ -346,6 +348,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
parameters,
formValues: form.values.rich_parameter_values ?? [],
setFieldValue: form.setFieldValue,
lastSentValues: lastSentValuesRef,
});
const disabled =
@@ -16,7 +16,7 @@ import {
getInitialParameterValues,
useValidationSchemaForDynamicParameters,
} from "modules/workspaces/DynamicParameter/DynamicParameter";
import type { FC } from "react";
import { type FC, useRef } from "react";
import { cn } from "utils/cn";
import { docs } from "utils/docs";
import type { AutofillBuildParameter } from "utils/richParameters";
@@ -69,6 +69,8 @@ export const WorkspaceParametersPageViewExperimental: FC<
workspace.template_require_active_version &&
!canChangeVersions;
const lastSentValuesRef = useRef<Map<string, string>>(new Map());
// Debounce websocket sends to avoid stale responses overwriting
// the form while the user is still typing.
const { debounced: sendDynamicParamsRequest } = useDebouncedFunction(
@@ -81,6 +83,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
}
}
formInputs[parameter.name] = value;
lastSentValuesRef.current = new Map(Object.entries(formInputs));
sendMessage(formInputs);
},
500,
@@ -102,6 +105,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
parameters,
formValues: form.values.rich_parameter_values ?? [],
setFieldValue: form.setFieldValue,
lastSentValues: lastSentValuesRef,
});
// True when the form holds values the backend hasn't evaluated