From 12e40226e0bd9b15a6956b0fc73fa8f9f7faefe2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 19:37:42 +0000 Subject: [PATCH 1/4] fix(components): bound each registered field widget's lazy load to its own slot (objectui#12559) A collapsed section's first expand drew a not-yet-loaded lazy field widget with no Suspense boundary in the form, so the suspension unwound to the host's nearest boundary; the console's record dialog mounts under the shell's root boundary, so the whole console (dialog included) was swapped for the root fallback. Each registered widget now renders behind a per-widget boundary cached per component, with a control-height placeholder. Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- .changeset/12559-dialog-section-toggle.md | 9 + .../components/src/renderers/form/form.tsx | 56 +++- .../modalSectionToggleSuspense-12559.test.tsx | 243 ++++++++++++++++++ 3 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 .changeset/12559-dialog-section-toggle.md create mode 100644 packages/plugin-form/src/__tests__/modalSectionToggleSuspense-12559.test.tsx diff --git a/.changeset/12559-dialog-section-toggle.md b/.changeset/12559-dialog-section-toggle.md new file mode 100644 index 0000000000..1cec7476d9 --- /dev/null +++ b/.changeset/12559-dialog-section-toggle.md @@ -0,0 +1,9 @@ +--- +'@object-ui/components': patch +--- + +A form field whose widget is still loading no longer takes its host off screen (objectui#12559). + +The field widgets `@object-ui/fields` registers load on demand: a type's code is fetched the first time a form draws it. The form renderer drew such a widget with no loading boundary of its own, so the wait landed on the host's nearest one. In the console's record create and edit dialog that is the boundary around the whole console, because the dialog mounts beside the pages rather than inside one. Opening a collapsed section whose fields used a type not loaded yet, for example a currency or address field, swapped the whole console, dialog included, for the loading screen until the code arrived. The full-page form hid only its page. + +Each registered field widget now waits in its own slot: while its code loads, the field shows a placeholder the height of a control, and the section opens in place with everything typed elsewhere in the form kept. Nothing is added to the package entry: no export, prop or type member. diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index b067131cdc..a60788de51 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -4450,6 +4450,55 @@ function nativeNumberStep(carrier: Record | undefined): number return typeof scale === 'number' ? Math.pow(10, -scale) : 'any'; } +/** + * A registered field widget behind a `` boundary of the field's own + * (objectui#12559). + * + * The widgets `@object-ui/fields` registers are `React.lazy` — each type's + * chunk is fetched the first time a form draws that type. Without a boundary + * here, that first render suspended past the form to the HOST's nearest + * ``, whatever and wherever it is. For the console's record dialog + * that is the shell's root boundary — the dialog mounts beside the routes, not + * inside the route boundary — so expanding a collapsed section whose fields + * used a type not yet loaded swapped the WHOLE console, dialog included, for + * the root fallback while the chunk loaded. The full-page form hid only its + * page, because its nearest boundary is the route's. The renderer that introduces the laziness + * is the one that owns the wait: a pending widget draws a placeholder in its + * own slot, and nothing above the field is ever hidden or torn down. + * + * The bounded twin is cached PER WIDGET COMPONENT, so its identity is as + * stable as the registration it wraps: a fresh wrapper per render would + * remount every widget on every render. It receives the props ``'s + * Slot merges onto it (`id`, `aria-*`, `ref` — a plain prop under React 19) + * and hands them all to the widget unchanged. + */ +const boundedFieldWidgets = new WeakMap, React.ComponentType>(); + +function boundedFieldWidget(Widget: React.ComponentType): React.ComponentType { + let Bounded = boundedFieldWidgets.get(Widget); + if (!Bounded) { + const BoundedFieldWidget = (props: Record) => ( + }> + + + ); + Bounded = BoundedFieldWidget; + boundedFieldWidgets.set(Widget, Bounded); + } + return Bounded; +} + +/** The slot a field widget holds while its chunk loads: the height of a control, nothing to read. */ +function FieldWidgetPending() { + return ( + }> + + , + ); + await formReady(); + expect(drawnFields()).not.toContain('website'); + + fireEvent.click(sectionHeader('Company Information')!); + release(); + + await screen.findByTestId('deferred-url-widget'); + expect(sectionHeader('Company Information')?.getAttribute('aria-expanded')).toBe('true'); + expect(drawnFields()).toEqual(expect.arrayContaining(['company', 'website'])); + }); +}); From 163f545844c958d76f45c865e1e92a5f5accb4ee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:01:11 +0000 Subject: [PATCH 2/4] chore(components): type the bounded field widget on RenderFieldProps; lint-clean the 12559 pin host (objectui#12559) Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- packages/components/src/renderers/form/form.tsx | 10 ++++++---- .../modalSectionToggleSuspense-12559.test.tsx | 8 +++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index a60788de51..54d30b3e17 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -4472,12 +4472,14 @@ function nativeNumberStep(carrier: Record | undefined): number * Slot merges onto it (`id`, `aria-*`, `ref` — a plain prop under React 19) * and hands them all to the widget unchanged. */ -const boundedFieldWidgets = new WeakMap, React.ComponentType>(); +type RegisteredFieldWidget = React.ComponentType; -function boundedFieldWidget(Widget: React.ComponentType): React.ComponentType { +const boundedFieldWidgets = new WeakMap(); + +function boundedFieldWidget(Widget: RegisteredFieldWidget): RegisteredFieldWidget { let Bounded = boundedFieldWidgets.get(Widget); if (!Bounded) { - const BoundedFieldWidget = (props: Record) => ( + const BoundedFieldWidget = (props: RenderFieldProps) => ( }> @@ -4564,7 +4566,7 @@ function renderFieldComponent(type: string, props: RenderFieldProps) { // Drawn through its BOUNDED twin (objectui#12559): a registered widget is // typically `React.lazy`, so its first render suspends — see // `boundedFieldWidget` for why that suspension must stop at this field. - const Bounded = boundedFieldWidget(RegisteredComponent); + const Bounded = boundedFieldWidget(RegisteredComponent as RegisteredFieldWidget); return ; } diff --git a/packages/plugin-form/src/__tests__/modalSectionToggleSuspense-12559.test.tsx b/packages/plugin-form/src/__tests__/modalSectionToggleSuspense-12559.test.tsx index e6f25e40cc..702fe3ea9e 100644 --- a/packages/plugin-form/src/__tests__/modalSectionToggleSuspense-12559.test.tsx +++ b/packages/plugin-form/src/__tests__/modalSectionToggleSuspense-12559.test.tsx @@ -92,7 +92,7 @@ const SECTIONS = (company: Record) => [ /** The console's mount, reduced: root boundary → host → keyed dialog. */ function mountConsoleShaped(ds: any, company: Record) { const counts = { hostFallbackMounts: 0, hostMounts: 0 }; - let rerenderHost: () => void = () => {}; + const handle = { rerender: () => {} }; function HostFallback() { React.useEffect(() => { counts.hostFallbackMounts++; }, []); return
loading
; @@ -100,7 +100,9 @@ function mountConsoleShaped(ds: any, company: Record) { function Host() { const [mountId] = useState(() => ++counts.hostMounts); const [, setTick] = useState(0); - rerenderHost = () => act(() => setTick((n) => n + 1)); + React.useEffect(() => { + handle.rerender = () => act(() => setTick((n) => n + 1)); + }, []); return (
) { , ); - return { counts, rerenderHost: () => rerenderHost() }; + return { counts, rerenderHost: () => handle.rerender() }; } const sectionHeader = (label: string) => From 276d22c175d707e977c8b2f3d6d9022e07e3c273 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:16:54 +0000 Subject: [PATCH 3/4] fix(plugin-form): the record dialog owns its body's lazy-widget wait (objectui#12559) Move the boundary from every registered field widget (form renderer) to the modal form's dialog bodies, the shape FlowRunner gives its screen body. The per-widget boundary changed the readiness contract of every form in the product (labels drawn before their controls; a pinned test went red on it), while the defect is specific to an overlay a host mounts outside its page boundary. form.tsx is back to main. Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- .changeset/12559-dialog-section-toggle.md | 8 +-- .../components/src/renderers/form/form.tsx | 58 +------------------ packages/plugin-form/src/ModalForm.tsx | 44 +++++++++++++- .../modalSectionToggleSuspense-12559.test.tsx | 24 ++++---- 4 files changed, 59 insertions(+), 75 deletions(-) diff --git a/.changeset/12559-dialog-section-toggle.md b/.changeset/12559-dialog-section-toggle.md index 1cec7476d9..55e827bdee 100644 --- a/.changeset/12559-dialog-section-toggle.md +++ b/.changeset/12559-dialog-section-toggle.md @@ -1,9 +1,9 @@ --- -'@object-ui/components': patch +'@object-ui/plugin-form': patch --- -A form field whose widget is still loading no longer takes its host off screen (objectui#12559). +Opening a collapsed section in a record dialog no longer takes the whole console off screen (objectui#12559). -The field widgets `@object-ui/fields` registers load on demand: a type's code is fetched the first time a form draws it. The form renderer drew such a widget with no loading boundary of its own, so the wait landed on the host's nearest one. In the console's record create and edit dialog that is the boundary around the whole console, because the dialog mounts beside the pages rather than inside one. Opening a collapsed section whose fields used a type not loaded yet, for example a currency or address field, swapped the whole console, dialog included, for the loading screen until the code arrived. The full-page form hid only its page. +The field widgets `@object-ui/fields` registers load on demand: a type's code is fetched the first time a form draws it. Nothing inside the modal form caught that wait, so it landed on the host's nearest loading boundary. In the console's record create and edit dialog that is the boundary around the whole console, because the dialog mounts beside the pages rather than inside one. Opening a collapsed section whose fields used a type not loaded yet, for example a currency or address field, swapped the whole console, dialog included, for the loading screen until the code arrived. The full-page form hid only its page. -Each registered field widget now waits in its own slot: while its code loads, the field shows a placeholder the height of a control, and the section opens in place with everything typed elsewhere in the form kept. Nothing is added to the package entry: no export, prop or type member. +The modal form now owns that wait: the dialog keeps its header and footer, its body shows a placeholder until the widget's code arrives, and the form underneath keeps its state, so the section the user opened is open and everything typed elsewhere is kept. Nothing is added to the package entry: no export, prop or type member. diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index 54d30b3e17..b067131cdc 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -4450,57 +4450,6 @@ function nativeNumberStep(carrier: Record | undefined): number return typeof scale === 'number' ? Math.pow(10, -scale) : 'any'; } -/** - * A registered field widget behind a `` boundary of the field's own - * (objectui#12559). - * - * The widgets `@object-ui/fields` registers are `React.lazy` — each type's - * chunk is fetched the first time a form draws that type. Without a boundary - * here, that first render suspended past the form to the HOST's nearest - * ``, whatever and wherever it is. For the console's record dialog - * that is the shell's root boundary — the dialog mounts beside the routes, not - * inside the route boundary — so expanding a collapsed section whose fields - * used a type not yet loaded swapped the WHOLE console, dialog included, for - * the root fallback while the chunk loaded. The full-page form hid only its - * page, because its nearest boundary is the route's. The renderer that introduces the laziness - * is the one that owns the wait: a pending widget draws a placeholder in its - * own slot, and nothing above the field is ever hidden or torn down. - * - * The bounded twin is cached PER WIDGET COMPONENT, so its identity is as - * stable as the registration it wraps: a fresh wrapper per render would - * remount every widget on every render. It receives the props ``'s - * Slot merges onto it (`id`, `aria-*`, `ref` — a plain prop under React 19) - * and hands them all to the widget unchanged. - */ -type RegisteredFieldWidget = React.ComponentType; - -const boundedFieldWidgets = new WeakMap(); - -function boundedFieldWidget(Widget: RegisteredFieldWidget): RegisteredFieldWidget { - let Bounded = boundedFieldWidgets.get(Widget); - if (!Bounded) { - const BoundedFieldWidget = (props: RenderFieldProps) => ( - }> - - - ); - Bounded = BoundedFieldWidget; - boundedFieldWidgets.set(Widget, Bounded); - } - return Bounded; -} - -/** The slot a field widget holds while its chunk loads: the height of a control, nothing to read. */ -function FieldWidgetPending() { - return ( - @@ -1198,4 +1204,38 @@ export const ModalForm: React.FC = ({ ); }; +/** + * What the dialog's body shows while a field widget's code loads + * (objectui#12559). + * + * The field widgets `@object-ui/fields` registers are `React.lazy`: a type's + * chunk is fetched the first time a form draws it, and that first render + * SUSPENDS. With no boundary inside the dialog, the suspension left the dialog + * and landed on the HOST's nearest ``. A record dialog is an overlay + * a host mounts beside its pages — the console mounts this one beside its + * routes, under the shell's root boundary — so expanding a collapsed section + * whose fields used a type not loaded yet swapped the WHOLE console, dialog + * included, for the root fallback while the chunk loaded. A full-page form + * sits inside its route's boundary and hid only its page. + * + * So the dialog owns its body's wait, the shape `FlowRunner` gives its screen + * body: the header and footer stay, the body shows this placeholder, and the + * form underneath is hidden, not unmounted — React keeps its state, so the + * section the user opened is open and every typed value is kept when the + * widget lands. Distinct from `modal-form-skeleton`, which is the record READ's + * loading state. + */ +function ModalFormBodyPending() { + return ( + + ); +} + export default ModalForm; diff --git a/packages/plugin-form/src/__tests__/modalSectionToggleSuspense-12559.test.tsx b/packages/plugin-form/src/__tests__/modalSectionToggleSuspense-12559.test.tsx index 702fe3ea9e..d0f2561369 100644 --- a/packages/plugin-form/src/__tests__/modalSectionToggleSuspense-12559.test.tsx +++ b/packages/plugin-form/src/__tests__/modalSectionToggleSuspense-12559.test.tsx @@ -14,14 +14,13 @@ * `React.lazy`: a type's chunk is fetched the first time a form draws it. A * section that starts collapsed holds its fields out of the DOM, so the click * that opens it is often the first render of a type (`currency`, `url`, - * `address`…). The form renderer drew that lazy widget with no boundary of its - * own, so the suspension left the form, left `ModalForm`, and landed on the - * HOST's nearest ``. The console mounts the record dialog beside its - * routes, under the shell's root boundary, with no route boundary in between — - * so the whole console, dialog included, was swapped for the root fallback - * while the chunk loaded. The full-page form hid only its page: its nearest - * boundary is the route's. The fix is a boundary per registered field widget - * in the form renderer (`boundedFieldWidget` in `form.tsx`). + * `address`…). Nothing inside the dialog caught that suspension, so it left + * `ModalForm` and landed on the HOST's nearest ``. The console mounts + * the record dialog beside its routes, under the shell's root boundary, with no + * route boundary in between — so the whole console, dialog included, was + * swapped for the root fallback while the chunk loaded. The full-page form hid + * only its page: its nearest boundary is the route's. The fix is a boundary + * around the dialog's body (`ModalFormBodyPending` in `ModalForm.tsx`). * * NOT the producer, read off the code: the collapse state lives in * `ModalForm`'s own `useState` (not above the dialog's key), and the console's @@ -165,13 +164,12 @@ describe('ModalForm — a collapsed section opens in place (objectui#12559)', () fireEvent.click(sectionHeader('Company Information')!); - // Mid-load: the field's own slot waits; nothing above the form does. + // Mid-load: the dialog's body waits; nothing above the dialog does. expect(counts.hostFallbackMounts).toBe(fallbacksBefore); expect(screen.queryByTestId('host-fallback')).toBeNull(); expect(dialogShown()).toBe(true); - expect(sectionHeader('Company Information')?.getAttribute('aria-expanded')).toBe('true'); - expect(drawnFields()).toEqual(expect.arrayContaining(['company', 'website'])); - expect(document.body.querySelector('[data-field="website"] [data-field-widget-pending]')).not.toBeNull(); + expect(screen.getByRole('dialog').textContent).toContain('Create Lead'); + expect(screen.getByRole('dialog').querySelector('[data-testid="modal-form-body-pending"]')).not.toBeNull(); release(); await screen.findByTestId('deferred-url-widget'); @@ -181,8 +179,10 @@ describe('ModalForm — a collapsed section opens in place (objectui#12559)', () expect(counts.hostMounts).toBe(1); expect(ds.getObjectSchema).toHaveBeenCalledTimes(1); expect(screen.queryByTestId('modal-form-skeleton')).toBeNull(); + expect(screen.queryByTestId('modal-form-body-pending')).toBeNull(); expect(firstNameInput()!.value).toBe('Ada'); expect(sectionHeader('Company Information')?.getAttribute('aria-expanded')).toBe('true'); + expect(drawnFields()).toEqual(expect.arrayContaining(['company', 'website'])); }); it('the toggled state is owned by the dialog: a host re-render with a fresh schema keeps the section open', async () => { From 48a6bc42c171fcb0dac960b0e4b2c11faf04c1d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:31:48 +0000 Subject: [PATCH 4/4] test(plugin-form): read the tabbed dialog's form once its body has stopped waiting (objectui#12559) Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- packages/plugin-form/src/sectionedFormValues.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/plugin-form/src/sectionedFormValues.test.tsx b/packages/plugin-form/src/sectionedFormValues.test.tsx index 735acede7f..2bd0afc130 100644 --- a/packages/plugin-form/src/sectionedFormValues.test.tsx +++ b/packages/plugin-form/src/sectionedFormValues.test.tsx @@ -112,6 +112,10 @@ describe('ModalForm tabbed sections — per-tab form state (#2959)', () => { ); await waitFor(() => expect(screen.getByTestId('modal-form-footer')).toBeTruthy()); + // The footer is drawn while the body still waits on a field widget's code + // (objectui#12559: the dialog owns that wait), so the form is read once + // the body's placeholder has gone. + await waitFor(() => expect(screen.queryByTestId('modal-form-body-pending')).toBeNull()); // ONE form for all three sections — the shared-`formId` duplicate
// elements are what stranded section 2+ outside the submit (#2153).