Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion frontend/src/components/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,10 @@ export const OnBlurredInput = React.forwardRef<
onChange={(event) => setInternalValue(event.target.value)}
onBlur={() => setValue(internalValue || "")}
onKeyDown={(event) => {
if (event.key !== "Enter") {
// Only commit on plain Enter. Shift+Enter should not submit the
// enclosing form — let the event bubble naturally (e.g. newline in a
// textarea, or no-op in a single-line input).
if (event.key !== "Enter" || event.shiftKey) {
return;
}
setValue(internalValue || "");
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/plugins/impl/FormPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,13 @@ export const FormWrapper = <T,>({
"bg-(--amber-1) border-(--amber-7)": !synchronized && bordered,
})}
onKeyDown={(evt) => {
// Handle enter + ctrl/meta key
// Handle enter + ctrl/meta key — route through the form's onSubmit
// so that validation and clearOnSubmit are applied consistently,
// just as when the submit button is clicked.
if (evt.key === "Enter" && (evt.ctrlKey || evt.metaKey)) {
evt.preventDefault();
evt.stopPropagation();
setValue(newValue);
formDiv.current?.requestSubmit();
}
}}
>
Expand Down
186 changes: 186 additions & 0 deletions frontend/src/plugins/impl/__tests__/FormPlugin.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { SetupMocks } from "@/__mocks__/common";
import { FormWrapper, type FormWrapperProps } from "../FormPlugin";

beforeAll(() => {
SetupMocks.resizeObserver();
});

function renderForm(overrides: Partial<FormWrapperProps<string>> = {}) {
const setValue = vi.fn();
const validate = vi.fn().mockResolvedValue(null);

render(
<FormWrapper<string>
label={null}
bordered={false}
loading={false}
submitButtonLabel="Submit"
submitButtonDisabled={false}
clearOnSubmit={false}
showClearButton={false}
clearButtonLabel="Clear"
currentValue="old"
newValue="new"
setValue={setValue}
validate={validate}
shouldValidate={false}
{...overrides}
>
<input data-testid="inner-input" />
</FormWrapper>,
);

return { setValue, validate };
}

describe("FormWrapper — submit button", () => {
it("calls setValue when the submit button is clicked", async () => {
const { setValue } = renderForm();
fireEvent.click(screen.getByTestId("marimo-plugin-form-submit-button"));
await waitFor(() => expect(setValue).toHaveBeenCalledWith("new"));
});

it("does NOT call setValue when shouldValidate=true and validate returns an error", async () => {
const validate = vi.fn().mockResolvedValue("Invalid input");
const { setValue } = renderForm({ shouldValidate: true, validate });
fireEvent.click(screen.getByTestId("marimo-plugin-form-submit-button"));
await waitFor(() =>
expect(screen.getByText("Invalid input")).toBeInTheDocument(),
);
expect(setValue).not.toHaveBeenCalled();
});

it("calls setValue when shouldValidate=true and validate returns null", async () => {
const validate = vi.fn().mockResolvedValue(null);
const { setValue } = renderForm({ shouldValidate: true, validate });
fireEvent.click(screen.getByTestId("marimo-plugin-form-submit-button"));
await waitFor(() => expect(setValue).toHaveBeenCalledWith("new"));
});
});

describe("FormWrapper — Ctrl+Enter / Cmd+Enter", () => {
it("submits (calls setValue) on Ctrl+Enter", async () => {
const { setValue } = renderForm();
const form = screen
.getByTestId("marimo-plugin-form-submit-button")
.closest("form")!;
// requestSubmit is not implemented in jsdom — spy on it and trigger submit
const requestSubmit = vi
.spyOn(form, "requestSubmit")
.mockImplementation(() => {
fireEvent.submit(form);
});

fireEvent.keyDown(form.firstElementChild!, {
key: "Enter",
ctrlKey: true,
});

expect(requestSubmit).toHaveBeenCalled();
await waitFor(() => expect(setValue).toHaveBeenCalledWith("new"));
});

it("submits (calls setValue) on Cmd+Enter (metaKey)", async () => {
const { setValue } = renderForm();
const form = screen
.getByTestId("marimo-plugin-form-submit-button")
.closest("form")!;
vi.spyOn(form, "requestSubmit").mockImplementation(() => {
fireEvent.submit(form);
});

fireEvent.keyDown(form.firstElementChild!, {
key: "Enter",
metaKey: true,
});

await waitFor(() => expect(setValue).toHaveBeenCalledWith("new"));
});

it("runs validation on Ctrl+Enter when shouldValidate=true", async () => {
const validate = vi.fn().mockResolvedValue("Bad value");
const { setValue } = renderForm({ shouldValidate: true, validate });
const form = screen
.getByTestId("marimo-plugin-form-submit-button")
.closest("form")!;
vi.spyOn(form, "requestSubmit").mockImplementation(() => {
fireEvent.submit(form);
});

fireEvent.keyDown(form.firstElementChild!, {
key: "Enter",
ctrlKey: true,
});

await waitFor(() =>
expect(screen.getByText("Bad value")).toBeInTheDocument(),
);
expect(setValue).not.toHaveBeenCalled();
});

it("applies clearOnSubmit on Ctrl+Enter", async () => {
const { setValue } = renderForm({ clearOnSubmit: true });
const form = screen
.getByTestId("marimo-plugin-form-submit-button")
.closest("form")!;
vi.spyOn(form, "requestSubmit").mockImplementation(() => {
fireEvent.submit(form);
});

fireEvent.keyDown(form.firstElementChild!, {
key: "Enter",
ctrlKey: true,
});

await waitFor(() => expect(setValue).toHaveBeenCalledWith("new"));
});

it("does NOT submit on plain Enter (no modifier)", () => {
const { setValue } = renderForm();
const form = screen
.getByTestId("marimo-plugin-form-submit-button")
.closest("form")!;
const requestSubmit = vi.spyOn(form, "requestSubmit");

fireEvent.keyDown(form.firstElementChild!, { key: "Enter" });

expect(requestSubmit).not.toHaveBeenCalled();
expect(setValue).not.toHaveBeenCalled();
});
});

describe("FormWrapper — clear button", () => {
it("renders a clear button when showClearButton=true", () => {
renderForm({ showClearButton: true });
expect(
screen.getByTestId("marimo-plugin-form-clear-button"),
).toBeInTheDocument();
});

it("does NOT render a clear button when showClearButton=false", () => {
renderForm({ showClearButton: false });
expect(
screen.queryByTestId("marimo-plugin-form-clear-button"),
).not.toBeInTheDocument();
});
});

describe("FormWrapper — loading state", () => {
it("disables the submit button when loading=true", () => {
renderForm({ loading: true });
expect(
screen.getByTestId("marimo-plugin-form-submit-button"),
).toBeDisabled();
});

it("disables the submit button when submitButtonDisabled=true", () => {
renderForm({ submitButtonDisabled: true });
expect(
screen.getByTestId("marimo-plugin-form-submit-button"),
).toBeDisabled();
});
});
14 changes: 11 additions & 3 deletions marimo/_plugins/ui/_core/ui_element.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,15 +452,23 @@ def _update(self, value: S) -> None:
Calls the on_change handler with the element's new value as a
side-effect.
"""
self._update_value(value)
if self._on_change is not None:
self._on_change(self._value)

def _update_value(self, value: S) -> None:
"""Update value without triggering the on_change callback.

Used internally when a parent element (e.g. form) needs to hydrate
this element's value as part of its own conversion, without
double-firing the element's on_change handler.
"""
self._value_frontend = value
try:
self._value = self._convert_value(value)
except MarimoConvertValueException:
raise

if self._on_change is not None:
self._on_change(self._value)

def _on_update_completion(self) -> bool:
"""Callback to run after the kernel has processed a value update.

Expand Down
5 changes: 4 additions & 1 deletion marimo/_plugins/ui/_impl/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -1578,7 +1578,10 @@ def _validate(self, value: ValueArgs) -> Optional[str]:
def _convert_value(self, value: Optional[JSONTypeBound]) -> Optional[T]:
if value is None:
return None
self.element._update(value)
# Use _update_value (not _update) to hydrate the wrapped element's
# value without triggering its on_change handler — the form's own
# on_change is what should fire, not the wrapped element's.
self.element._update_value(value)
return self.element.value


Expand Down
54 changes: 54 additions & 0 deletions tests/_plugins/ui/_impl/test_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -677,3 +677,57 @@ def test_power_scale() -> None:
assert range_slider.start == 1
assert range_slider.stop == 9
assert range_slider.step is None


# ---------------------------------------------------------------------------
# form — on_change double-fire regression tests (issue #8324)
# ---------------------------------------------------------------------------


def test_form_convert_value_does_not_fire_element_on_change() -> None:
"""Submitting a form must NOT trigger the wrapped element's on_change.

Before the fix, form._convert_value called element._update(), which
always fired the element's on_change callback. After the fix it calls
element._update_value(), which skips that callback.
"""
element_changes: list[Any] = []
text = ui.text(on_change=lambda v: element_changes.append(v))
f = text.form()

# Simulate a form submission: the form receives the wrapped element's
# serialised value from the frontend.
f._update("hello")

# The form's value should be updated …
assert f._value == "hello"
# … but the wrapped element's on_change must NOT have been called.
assert element_changes == [], (
"Wrapped element's on_change fired during form submission — "
"this is the double-fire bug described in issue #8324."
)


def test_form_on_change_fires_once_on_submit() -> None:
"""The form's own on_change must fire exactly once per submission."""
form_changes: list[Any] = []
text = ui.text()
f = text.form(on_change=lambda v: form_changes.append(v))

f._update("world")

assert form_changes == ["world"]


def test_form_update_value_does_not_trigger_on_change() -> None:
"""_update_value is the silent counterpart to _update."""
changes: list[Any] = []
text = ui.text(on_change=lambda v: changes.append(v))

text._update_value("silent")
assert text._value == "silent"
assert changes == [], "_update_value must not trigger on_change"

# Contrast: _update *should* trigger on_change.
text._update("loud")
assert changes == ["loud"]
Loading