Skip to content

Commit bbdb498

Browse files
baozhoutaoclaude
andauthored
fix(plugin-form): block page unload while a modal/drawer form has unsaved input (#2998)
The in-app discard guard (#2028) only intercepts Radix close paths — backdrop, Escape, the X. A browser refresh or tab close never reaches them: beforeunload fired with nobody calling preventDefault, so a dirty create/edit overlay silently lost everything the user had typed (the dialog itself is even restored afterwards via the ?recordId= URL param, but empty). Register a beforeunload listener while the overlay is open and dirty (and confirmOnDiscard hasn't been opted out), mirroring the existing StudioDesignSurface pillar guard. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent c769d3d commit bbdb498

3 files changed

Lines changed: 83 additions & 0 deletions

File tree

packages/plugin-form/src/DrawerForm.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,20 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
363363
}
364364
}, [confirmOnDiscard, isDirty, finalizeClose]);
365365

366+
// Browser-native "leave site?" prompt on tab close / reload while the form
367+
// is dirty. The in-app guard above only covers Radix close paths (backdrop,
368+
// Escape, the X) — a refresh bypasses them all and the input is gone.
369+
useEffect(() => {
370+
if (!isOpen || !isDirty || !confirmOnDiscard) return;
371+
const handler = (e: BeforeUnloadEvent) => {
372+
e.preventDefault();
373+
// Required for Chrome to actually show the prompt.
374+
e.returnValue = '';
375+
};
376+
window.addEventListener('beforeunload', handler);
377+
return () => window.removeEventListener('beforeunload', handler);
378+
}, [isOpen, isDirty, confirmOnDiscard]);
379+
366380
// The explicit Cancel button is an *intentional* discard, so it closes
367381
// immediately — no "Discard changes?" prompt. The unsaved-changes guard only
368382
// intercepts *accidental* closes (backdrop click, Escape, the X), which Radix

packages/plugin-form/src/ModalForm.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,20 @@ export const ModalForm: React.FC<ModalFormProps> = ({
454454
}
455455
}, [confirmOnDiscard, isDirty, finalizeClose]);
456456

457+
// Browser-native "leave site?" prompt on tab close / reload while the form
458+
// is dirty. The in-app guard above only covers Radix close paths (backdrop,
459+
// Escape, the X) — a refresh bypasses them all and the input is gone.
460+
useEffect(() => {
461+
if (!isOpen || !isDirty || !confirmOnDiscard) return;
462+
const handler = (e: BeforeUnloadEvent) => {
463+
e.preventDefault();
464+
// Required for Chrome to actually show the prompt.
465+
e.returnValue = '';
466+
};
467+
window.addEventListener('beforeunload', handler);
468+
return () => window.removeEventListener('beforeunload', handler);
469+
}, [isOpen, isDirty, confirmOnDiscard]);
470+
457471
// The explicit Cancel button is an *intentional* discard, so it closes
458472
// immediately — no "Discard changes?" prompt. The unsaved-changes guard only
459473
// intercepts *accidental* closes (backdrop click, Escape, the X), which Radix

packages/plugin-form/src/discardGuard.test.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,58 @@ describe('DrawerForm unsaved-changes guard', () => {
203203
expect(onOpenChange).not.toHaveBeenCalled();
204204
});
205205
});
206+
207+
/**
208+
* Browser refresh / tab close while dirty. The Radix close guard above never
209+
* sees an unload — only a `beforeunload` listener can intercept it, so a dirty
210+
* overlay must register one (and a pristine one must not, or every navigation
211+
* away from the app would prompt).
212+
*/
213+
function fireBeforeUnload(): Event {
214+
const evt = new Event('beforeunload', { cancelable: true });
215+
window.dispatchEvent(evt);
216+
return evt;
217+
}
218+
219+
describe.each([
220+
['ModalForm', ModalForm],
221+
['DrawerForm', DrawerForm],
222+
] as const)('%s beforeunload guard', (_name, Form) => {
223+
it('does not block unload while the form is pristine', async () => {
224+
render(
225+
<Form
226+
schema={{ objectName: 'task', mode: 'create', open: true, onOpenChange: vi.fn() } as any}
227+
dataSource={ds}
228+
/>,
229+
);
230+
await waitFor(() => {
231+
if (!document.querySelector('input')) throw new Error('form not ready');
232+
});
233+
234+
expect(fireBeforeUnload().defaultPrevented).toBe(false);
235+
});
236+
237+
it('blocks unload while the form is dirty', async () => {
238+
render(
239+
<Form
240+
schema={{ objectName: 'task', mode: 'create', open: true, onOpenChange: vi.fn() } as any}
241+
dataSource={ds}
242+
/>,
243+
);
244+
await dirtyTheForm();
245+
246+
await waitFor(() => expect(fireBeforeUnload().defaultPrevented).toBe(true));
247+
});
248+
249+
it('does not block unload when confirmOnDiscard is false, even dirty', async () => {
250+
render(
251+
<Form
252+
schema={{ objectName: 'task', mode: 'create', open: true, confirmOnDiscard: false, onOpenChange: vi.fn() } as any}
253+
dataSource={ds}
254+
/>,
255+
);
256+
await dirtyTheForm();
257+
258+
expect(fireBeforeUnload().defaultPrevented).toBe(false);
259+
});
260+
});

0 commit comments

Comments
 (0)