diff --git a/.changeset/27483-invalid-typed-date-refuses-submit.md b/.changeset/27483-invalid-typed-date-refuses-submit.md
new file mode 100644
index 00000000000..dc69cb107b8
--- /dev/null
+++ b/.changeset/27483-invalid-typed-date-refuses-submit.md
@@ -0,0 +1,14 @@
+---
+'@object-ui/fields': patch
+'@object-ui/components': patch
+---
+
+fix(fields, components): a date typed in a form the picker cannot read is a field error that refuses submit, not a silent drop (#27483)
+
+Clause-②: yes (widening)
+Metadata-protocol: none
+
+Typing `13/45/2026` into a record form's date field and pressing Create saved the record with the date empty (or, on edit, with the old date) and a success toast. The picker never writes text that names no date, so the form value stayed what it was and nothing told the form.
+
+- **Now.** The text box is marked invalid as soon as the user leaves it, the field shows the picker's message (the typed text and the expected format) as its form error, and submit is refused the way a missing required value is: the dialog stays open and nothing is sent. Correcting or clearing the text withdraws the error. `datetime` fields behave the same.
+- **`onInvalidInputChange`** is a new optional widget prop on `FieldWidgetComponentProps`, the widget props type `@object-ui/fields` exports. The key is optional, so every existing widget and host compiles and behaves as before; no consumer breaks. It is fired as follows: a widget that parses typed text fires it with a message when the committed text names no value, and with `null` once it is valid, cleared, or unmounted. The `form` renderer (`@object-ui/components`) turns the message into the field's `invalidInput` validation error and passes the callback to the `date` / `datetime` widgets only. A widget given the callback leaves the message text to the form; hosts that pass none (the grid's inline editor, the action-param dialog) keep the picker's own notice, unchanged.
diff --git a/ui/packages/components/src/renderers/form/__tests__/form-invalid-input-refuses-submit-27483.test.tsx b/ui/packages/components/src/renderers/form/__tests__/form-invalid-input-refuses-submit-27483.test.tsx
new file mode 100644
index 00000000000..9b72523fc35
--- /dev/null
+++ b/ui/packages/components/src/renderers/form/__tests__/form-invalid-input-refuses-submit-27483.test.tsx
@@ -0,0 +1,115 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectstack#27483 — the form renderer's half of the `onInvalidInputChange`
+ * channel: a widget's report of text it could not parse becomes the field's
+ * validation error and refuses submit; withdrawing it lets the submit through;
+ * and only the widgets that report receive the callback.
+ *
+ * The real date picker lives in `@object-ui/fields`, which components tests
+ * never load (its end-to-end pin is there); a probe stands in for it here.
+ */
+
+import React from 'react';
+import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { ComponentRegistry } from '@object-ui/core';
+// Module scope, not `beforeAll` — the cold transform must not be billed to
+// `hookTimeout`. See object-ui/no-dynamic-import-in-test-hook (objectui#3010).
+import '../../../renderers';
+
+const MESSAGE = 'Not saved: "13/45/2026" is not a date.';
+
+type ProbeProps = {
+ field?: { name?: string };
+ onInvalidInputChange?: (message: string | null) => void;
+};
+
+function ReportingProbe(props: ProbeProps) {
+ return (
+
+
+
+
+ );
+}
+
+beforeAll(() => {
+ ComponentRegistry.register('field:date', ReportingProbe, { namespace: 'test' });
+ // A registered widget that does not report: the strip must keep the key off it.
+ ComponentRegistry.register('field:lookup', ReportingProbe, { namespace: 'test' });
+}, 30000);
+
+beforeEach(() => {
+ if (!Element.prototype.scrollIntoView) {
+ Element.prototype.scrollIntoView = () => {};
+ }
+});
+
+function renderForm(onSubmit: (data: Record) => void) {
+ const Form = ComponentRegistry.get('form')!;
+ return render(
+ ,
+ );
+}
+
+const dueRow = () => document.querySelector('[data-field="due"]') as HTMLElement;
+const within = (row: HTMLElement, name: string) =>
+ Array.from(row.querySelectorAll('button')).find((b) => b.textContent === name)!;
+
+describe('form renderer — unparseable widget input is a field error (objectstack#27483)', () => {
+ it('a report shows as the field message at once and refuses submit', async () => {
+ const onSubmit = vi.fn();
+ renderForm(onSubmit);
+
+ fireEvent.click(within(dueRow(), 'report'));
+ await waitFor(() => expect(dueRow().textContent).toContain(MESSAGE));
+
+ const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
+ fireEvent.click(screen.getByRole('button', { name: /create/i }));
+ await waitFor(() => expect(scroll).toHaveBeenCalled());
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('a withdrawn report clears the error and the submit goes through', async () => {
+ const onSubmit = vi.fn();
+ renderForm(onSubmit);
+
+ fireEvent.click(within(dueRow(), 'report'));
+ await waitFor(() => expect(dueRow().textContent).toContain(MESSAGE));
+ fireEvent.click(within(dueRow(), 'withdraw'));
+ await waitFor(() => expect(dueRow().textContent).not.toContain(MESSAGE));
+
+ fireEvent.click(screen.getByRole('button', { name: /create/i }));
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ });
+
+ it('only the reporting widget types receive the callback', () => {
+ renderForm(() => {});
+ expect(screen.getByTestId('probe-due')).toHaveAttribute('data-has-key', 'yes');
+ expect(screen.getByTestId('probe-account')).toHaveAttribute('data-has-key', 'no');
+ });
+});
diff --git a/ui/packages/components/src/renderers/form/form.tsx b/ui/packages/components/src/renderers/form/form.tsx
index 12d5c6b5a24..105bca62979 100644
--- a/ui/packages/components/src/renderers/form/form.tsx
+++ b/ui/packages/components/src/renderers/form/form.tsx
@@ -1229,6 +1229,7 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende
dependentValues,
dependsOnLabels,
emptyHint,
+ onInvalidInputChange,
// Renderer-only, and deliberately NOT a new key in the widget contract
// (objectui#3393). The call site started forwarding `label` for the
// built-in fullscreen dialog; registered widgets must keep receiving
@@ -1266,9 +1267,15 @@ function stripRegisteredFieldProps(type: string, props: RenderFieldProps): Rende
// A record-mode amount's symbol and decimals follow the code the user picks
// in its sibling select, so the amount box needs the live record too.
...(RECORD_CURRENCY_WIDGET_TYPES.has(normalizedType) ? { dependentValues } : {}),
+ // Only the widgets that report unparseable text read it; every other one
+ // would spread an unknown handler onto its DOM (objectstack#27483).
+ ...(INVALID_INPUT_REPORTING_WIDGET_TYPES.has(normalizedType) ? { onInvalidInputChange } : {}),
};
}
+/** The registered widgets that fire `onInvalidInputChange`: the date picker's two faces. */
+const INVALID_INPUT_REPORTING_WIDGET_TYPES: ReadonlySet = new Set(['date', 'datetime']);
+
/**
* FullscreenTextarea — `