Skip to content

Commit eda55b9

Browse files
fix(app-shell): a full-page record form opened by direct URL returns inside the app after save or cancel (#27558)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu76CdcjMmgYPLs3UuMF1RMq)_ ## 维护者速读 - 改了什么:整页记录表单(新建 / 编辑)保存或取消后,只有当本控制台会话里确有上一页时才"后退",否则进入记录详情(编辑)或对象列表(新建)。 - 为什么改:从粘贴链接、新标签页或深链打开表单时,原来按浏览器标签页的历史长度判断,会退出应用跳到控制台首页,保存提示也随旧页面一起丢失。 - 风险与回滚:只改 `RecordFormPage` 的返回判定一处;应用内进入的行为不变。回滚即还原该文件一个提交。 - 先看:`ui/packages/app-shell/src/views/RecordFormPage.tsx` 的 `goBack`(`hasInAppHistory`),然后看新测试 `RecordFormPage.directUrlReturn-27454.test.tsx`。 - 两分钟验证:在新标签页直接打开 `/_console/apps/com.example.showcase/showcase_task/new`,填写并点 Create,应看到 `Task "TITLE" created` 提示并停留在任务列表(应用内)。 - 需要你做的:无,席位落地。 Fixes #27454 Clause-②: no Metadata-protocol: none ## What changes - `RecordFormPage`'s `goBack` decided "came from inside the app" from `window.history.length > 1`. That counts the tab's entries from before the console document loaded, so a form opened by pasted URL, new tab or deep link went Back out of the app, and the save toast fired just before the document unloaded. - It now reads react-router's own location key: the entry the router started on is keyed `'default'`, every entry it navigated to gets a generated key. Back only when the key is not `'default'`; otherwise the existing parent route (record detail in edit mode, object list in create mode) with `replace`. This is the same rule `FormPage`'s cancel already uses in the console, and the same route in-app entry takes when nothing is behind it, not a second fallback. - Save and cancel share `goBack`, so both follow the rule; edit mode (`/record/ID/edit`) is covered by the same change. ## Tests New `RecordFormPage.directUrlReturn-27454.test.tsx`, with `window.history.length` stubbed to 3 on every cell: - edit, direct URL: save toasts `Task "Quarterly review" updated` and lands on the record detail; - create, direct URL: save toasts `Task "Quarterly review" created` and lands on the object list; - edit, direct URL: cancel lands on the record detail, no toast; - edit and create, in-app entry (an earlier router entry): save and cancel go back to that page. Ablation (one-shot, through `scripts/ablation-replace.mjs`, restored to the HEAD blob): putting the `window.history.length > 1` test back turns the three direct-URL cells red and leaves the two in-app cells green, as predicted. ## Acceptance notes - `ui/packages/layout/src/PageHeader.tsx` (its back button) uses the same `window.history.length > 1` pattern. That is the back-button control, a different bug class from the after-save route; left unchanged here. ## Verification All readings on head `6912ecba`. <details><summary>Local runs</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/views/RecordFormPage` (lock VERDICT command-exit 0): Test Files 10 passed, Tests 61 passed. - Ablation run (lock VERDICT command-exit 1, expected): 3 failed, 2 passed; restore blob equals HEAD, `git diff HEAD` empty. - `pnpm --filter @object-ui/app-shell run type-check` (both the source and the test tsconfig): exit 0. - `pnpm --dir ui check:new-line-citations`: 0 new citations, exit 0. - `dispatch-gates --commands` derived 34 families; all 34 run with exit 0 recorded, `--ran` reconciliation: 34 run, 0 NOT-MEASURED, 0 UNRUN. That includes `pnpm --dir ui exec vitest run scripts/` (169 files passed, 2 skipped). - Changed lines: 149 (+138 / -11) over 3 files. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgXU26UBEjd1KKHgNEx)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgXU26UBEjd1KKHgNEx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 49896f7 commit eda55b9

3 files changed

Lines changed: 138 additions & 11 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): a full-page record form opened by direct URL returns inside the app after save or cancel
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The full-page create and edit forms (`/new`, `/record/ID/edit`) went Back after a save or cancel whenever the browser tab had any history, including entries from before the console loaded. A form reached by a pasted URL, a new tab or a deep link therefore left the app (often for the console home), and the save toast was lost with the unloaded page. The form now goes Back only when an earlier page of this console session is behind it; otherwise it goes to the record (edit) or the object list (create), as in-app entry does when nothing is behind it, and the toast is shown there.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* objectstack-ai/objectstack#27454 — a full-page record form entered by direct
3+
* URL returns INSIDE the app after save or cancel: the record detail (edit) or
4+
* the object list (create), as in-app entry does with nothing behind it. The
5+
* tab's own history is longer than the console's, so `window.history.length`
6+
* is simulated as 3 on every cell. Direction, written before the run: keyed on
7+
* `window.history.length > 1`, every direct-URL cell RED; in-app cells GREEN.
8+
*/
9+
10+
import '@testing-library/jest-dom/vitest';
11+
import * as React from 'react';
12+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
13+
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
14+
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
15+
import { I18nProvider } from '@object-ui/i18n';
16+
import { RecordFormPage } from './RecordFormPage';
17+
18+
const h = React.createElement;
19+
20+
const { toastSuccess } = vi.hoisted(() => ({ toastSuccess: vi.fn() }));
21+
22+
vi.mock('sonner', () => ({
23+
toast: Object.assign(vi.fn(), { success: toastSuccess, error: vi.fn(), info: vi.fn(), dismiss: vi.fn() }),
24+
}));
25+
26+
vi.mock('@object-ui/auth', async (importOriginal) => ({
27+
...(await importOriginal<Record<string, unknown>>()),
28+
useAuth: () => ({
29+
user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'admin' },
30+
getAuthConfig: async () => ({ features: {} }),
31+
activeOrganization: null,
32+
}),
33+
}));
34+
35+
// Stand-in form: one button per exit, as `ObjectForm` calls `onSuccess(result)` / `onCancel()`.
36+
vi.mock('@object-ui/plugin-form', async (importOriginal) => ({
37+
...(await importOriginal<Record<string, unknown>>()),
38+
ObjectForm: ({ schema }: { schema: { onSuccess?: (s?: unknown) => void; onCancel?: () => void } }) =>
39+
h(
40+
'div',
41+
null,
42+
h('button', { type: 'button', 'data-testid': 'fire-success', onClick: () => schema.onSuccess?.({ id: 't1', subject: 'Quarterly review' }) }, 'ok'),
43+
h('button', { type: 'button', 'data-testid': 'fire-cancel', onClick: () => schema.onCancel?.() }, 'cancel'),
44+
),
45+
}));
46+
47+
const TASK = { name: 'showcase_task', label: 'Task', nameField: 'subject', fields: { subject: { type: 'text' } } };
48+
const METADATA = { objects: [TASK], loading: false };
49+
vi.mock('../providers/MetadataProvider', () => ({ useMetadata: () => METADATA }));
50+
vi.mock('../providers/AdapterProvider', () => ({ useAdapter: () => null }));
51+
52+
const Where = () => h('div', { 'data-testid': 'where' }, useLocation().pathname);
53+
54+
const LIST = '/apps/crm/showcase_task';
55+
const DETAIL = '/apps/crm/showcase_task/record/t1';
56+
const VIEW = '/apps/crm/showcase_task/view/open';
57+
58+
beforeEach(() => {
59+
toastSuccess.mockClear();
60+
vi.spyOn(window.history, 'length', 'get').mockReturnValue(3);
61+
});
62+
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
63+
64+
/** Mount the form behind `before` (none = direct URL), fire `exit`, return where it lands. */
65+
function exitFrom(mode: 'create' | 'edit', exit: 'success' | 'cancel', before?: string): string {
66+
const form = mode === 'create' ? `${LIST}/new` : `${DETAIL}/edit`;
67+
const entries = before ? [before, form] : [form];
68+
render(
69+
h(I18nProvider, {
70+
config: { defaultLanguage: 'en', detectBrowserLanguage: false },
71+
children: h(
72+
MemoryRouter,
73+
{ initialEntries: entries, initialIndex: entries.length - 1 },
74+
h(
75+
Routes,
76+
null,
77+
h(Route, { path: '/apps/:appName/:objectName/new', element: h(RecordFormPage, { mode: 'create' }) }),
78+
h(Route, { path: '/apps/:appName/:objectName/record/:recordId/edit', element: h(RecordFormPage, { mode: 'edit' }) }),
79+
h(Route, { path: '*', element: h(Where) }),
80+
),
81+
),
82+
}),
83+
);
84+
fireEvent.click(screen.getByTestId(exit === 'success' ? 'fire-success' : 'fire-cancel'));
85+
return screen.getByTestId('where').textContent ?? '';
86+
}
87+
88+
describe('RecordFormPage — direct-URL entry returns inside the app (objectstack-ai/objectstack#27454)', () => {
89+
it('edit, direct URL: saves, toasts, lands on the record detail', () => {
90+
expect(exitFrom('edit', 'success')).toBe(DETAIL);
91+
expect(toastSuccess).toHaveBeenCalledWith('Task "Quarterly review" updated');
92+
});
93+
94+
it('create, direct URL: saves, toasts, lands on the object list', () => {
95+
expect(exitFrom('create', 'success')).toBe(LIST);
96+
expect(toastSuccess).toHaveBeenCalledWith('Task "Quarterly review" created');
97+
});
98+
99+
it('edit, direct URL: cancel lands on the record detail, no toast', () => {
100+
expect(exitFrom('edit', 'cancel')).toBe(DETAIL);
101+
expect(toastSuccess).not.toHaveBeenCalled();
102+
});
103+
104+
it('edit, in-app entry: save goes back to the page it came from', () => {
105+
expect(exitFrom('edit', 'success', VIEW)).toBe(VIEW);
106+
expect(toastSuccess).toHaveBeenCalledTimes(1);
107+
});
108+
109+
it('create, in-app entry: cancel goes back to the page it came from', () => {
110+
expect(exitFrom('create', 'cancel', VIEW)).toBe(VIEW);
111+
});
112+
});

‎ui/packages/app-shell/src/views/RecordFormPage.tsx‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,18 @@
1818
* fetches the existing record (in edit mode) via `dataSource.findOne`,
1919
* so this page does not need to manage its own loading state for
2020
* record data.
21-
* - On success / cancel, navigates back if the user has history, otherwise
22-
* falls back to a sensible parent route (record detail in edit mode,
23-
* object list in create mode). Cancel always navigates back without a
24-
* toast.
21+
* - On success / cancel, navigates back when an earlier entry of THIS
22+
* console session is behind the page, otherwise goes to the parent route
23+
* (record detail in edit mode, object list in create mode). Cancel never
24+
* toasts.
2525
* - Wraps the form in a sticky page header (back button + title) for a
2626
* consistent full-screen chrome.
2727
*
2828
* @module views/RecordFormPage
2929
*/
3030

3131
import { useCallback, useEffect, useMemo, useState } from 'react';
32-
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
32+
import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom';
3333
import { ObjectForm } from '@object-ui/plugin-form';
3434
import { Button, Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
3535
import { ArrowLeft, Building2, Database } from 'lucide-react';
@@ -71,6 +71,7 @@ export function RecordFormPage({ mode }: RecordFormPageProps) {
7171
recordId: string;
7272
}>();
7373
const navigate = useNavigate();
74+
const location = useLocation();
7475
const [searchParams] = useSearchParams();
7576
const dataSource = useAdapter();
7677
const { objects, loading: metadataLoading } = useMetadata();
@@ -120,18 +121,22 @@ export function RecordFormPage({ mode }: RecordFormPageProps) {
120121

121122
/**
122123
* Navigate back to the most relevant location.
123-
* Prefer `history.back()` so users return to the exact list/view they came
124-
* from (preserving filters, scroll position, etc.). Fall back to the
125-
* record detail (edit mode) or list (create mode) when there is no
126-
* history entry — happens on direct/refreshed loads of the URL.
124+
* Back when a page of this console is behind this one (react-router keys
125+
* the entry it started on `'default'`, as `FormPage`'s cancel reads it), so
126+
* users return to the exact list/view they came from. ⛔ Not
127+
* `window.history.length`: it counts the tab's entries from before the
128+
* console loaded, so a pasted URL went Back out of the app and lost the save
129+
* toast (objectstack-ai/objectstack#27454). Otherwise the record detail
130+
* (edit) or list (create), inside the app.
127131
*/
132+
const hasInAppHistory = location.key !== 'default';
128133
const goBack = useCallback(() => {
129-
if (typeof window !== 'undefined' && window.history.length > 1) {
134+
if (hasInAppHistory) {
130135
navigate(-1);
131136
} else {
132137
navigate(recordDetailUrl, { replace: true });
133138
}
134-
}, [navigate, recordDetailUrl]);
139+
}, [navigate, recordDetailUrl, hasInAppHistory]);
135140

136141
// Two names, not one (objectui#11733): the title and the toasts speak of ONE
137142
// record, so they keep the singular; the breadcrumb link opens the object's

0 commit comments

Comments
 (0)