Skip to content

Commit 4704aa4

Browse files
os-litantclaude
andauthored
fix(form): a later submit outcome supersedes this form's earlier one (#7342)
A refused submit raised an error toast that nothing ever retired, so the confirmation of a later, successful submit of the same form appeared beside the refusal of the earlier one — a wizard's last step showing "Invalid project status transition." and "Your new project is ready…" at the same time. The two toasts are raised by two different modules, which is why neither could supersede the other on its own. The refusal comes from the form renderer in @object-ui/components: WizardForm hands it `onSubmit: handleStepSubmit`, that handler rethrows a rejected write, and the renderer's own catch sets the in-form banner AND toasts. The success toast is WizardForm's. Every outcome toast a form raises now travels under one stable per-form id: - the renderer publishes its three outcome toasts (the field-level rejection, an `onAction` error and a rejected write) under that id, and retires the previous attempt's toast in the same place it already cleared the previous attempt's in-form banner — so by the time a host can confirm a success, this form's last refusal is gone; - the console's FormPage publishes its confirmation and its submit failure under one id, closing the same asymmetry in a single function. Measured, not assumed: the refusal toast is not sticky by configuration. ConsoleToaster sets `duration: 4000` for every severity, @object-ui/components' own Toaster sets none (sonner's default TOAST_LIFETIME is also 4000), and `duration: Infinity` appears nowhere on any form path. Durations are therefore untouched — the defect is supersession, and it reproduces inside any lifetime. Deliberately excluded: the refused redirect DESTINATION keeps its own toast. The write succeeded there and only the declared destination is out of contract, so that refusal has to stay readable beside the confirmation it qualifies. Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho Co-authored-by: Claude <noreply@anthropic.com>
1 parent 35d7688 commit 4704aa4

5 files changed

Lines changed: 483 additions & 6 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@object-ui/components': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
A form no longer ends on a screen asserting both a failure and a success (objectui#7252).
7+
8+
A refused submit raised an error toast that nothing ever retired, so when the user
9+
fixed the input and submitted again the confirmation of that second attempt appeared
10+
*beside* the refusal of the first — a wizard's last step showing "Invalid project
11+
status transition." and "Your new project is ready…" at the same time.
12+
13+
Every outcome toast a form raises now travels under one stable per-form id, so the
14+
later outcome supersedes the earlier one instead of stacking beside it:
15+
16+
- `@object-ui/components`' form renderer publishes its three outcome toasts (the
17+
field-level rejection, an `onAction` error, and a rejected write) under that id, and
18+
retires the previous attempt's toast in the same place it already cleared the
19+
previous attempt's in-form banner. This is what fixes the reported wizard flow: the
20+
refusal comes from this renderer while the success toast is raised by the host
21+
(`WizardForm` / `ObjectForm`), so no single raiser could supersede the other before.
22+
- the console's own `FormPage` publishes its confirmation and its submit failure under
23+
one id, for the same reason.
24+
25+
Toast durations are unchanged — this is about supersession, not lifetime. The
26+
objectui#4190 arm is deliberately excluded: a refused redirect *destination* still gets
27+
its own toast, because the write succeeded and that refusal has to stay readable beside
28+
the confirmation it qualifies.
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#7252 — a form never ends on a screen that asserts BOTH outcomes.
5+
*
6+
* The card was filed against the console's WIZARD path, where the refusal and
7+
* the confirmation are raised by two different modules (`@object-ui/components`'
8+
* form renderer and `@object-ui/plugin-form`'s `WizardForm`); that end-to-end
9+
* flow is pinned in
10+
* `packages/plugin-form/src/WizardForm.outcomeToastSupersede.test.tsx`.
11+
*
12+
* This page carried the SAME defect in a single function. `handleSubmit` clears
13+
* the `error` banner at the top of every attempt but published its toast under
14+
* sonner's auto-generated id, so nothing here could retire it: a refused submit
15+
* left its toast up, and the confirmation of the retry landed beside it. Both
16+
* outcomes now travel under one per-form id, so the later one supersedes the
17+
* earlier in place.
18+
*
19+
* ## Why the assertion is a registry rather than a call count
20+
*
21+
* `toast.error` was called and `toast.success` was called is true both before
22+
* and after the fix — it is exactly the reading under which the defect looks
23+
* fine. What the card is about is what remains ON SCREEN, so `sonner` is
24+
* replaced by a registry modelling the one behaviour the fix relies on: raising
25+
* a toast under an id the registry already holds REPLACES that entry.
26+
*
27+
* ## Reverse verification — predicted, then measured
28+
*
29+
* Dropping `{ id: outcomeToastId }` from the two submit-outcome toasts turns
30+
* the first test RED (two entries on screen: the refusal and the confirmation)
31+
* and leaves the second one — which asserts that the redirect refusal is NOT
32+
* folded into that id — green, since it never depended on the id at all.
33+
*/
34+
35+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
36+
import { render, screen, waitFor } from '@testing-library/react';
37+
import userEvent from '@testing-library/user-event';
38+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
39+
import { FormPage } from './FormPage';
40+
41+
type ToastEntry = { type: string; message: string };
42+
43+
const { toastRegistry, fakeToast } = vi.hoisted(() => {
44+
const toastRegistry = new Map<string | number, { type: string; message: string }>();
45+
let auto = 0;
46+
const raise =
47+
(type: string) =>
48+
(message: unknown, options?: { id?: string | number }) => {
49+
// No id is sonner's auto-id — a NEW toast every time, which is precisely
50+
// the pre-fix behaviour that let two outcomes share the screen.
51+
const id = options?.id ?? `auto:${(auto += 1)}`;
52+
toastRegistry.set(id, { type, message: String(message) });
53+
return id;
54+
};
55+
const fakeToast = {
56+
success: raise('success'),
57+
error: raise('error'),
58+
info: raise('info'),
59+
warning: raise('warning'),
60+
dismiss: (id?: string | number) => {
61+
if (id === undefined) toastRegistry.clear();
62+
else toastRegistry.delete(id);
63+
return id;
64+
},
65+
};
66+
return { toastRegistry, fakeToast };
67+
});
68+
69+
vi.mock('sonner', () => ({ toast: fakeToast }));
70+
71+
/** What the submitter can actually see, in the order sonner holds it. */
72+
const onScreen = (): ToastEntry[] => [...toastRegistry.values()];
73+
74+
const publicPayload = (submitBehavior?: unknown) => ({
75+
slug: 'contact-us',
76+
object: 'showcase_inquiry',
77+
label: 'Contact us',
78+
form: {
79+
type: 'simple',
80+
sections: [{ fields: ['title'] }],
81+
...(submitBehavior ? { submitBehavior } : {}),
82+
},
83+
objectSchema: { name: 'showcase_inquiry', fields: { title: { type: 'text', label: 'Title' } } },
84+
});
85+
86+
/**
87+
* Answers the form resolver, then refuses the first N submits with a 400 the
88+
* way the reported flow was refused, and accepts every one after that.
89+
*/
90+
function stubFetchRefusingFirst(refusals: number, payload: Record<string, unknown>) {
91+
let posts = 0;
92+
return vi.fn(async (url: string, init?: RequestInit) => {
93+
if (init?.method === 'POST') {
94+
posts += 1;
95+
if (posts <= refusals) {
96+
return {
97+
ok: false,
98+
status: 400,
99+
statusText: 'Bad Request',
100+
text: async () => 'Invalid project status transition.',
101+
json: async () => ({ error: 'Invalid project status transition.' }),
102+
} as unknown as Response;
103+
}
104+
return {
105+
ok: true,
106+
status: 201,
107+
statusText: 'Created',
108+
json: async () => ({ object: 'showcase_inquiry', id: 'inq-1', record: { id: 'inq-1' } }),
109+
text: async () => '{}',
110+
} as unknown as Response;
111+
}
112+
if (!String(url).includes('/forms/')) throw new Error(`unstubbed fetch: ${url}`);
113+
return {
114+
ok: true,
115+
status: 200,
116+
statusText: 'OK',
117+
json: async () => payload,
118+
text: async () => JSON.stringify(payload),
119+
} as unknown as Response;
120+
});
121+
}
122+
123+
const renderPublic = () =>
124+
render(
125+
<MemoryRouter initialEntries={['/f/contact-us']}>
126+
<Routes>
127+
<Route path="/f/:slug" element={<FormPage mode="public" />} />
128+
</Routes>
129+
</MemoryRouter>,
130+
);
131+
132+
beforeEach(() => {
133+
toastRegistry.clear();
134+
});
135+
136+
afterEach(() => {
137+
vi.unstubAllGlobals();
138+
});
139+
140+
describe('FormPage — the later submit outcome supersedes the earlier one', () => {
141+
it('leaves only the confirmation after a refused submit is retried and accepted', async () => {
142+
vi.stubGlobal('fetch', stubFetchRefusingFirst(1, publicPayload()));
143+
renderPublic();
144+
145+
await screen.findByLabelText(/Title/);
146+
await userEvent.type(screen.getByLabelText(/Title/), 'Ship it');
147+
148+
await userEvent.click(screen.getByRole('button', { name: /Submit/ }));
149+
await waitFor(() => expect(onScreen()).toHaveLength(1));
150+
expect(onScreen()[0].type).toBe('error');
151+
expect(onScreen()[0].message).toContain('Invalid project status transition.');
152+
153+
// The retry the card describes: same form, same page, and it is accepted.
154+
await userEvent.click(screen.getByRole('button', { name: /Submit/ }));
155+
expect(await screen.findByText('Your submission has been received.')).toBeInTheDocument();
156+
157+
await waitFor(() => expect(onScreen()).toEqual([{ type: 'success', message: 'Submitted' }]));
158+
});
159+
160+
/**
161+
* The objectui#4190 arm is NOT a submit outcome: the write succeeded and only
162+
* the declared destination is out of contract, so its refusal has to stay
163+
* readable BESIDE the confirmation. It therefore keeps its own toast — folding
164+
* it into the shared id would silently delete the confirmation of a write that
165+
* really happened.
166+
*/
167+
it('keeps a refused redirect destination beside the confirmation it qualifies', async () => {
168+
vi.stubGlobal(
169+
'fetch',
170+
stubFetchRefusingFirst(0, publicPayload({ kind: 'redirect', url: 'https://evil.example/x' })),
171+
);
172+
renderPublic();
173+
174+
await screen.findByLabelText(/Title/);
175+
await userEvent.click(screen.getByRole('button', { name: /Submit/ }));
176+
177+
await waitFor(() => expect(onScreen()).toHaveLength(2));
178+
expect(onScreen().map((e) => e.type)).toEqual(['success', 'error']);
179+
});
180+
});

‎apps/console/src/components/FormPage.tsx‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
* sibling of `@object-ui/plugin-form`'s `omitServerResolvedDefaults`.
104104
*/
105105

106-
import { useEffect, useMemo, useState, type FormEvent } from 'react';
106+
import { useEffect, useId, useMemo, useState, type FormEvent } from 'react';
107107
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
108108
import { toast } from 'sonner';
109109
import {
@@ -1578,6 +1578,23 @@ export function FormPage({ mode, recordPath }: FormPageProps) {
15781578
const [loaded, setLoaded] = useState<LoadedForm | null>(null);
15791579
const [values, setValues] = useState<Record<string, unknown>>({});
15801580
const [submitting, setSubmitting] = useState(false);
1581+
/**
1582+
* The sonner id this form's submit OUTCOME is published under — one id for
1583+
* the confirmation and for the refusal, so the later of the two SUPERSEDES
1584+
* the earlier instead of stacking beside it (objectui#7252).
1585+
*
1586+
* The `error` banner above is already cleared at the top of every attempt;
1587+
* its toast was not, because it went out under sonner's auto-generated id and
1588+
* nothing here held a handle on it. A refused submit therefore left its toast
1589+
* on screen, and the success toast of the retry landed next to it — the form
1590+
* ending on a screen that asserted both outcomes at once.
1591+
*
1592+
* ⚠️ The redirect REFUSAL below deliberately does NOT share this id. That one
1593+
* is not a submit outcome: the write succeeded and only the declared
1594+
* destination is out of contract, so it has to be readable BESIDE the
1595+
* confirmation this id carries, exactly as objectui#4190 ruled.
1596+
*/
1597+
const outcomeToastId = `form-outcome:${useId()}`;
15811598
const [submitted, setSubmitted] = useState(false);
15821599
/**
15831600
* The outcome of a `redirect` submit behaviour: the in-app route to go to
@@ -1681,7 +1698,7 @@ export function FormPage({ mode, recordPath }: FormPageProps) {
16811698
mode === 'public'
16821699
? await submitPublic(identifier, payload)
16831700
: await submitInternal(loaded.object, payload, editingId);
1684-
toast.success('Submitted');
1701+
toast.success('Submitted', { id: outcomeToastId });
16851702
// Behaviour after submit
16861703
switch (behavior.kind) {
16871704
case 'created-record': {
@@ -1766,7 +1783,7 @@ export function FormPage({ mode, recordPath }: FormPageProps) {
17661783
} catch (err: any) {
17671784
const msg = err?.message ?? String(err);
17681785
setError(msg);
1769-
toast.error(msg);
1786+
toast.error(msg, { id: outcomeToastId });
17701787
} finally {
17711788
setSubmitting(false);
17721789
}

‎packages/components/src/renderers/form/form.tsx‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,6 +1130,29 @@ ComponentRegistry.register('form',
11301130

11311131
const [isSubmitting, setIsSubmitting] = React.useState(false);
11321132
const [submitError, setSubmitError] = React.useState<string | null>(null);
1133+
/**
1134+
* The sonner id every OUTCOME toast of THIS form is published under.
1135+
*
1136+
* The in-form banner (`submitError`) and its toast are raised together at
1137+
* each of the three points below, but only the banner was ever retired
1138+
* together: the toast went out under sonner's auto-generated id, so nothing
1139+
* here held a handle on it and a refused submit's toast outlived the
1140+
* attempt that raised it. A later SUCCESSFUL submit — whose success toast a
1141+
* host raises (`WizardForm`, `ObjectForm`), not this renderer — then landed
1142+
* BESIDE that stale refusal, and the form ended on a screen asserting both
1143+
* outcomes at once (objectui#7252).
1144+
*
1145+
* One stable id closes both halves. Reusing an id sonner already holds
1146+
* UPDATES that toast rather than stacking a second one, so every outcome
1147+
* this form reports supersedes the one before it; and the dismissal beside
1148+
* `setSubmitError(null)` retires it the moment a new attempt starts, so by
1149+
* the time any host can confirm a success this form's last refusal is gone.
1150+
*
1151+
* Scoped to this form instance (`React.useId()`) on purpose: a blanket
1152+
* `toast.dismiss()` would take unrelated toasts down with it.
1153+
*/
1154+
const formInstanceId = React.useId();
1155+
const outcomeToastId = `form-outcome:${formInstanceId}`;
11331156
// Active `fieldTabs` panel. Undefined until something picks one — the
11341157
// effective tab falls back to `defaultFieldTab`, then to the first tab.
11351158
// Which tab of an in-progress form is showing is presentational and dies
@@ -1912,7 +1935,7 @@ ComponentRegistry.register('form',
19121935
const fieldsText =
19131936
labels.slice(0, MAX).join(t('validation.formInvalidJoiner')) +
19141937
(labels.length > MAX ? '…' : '');
1915-
toast.error(t('validation.formInvalid', { fields: fieldsText }));
1938+
toast.error(t('validation.formInvalid', { fields: fieldsText }), { id: outcomeToastId });
19161939

19171940
const errored = new Set(names);
19181941
const firstName =
@@ -1948,6 +1971,10 @@ ComponentRegistry.register('form',
19481971
const handleSubmit = form.handleSubmit(async (data) => {
19491972
setIsSubmitting(true);
19501973
setSubmitError(null);
1974+
// …and the toast twin of that banner (objectui#7252). Both belong to the
1975+
// previous attempt and both are stale now; the toast outliving it is what
1976+
// let an earlier refusal share the screen with the success that followed.
1977+
toast.dismiss(outcomeToastId);
19511978
// This attempt cleared client validation — drop the previous attempt's
19521979
// tab markers (the server may re-add its own below).
19531980
setRejectedFieldNames([]);
@@ -1999,7 +2026,7 @@ ComponentRegistry.register('form',
19992026
setSubmitError(result.error);
20002027
// Also surface as a toast so the message is visible even when the
20012028
// in-form banner has scrolled out of view (long forms in modals/drawers).
2002-
toast.error(result.error);
2029+
toast.error(result.error, { id: outcomeToastId });
20032030
return;
20042031
}
20052032
}
@@ -2069,7 +2096,7 @@ ComponentRegistry.register('form',
20692096
setSubmitError(errorMessage);
20702097
// Also surface as a toast so the message is visible even when the
20712098
// in-form banner has scrolled out of view (long forms in modals/drawers).
2072-
toast.error(errorMessage);
2099+
toast.error(errorMessage, { id: outcomeToastId });
20732100

20742101
// Log errors for debugging (dev environment only)
20752102
// process may not be defined in all environments

0 commit comments

Comments
 (0)