Skip to content

Commit 733efbd

Browse files
feat(console): the object list keeps the page a reader turned to, in the address (#31229)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XMsoTAj6Y44LLZQepuTgtsc)_ Fixes #30850 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:控制台对象列表把当前页码写进地址栏(`uf__page`,用 replace 写,不新增历史记录)。翻到第 2 页、整页打开一条记录、再按返回,列表回到第 2 页;刷新和复制链接也落在同一页。手机上卡片列表的翻页同样生效。`ListView` 新增两个宿主属性 `page` / `onPageChange`(受控页码,和 `sort` 由宿主持有同一思路),不传时行为不变。 - 为什么改:维护者裁决 6708434746 选了方案 A(页码进 URL)。记录路由与列表路由是兄弟路由,整页打开记录会卸载列表,原来的页码是组件内 `useState(1)`,返回后总是第 1 页。手机上每条记录都整页打开,所以手机用户每看一条就回到第 1 页。 - 风险与回滚:两条既有规则保留:对象、筛选、排序、搜索、分组、每页条数或视图一变就回第 1 页并清掉参数;页码超出末页时收到最后一页。实现中发现一个连带问题并一起修了:路由器的函数式 `setSearchParams` 基于上一次渲染的参数,在第 2 页输入搜索词时,搜索写入和随之触发的「回第 1 页」写入会互相覆盖,搜索词丢失。现在 `ObjectView` 的写入在地址变化前串联。回滚即 revert。 - 先看:`ui/packages/app-shell/src/views/ObjectView.tsx`(写入串联,风险最高的一段),`ui/packages/plugin-list/src/ListView.tsx`(受控页码与 `setServerPage`),`ui/packages/app-shell/src/views/userFilterUrlState.ts`(`uf__page` 的读写)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/ObjectView.listPageInUrl-30850.test.tsx`,预期 `Tests 6 passed (6)`。 - 需要你做的:无,席位落地。 ## What changes - **`@object-ui/plugin-list` — a controlled page.** `ListView` declares two host props, `page` and `onPageChange`. Passing `page` makes the page controlled: the list reads it every render and asks for every move through `onPageChange` (the pager; the existing reset to page 1 when the object, filters, sort, search, grouping, page size or view change; the existing clamp to the last reachable page when a load comes back empty past the end). `setServerPage` stays the one door every move goes through; it is now stable and reads the latest committed page and host callback through a ref set in a layout effect, because the fetch effect's clamp and the sort handlers call it from closures taken renders ago. A move to the page already shown calls nothing. Without `page` the list keeps its page itself, exactly as before, and `onPageChange` only reports the move. Both props are destructured by name, so neither rides the `{...props}` forward into the view component. - **`@object-ui/app-shell` — the binding.** The list's page joins the `uf_` list-state family as `uf__page` (`LIST_PAGE_PARAM`), owned by `userFilterUrlState.ts`. `parseListPageParam` reads it strictly: a whole number from 1 up, else page 1. `applyListStateParams({ page })` writes a page past 1 and deletes the param for page 1 or `null`. `useObjectViewListUrlState` reads it from the address every render (`listPage`), and `objectViewListRenderer` passes it to `ListView` as `page`, with `onPageChange` writing back through `writeListUrlState({ page })`, which replaces the entry. - **Name choice against `urlParams.ts`.** `RESERVED_URL_PARAMS` does not hold `uf__page` (pinned). The bare name `page` was not taken: the `AppContent` docblock above `RECORD_NAMED_PAGE_ROUTE` records `page` as a page-scoped list parameter "elsewhere", and the `uf_` family is where the object list already keeps its search, sort, filter and grouping, so the sign-out return link (`withoutListStateParams`) and "Reset to view" drop it with the rest without new code paths. - **The page is the reader's place, not list state.** `uf__page` is left out of `userFilterParamsKey`, so a page alone never makes an address read as a link that carries list state (which would skip the reader's stored sort and filters), and out of `parseUserFilterParams`, so it is never read as a quick-filter chip on a field named `_page`. It is not part of the seed: it is read live on every render. - **Write chaining in `ObjectView`.** React Router's functional `setSearchParams` starts from the params it last rendered, and the navigation commits in a transition. A search typed on page 2 wrote `uf__search`, then the list's reset wrote "delete `uf__page`" from the same stale params, and the search term was lost (the real-router test below caught it). `ObjectView`'s one setter now starts each write from the previous write until `location.key` moves. It serves every writer on the page (list state, quick-filter chips, the drawer's `recordId`), so a chip change on page 2 is covered by the same fix. - **README** (`ui/packages/plugin-list/README.md`): a "Host props: `page` and `onPageChange`" section beside the other host props. - **Changeset** `.changeset/30850-list-page-in-url.md`: `@object-ui/plugin-list` and `@object-ui/app-shell` patch, `Clause-②: yes (widening)` (plugin-list's published `ListViewProps` gains two optional props; app-shell's module exports gain `LIST_PAGE_PARAM` and `parseListPageParam`), `Metadata-protocol: none`. No i18n key is added, changed or removed. ## Why Ruling 6708434746 (maintainer 「同意」 to A): the list page goes in the URL as a page-scoped param named against `urlParams.ts`, written with `replace`, cleared by the reset-to-1 rule. The record route is a sibling of the list route in `AppContent`, so a record opened as a full page unmounts `ObjectView` and `ListView`, and the list's `useState(1)` page started over on Back. On a phone every record opens as a page (#27058), so every phone reader lost their place after each record. **Phone measurement first (the ruling's precondition).** The phone list pages; it does not load on scroll. `ObjectGrid`'s card face (below the app breakpoint) ends in `object-grid-card-pager`, which calls the same `manualOnPageChange` the table's pager does, and there is no scroll or intersection loader in the card path. Measured in a rendered test at 390 px through the real `ObjectView` and grid: the card pager is drawn, its Next turns to page 2 (`$skip` 10), and Back from the record page lands on page 2 with `Page 2 of 3`. So no batch or scroll restore is owed. Not measured on a physical device or a live Console at phone width. ## Tests - New `ui/packages/app-shell/src/views/ObjectView.listPageInUrl-30850.test.tsx` (6 cases). This page, `plugin-view`'s `ObjectView`, `plugin-list`'s `ListView` and `plugin-grid`'s grid and card list render for real under a `MemoryRouter` whose record route is the list route's sibling, as in `AppContent`. Every step is a click (`fireEvent`) and the window the list asked for is read at the data source: - desktop: Next page → `uf__page=2`, `$skip` 10, every navigation `REPLACE`; the row's ↗ "Open as full page" → the record route, list unmounted; Back → `uf__page=2`, the remounted list asks only for `$skip` 10, the pager reads `Page 2 of 3`; one more Back leaves the list; - phone (390 px): the card pager turns to page 2; a card tap opens the record page; Back → page 2, `Page 2 of 3` on the card pager; - a reload or copied link with `uf__page=3` asks only for `$skip` 20; - a search typed on page 2 → `uf__search` written, `uf__page` gone, the window `{ skip: 0, search }`; - `uf__page=40` clamps to page 3 and the address follows; - control: `uf__page=two` opens page 1. - New `ui/packages/plugin-list/src/__tests__/ListView.controlledPage-30850.test.tsx` (7 cases, stub grid as in `ListView.serverPagination.test.tsx`): opens on the host's page with no page-1 window first; a pager move goes to the host and the window follows the value passed back; control: a host that does not pass the move back keeps the list where it is, no refetch; a query change sends the host back to 1; past the end clamps through the host; a move to the page already shown calls nothing; control: uncontrolled keeps its own page and still reports the move. - New `ui/packages/app-shell/src/views/userFilterUrlState.listPage-30850.test.ts` (7 cases): the spelling and no collision with `RESERVED_URL_PARAMS`; strict reading (`0`, `-2`, `2.5`, `1e3`, ` 2`, `02`, `two`, a 30-digit value all read as 1); page 1 and `null` delete, a patch without `page` leaves it; not a chip; not in `userFilterParamsKey`; dropped by both `withoutListStateParams`. - **Ablations**, see Verification. ## Acceptance notes - **Page size is not in the address.** A page size the reader picks in the pager is still `ListView` component state (no host stores it, before or after this change). Picking one resets to page 1 and clears the param, but after a full-page open and Back the list returns to the declared or default size while `uf__page` keeps its number, so the page number then counts windows of that size. Within the ruling's scope (the page only); noted, not filed. - **A malformed `uf__page` stays in the address** until the next move writes the param; it opens page 1 and changes nothing else. The other `uf_` params are rewritten on load by the seed; the page has no seed by design. - **Write chaining reaches every writer of `ObjectView`'s search params**, not only the list's. It changes nothing when writes are already a render apart (the base is then the rendered params, as before); it only stops a second write in the same window from undoing the first. - **Live Console not run.** The round trip and the phone path are proved by rendered tests through the real router and the real list and grid, not by a browser against a backend. ## Verification Base `8ef4d309` (`origin/main` when the branch was cut). Heavy runs through `scripts/pm/os-verify-lock.sh`. <details><summary>Suites and type-checks</summary> - Prerequisite: `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' --filter '@object-ui/plugin-list^...' build`, VERDICT command-exit 0. - `pnpm --dir ui exec vitest run --maxWorkers=3 --reporter=dot packages/plugin-list/` plus the 171 app-shell test files that import `ObjectView`, `userFilterUrlState`, `objectViewListUrlState`, `objectViewListRenderer` or `plugin-list` (on `ed830606`; head `5793838e` differs only by one test-file import): Test Files 343 passed (343), Tests 3749 passed (3749), VERDICT command-exit 0. The whole app-shell suite (2,212 files) is left to CI's Test UI. - The three new files on head `5793838e`: Test Files 3 passed (3), Tests 20 passed (20), VERDICT command-exit 0. - `pnpm --filter @object-ui/plugin-list run type-check` and `pnpm --filter @object-ui/app-shell run type-check` (each `tsc --noEmit` and `tsc -p tsconfig.test.json`): exit 0. - `pnpm --dir ui exec eslint` over the eight touched or new source and test files: exit 0, 0 errors. </details> <details><summary>Ablations (3, each red, each restored)</summary> Each through `scripts/ablation-replace.mjs`: anchor hit once, mutation proven on disk by anchor count and blob change, restore proven by blob == HEAD and `git diff HEAD` empty. ui vitest aliases `@object-ui/plugin-list` to `src`, so no build sits on the path. | # | Mutation | Red | |:--|:--|:--| | A1 | `ListView` ignores `page` (`const serverPage = ownPage;`) | 10 failed / 3 passed across the plugin-list and ObjectView files (the 3 are the uncontrolled control, the same-page no-call case and the malformed-value control) | | A2 | `ObjectView` writes stop chaining (`pending && false`) | 1 failed / 5 passed (the search on page 2 loses its term) | | A3 | `uf__page` counted in `userFilterParamsKey` | 1 failed / 6 passed (the page alone keys as nothing) | A3's first attempt was refused by the tool before running (its replacement text already occurred in the file, so the count could not rise); it was rerun with a distinct replacement, and the reading above is the rerun. </details> <details><summary>Derived gates (dispatch-gates --commands, 36 families, plus 4)</summary> All on head `5793838e`, exit codes captured before any pipe. - 36 derived, 36 run, all exit 0. `dispatch-gates.mjs --ran`: "36 derived famil(ies) accounted for — 36 run, 0 NOT-MEASURED". - Among them `pnpm --dir ui exec vitest run scripts/`: Test Files 170 passed | 2 skipped (172), Tests 4996 passed | 2 skipped (4998). - Run beyond the derivation, all exit 0: both type-checks, the eslint run above, and `pnpm --dir ui check:new-line-citations` (0 new citations). - The first round (head `ed830606`) had one red: `check:test-source-alias` asked the new ObjectView test to load `@objectstack/lint` at module top, since `ObjectView` reaches a dynamic `import('@objectstack/lint')` through `celAuthoring.ts`. `5793838e` adds that import; every family was then rerun. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. Signed: domain:ui#1 · os-project-manager · session_01FEkQ5pnaew3QBpfvsdUhfi --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5pnaew3QBpfvsdUhfi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d36d4ad commit 733efbd

10 files changed

Lines changed: 744 additions & 9 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/plugin-list': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
feat(plugin-list,app-shell): the console's object list keeps the page a reader turned to, in the address
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **`@object-ui/plugin-list`**: `ListView` gains two host props, `page` and `onPageChange`. Passing `page` makes the page controlled: the list reads it every render and asks for every move through `onPageChange` (the pager, the reset to page 1 when the object, filters, sort, search, grouping, page size or view change, and the clamp to the last reachable page when a load comes back empty past the end). A move to the page already shown calls nothing. Without `page` the list keeps its page itself, as before, and `onPageChange` only reports the move. Neither is a schema key.
12+
- **`@object-ui/app-shell`**: the console's object page keeps the list's page in the `uf__page` param of the list-state family (`LIST_PAGE_PARAM`, `parseListPageParam`), written with replace, so turning the page adds no history entry. Back from a record opened as a full page (the "Open as full page" arrow, a `page` object, a phone, where every record opens as a page and the card list turns the same page), a reload and a copied link all land on that page. Page 1 is no param, a value that is not a whole number from 1 up opens page 1, and "Reset to view" and the signed-out return link drop it with the rest of the list state. The page alone never makes an address read as a link that carries list state. The page's search-param writes now chain until the location moves, so a query change on page 2 and the reset to page 1 it causes no longer overwrite each other.
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#30850 — the console list keeps the page a reader turned to.
11+
*
12+
* The record route is a sibling of the list route, so a record opened as a
13+
* full page unmounts the list, and Back mounts it again. The page now lives in
14+
* the address (`uf__page`, written with replace), so:
15+
*
16+
* - page 2 → "Open as full page" → Back lands on page 2, and the record
17+
* page's own way back carries it too;
18+
* - on a phone (≤ 640 px every record opens as a page, objectstack#27058):
19+
* the card list's pager turns the same page, and Back lands on it;
20+
* - a reload or a copied link opens on the page it names;
21+
* - turning the page never adds a history entry;
22+
* - a change to the query (here: a search term) sends the list back to page
23+
* 1 and clears the param, and a page past the end clamps to the last one.
24+
*
25+
* Everything that decides it renders for real — this page, `plugin-view`'s
26+
* `ObjectView`, `plugin-list`'s `ListView` and `plugin-grid`'s grid and card
27+
* list — under a real router, driven by clicks. The window the list asked for
28+
* is read at the data source. Only the record body is a stub.
29+
*/
30+
31+
import * as React from 'react';
32+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
33+
import { render, cleanup, act, fireEvent, screen, waitFor } from '@testing-library/react';
34+
import { MemoryRouter, Routes, Route, useNavigate, useLocation, useNavigationType } from 'react-router-dom';
35+
import { createI18n, I18nProvider } from '@object-ui/i18n';
36+
37+
vi.mock('@object-ui/permissions', async (importOriginal) => {
38+
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
39+
// Stable identities — `ListView` names `perms` in its fetch dependencies.
40+
const perms = {
41+
check: () => ({ allowed: true }),
42+
checkField: () => true,
43+
getFieldPermissions: () => [],
44+
getRowFilter: () => undefined,
45+
getObjectApiOperations: () => undefined,
46+
roles: [],
47+
isLoaded: false,
48+
hasCapabilities: () => true,
49+
can: () => true,
50+
cannot: () => false,
51+
};
52+
const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] };
53+
return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms };
54+
});
55+
56+
vi.mock('@object-ui/auth', async (importOriginal) => ({
57+
...(await importOriginal<Record<string, unknown>>()),
58+
useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }),
59+
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
60+
createAuthenticatedFetch: () => vi.fn(),
61+
}));
62+
63+
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
64+
...(await importOriginal<Record<string, unknown>>()),
65+
useRealtimeSubscription: () => ({ lastMessage: null }),
66+
useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }),
67+
}));
68+
69+
vi.mock('sonner', () => ({
70+
toast: Object.assign(vi.fn(), {
71+
success: vi.fn(), error: vi.fn(), info: vi.fn(),
72+
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
73+
}),
74+
}));
75+
76+
vi.mock('./MetadataInspector', () => ({
77+
MetadataPanel: () => null,
78+
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
79+
}));
80+
vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null }));
81+
82+
import { ObjectView } from './ObjectView';
83+
import '@object-ui/plugin-grid';
84+
// `ObjectView` reaches a dynamic `import('@objectstack/lint')`: pay its load at
85+
// collection, never inside a case's clocked window (`check:test-source-alias`).
86+
import '@objectstack/lint';
87+
import { ExpressionProvider } from '../providers/ExpressionProvider';
88+
import { LIST_PAGE_PARAM, LIST_SEARCH_PARAM } from './userFilterUrlState';
89+
90+
const OBJ = 'paged_task_30850';
91+
const VIEW_ID = 'all';
92+
const LIST_PATH = `/apps/demo/${OBJ}/view/${VIEW_ID}`;
93+
const PAGE_SIZE = 10;
94+
const TOTAL = 25;
95+
const LAST_PAGE = Math.ceil(TOTAL / PAGE_SIZE);
96+
const ALL_ROWS = Array.from({ length: TOTAL }, (_, i) => ({ id: `t${i + 1}`, name: `Task ${i + 1}` }));
97+
98+
const OBJECTS = [
99+
{
100+
name: OBJ,
101+
label: 'Task',
102+
pluralLabel: 'Tasks',
103+
fields: {
104+
id: { type: 'text', label: 'Id' },
105+
name: { type: 'text', label: 'Name' },
106+
},
107+
listViews: {
108+
[VIEW_ID]: { name: VIEW_ID, label: 'All', type: 'grid', columns: ['name'], pagination: { pageSize: PAGE_SIZE } },
109+
},
110+
},
111+
];
112+
113+
/** The windows `ListView` asked for, newest last. The record-count probe (`$top: 0`) is excluded. */
114+
let windows: Array<{ skip: number; search?: string }> = [];
115+
116+
function makeDataSource() {
117+
return {
118+
find: vi.fn(async (object: string, params: any) => {
119+
const top = Number(params?.$top ?? PAGE_SIZE);
120+
const skip = Number(params?.$skip ?? 0);
121+
if (object === OBJ && top !== 0) windows.push({ skip, search: params?.$search });
122+
const matched = params?.$search
123+
? ALL_ROWS.filter((r) => r.name.toLowerCase().includes(String(params.$search).toLowerCase()))
124+
: ALL_ROWS;
125+
return { data: matched.slice(skip, skip + top), total: matched.length };
126+
}),
127+
findOne: vi.fn(async () => null),
128+
create: vi.fn(async () => ({})),
129+
update: vi.fn(async () => ({})),
130+
delete: vi.fn(async () => ({})),
131+
getObjectSchema: vi.fn(async (name: string) => OBJECTS.find((o) => o.name === name) ?? null),
132+
} as never;
133+
}
134+
135+
const lastWindow = () => windows[windows.length - 1];
136+
137+
/** A window long enough for every effect a step schedules to have fired. */
138+
const settle = () => act(() => new Promise<void>((resolve) => setTimeout(resolve, 400)));
139+
140+
const PHONE_WIDTH = 390;
141+
const DESKTOP_WIDTH = 1440;
142+
function setWidth(width: number) {
143+
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true });
144+
}
145+
146+
interface Host {
147+
search: () => URLSearchParams;
148+
pathname: () => string;
149+
/** How every navigation after mount was made (`PUSH` / `REPLACE` / `POP`). */
150+
navigations: () => string[];
151+
back: () => Promise<void>;
152+
}
153+
154+
async function mountHost(entries: string[]): Promise<Host> {
155+
const seen: Array<{ type: string; pathname: string; search: string }> = [];
156+
let navigate: ReturnType<typeof useNavigate> = () => {};
157+
function Probe() {
158+
navigate = useNavigate();
159+
const location = useLocation();
160+
const type = useNavigationType();
161+
React.useEffect(() => {
162+
seen.push({ type, pathname: location.pathname, search: location.search });
163+
}, [location.key, type, location.pathname, location.search]);
164+
return null;
165+
}
166+
const page = <ObjectView dataSource={makeDataSource()} objects={OBJECTS as any} onEdit={() => {}} />;
167+
// The list's own record href is built from the DOCUMENT's path
168+
// (`listRecordDetailUrl`), which a `MemoryRouter` does not move.
169+
window.history.replaceState(null, '', LIST_PATH);
170+
render(
171+
<I18nProvider instance={createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false })}>
172+
<ExpressionProvider user={{ id: 'u1', name: 'Ada', profile: 'user' }}>
173+
<MemoryRouter initialEntries={entries} initialIndex={entries.length - 1}>
174+
<Probe />
175+
<Routes>
176+
<Route path="/elsewhere" element={<div>elsewhere</div>} />
177+
<Route path="/apps/:appName/:objectName/view/:viewId" element={page} />
178+
{/* The record route is the list route's SIBLING, as in `AppContent`: opening it unmounts the list. */}
179+
<Route path="/apps/:appName/:objectName/record/:recordId" element={<output data-testid="record-page-route" />} />
180+
</Routes>
181+
</MemoryRouter>
182+
</ExpressionProvider>
183+
</I18nProvider>,
184+
);
185+
await settle();
186+
const mountedAt = seen.length;
187+
const current = () => seen[seen.length - 1];
188+
return {
189+
search: () => new URLSearchParams(current().search),
190+
pathname: () => current().pathname,
191+
navigations: () => seen.slice(mountedAt).map((s) => s.type),
192+
back: async () => {
193+
await act(async () => { navigate(-1); });
194+
await settle();
195+
},
196+
};
197+
}
198+
199+
/** The table's pager, or the phone card list's (both carry the table's words). */
200+
async function turnToNextPage() {
201+
fireEvent.click(screen.getAllByRole('button', { name: 'Next page' })[0]);
202+
await settle();
203+
}
204+
205+
beforeEach(() => {
206+
windows = [];
207+
vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(() => {});
208+
vi.stubGlobal(
209+
'fetch',
210+
vi.fn(async () =>
211+
new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }),
212+
),
213+
);
214+
});
215+
216+
afterEach(() => {
217+
cleanup();
218+
vi.unstubAllGlobals();
219+
vi.restoreAllMocks();
220+
setWidth(DESKTOP_WIDTH);
221+
window.localStorage.clear();
222+
window.history.replaceState(null, '', '/');
223+
});
224+
225+
describe('the console list keeps the page a reader turned to (objectstack#30850)', () => {
226+
it('page 2 → open as full page → Back lands on page 2; the turn added no history entry', async () => {
227+
setWidth(DESKTOP_WIDTH);
228+
const host = await mountHost(['/elsewhere', LIST_PATH]);
229+
await screen.findByText('Task 1', undefined, { timeout: 5000 });
230+
expect(lastWindow().skip).toBe(0);
231+
232+
await turnToNextPage();
233+
expect(host.search().get(LIST_PAGE_PARAM)).toBe('2');
234+
expect(lastWindow().skip).toBe(PAGE_SIZE);
235+
await screen.findByText('Task 11');
236+
expect(host.navigations().every((type) => type === 'REPLACE')).toBe(true);
237+
238+
// The row's ↗ — "Open as full page" — on a row of page 2.
239+
const row = screen.getByText('Task 11').closest('tr')!;
240+
fireEvent.click(row.querySelector('[data-testid="record-open-as-page"]')!);
241+
await screen.findByTestId('record-page-route', undefined, { timeout: 5000 });
242+
expect(host.pathname()).toBe(`/apps/demo/${OBJ}/record/t11`);
243+
expect(screen.queryByText('Task 11')).toBeNull();
244+
245+
windows = [];
246+
await host.back();
247+
expect(host.pathname()).toBe(LIST_PATH);
248+
expect(host.search().get(LIST_PAGE_PARAM)).toBe('2');
249+
await screen.findByText('Task 11', undefined, { timeout: 5000 });
250+
// The remounted list asked for page 2 and nothing else.
251+
expect(windows.map((w) => w.skip)).toEqual(windows.map(() => PAGE_SIZE));
252+
expect(screen.getByText(`Page 2 of ${LAST_PAGE}`)).toBeTruthy();
253+
254+
// One more Back leaves the list: turning the page pushed nothing.
255+
await host.back();
256+
expect(host.pathname()).toBe('/elsewhere');
257+
});
258+
259+
it('on a phone the card list turns the same page, and Back from the record page lands on it', async () => {
260+
setWidth(PHONE_WIDTH);
261+
const host = await mountHost(['/elsewhere', LIST_PATH]);
262+
await screen.findByText('Task 1', undefined, { timeout: 5000 });
263+
// A phone pages; it does not load more on scroll.
264+
expect(screen.getByTestId('object-grid-card-pager')).toBeTruthy();
265+
266+
await turnToNextPage();
267+
expect(host.search().get(LIST_PAGE_PARAM)).toBe('2');
268+
await screen.findByText('Task 11');
269+
270+
fireEvent.click(screen.getByText('Task 12'));
271+
await screen.findByTestId('record-page-route', undefined, { timeout: 5000 });
272+
expect(host.pathname()).toBe(`/apps/demo/${OBJ}/record/t12`);
273+
274+
windows = [];
275+
await host.back();
276+
expect(host.search().get(LIST_PAGE_PARAM)).toBe('2');
277+
await screen.findByText('Task 12', undefined, { timeout: 5000 });
278+
expect(windows.map((w) => w.skip)).toEqual(windows.map(() => PAGE_SIZE));
279+
expect(screen.getByTestId('object-grid-card-page-info').textContent).toBe(`Page 2 of ${LAST_PAGE}`);
280+
});
281+
282+
it('a reload or a copied link opens on the page it names', async () => {
283+
const host = await mountHost([`${LIST_PATH}?${LIST_PAGE_PARAM}=3`]);
284+
await screen.findByText('Task 21', undefined, { timeout: 5000 });
285+
expect(windows.map((w) => w.skip)).toEqual(windows.map(() => 2 * PAGE_SIZE));
286+
expect(host.search().get(LIST_PAGE_PARAM)).toBe('3');
287+
});
288+
289+
it('a change to the query sends the list back to page 1 and clears the param', async () => {
290+
const host = await mountHost([`${LIST_PATH}?${LIST_PAGE_PARAM}=2`]);
291+
await screen.findByText('Task 11', undefined, { timeout: 5000 });
292+
fireEvent.click(screen.getByTestId('search-icon-button'));
293+
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'Task 1' } });
294+
await settle();
295+
await waitFor(() => expect(host.search().get(LIST_SEARCH_PARAM)).toBe('Task 1'));
296+
expect(host.search().has(LIST_PAGE_PARAM)).toBe(false);
297+
expect(lastWindow()).toEqual({ skip: 0, search: 'Task 1' });
298+
});
299+
300+
it('a page past the end clamps to the last reachable one, and the address follows', async () => {
301+
const host = await mountHost([`${LIST_PATH}?${LIST_PAGE_PARAM}=40`]);
302+
await screen.findByText('Task 25', undefined, { timeout: 5000 });
303+
expect(host.search().get(LIST_PAGE_PARAM)).toBe(String(LAST_PAGE));
304+
expect(lastWindow().skip).toBe((LAST_PAGE - 1) * PAGE_SIZE);
305+
});
306+
307+
it('CONTROL: a value that is not a page opens page 1', async () => {
308+
await mountHost([`${LIST_PATH}?${LIST_PAGE_PARAM}=two`]);
309+
await screen.findByText('Task 1', undefined, { timeout: 5000 });
310+
expect(windows.every((w) => w.skip === 0)).toBe(true);
311+
});
312+
});

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*/
1111

1212
import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense } from 'react';
13-
import { useParams, useSearchParams, useNavigate, useLocation, useHref } from 'react-router-dom';
13+
import { useParams, useSearchParams, useNavigate, useLocation, useHref, createSearchParams } from 'react-router-dom';
1414
import { resolveAffordance, type FilterTokenScope, type SchemaLike } from '@object-ui/core';
1515
import { useObjectListPreferences } from '../context/ListPreferencesProvider.js';
1616
const ImportWizard = lazy(() =>
@@ -198,11 +198,28 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
198198
// One setter for the page's life, calling the latest one.
199199
const setSearchParamsRef = useRef(setSearchParamsNow);
200200
setSearchParamsRef.current = setSearchParamsNow;
201+
const location = useLocation();
202+
// objectstack#30850 — the router hands a functional update the params it
203+
// last RENDERED, and its navigation commits in a transition. Two writes
204+
// before that commit (a search term, then the list's own page reset that
205+
// term causes) would each start from the same params, and the second
206+
// would drop the first. So each write starts from the one before it until
207+
// the location moves (a new `location.key`, which every navigation makes).
208+
const locationKeyRef = useRef(location.key);
209+
locationKeyRef.current = location.key;
210+
const pendingSearchRef = useRef<{ locationKey: string; params: URLSearchParams } | undefined>(undefined);
201211
const setSearchParams = useCallback<typeof setSearchParamsNow>(
202-
(next, options) => setSearchParamsRef.current(next, options),
212+
(next, options) => setSearchParamsRef.current((rendered) => {
213+
const pending = pendingSearchRef.current;
214+
const base = pending && pending.locationKey === locationKeyRef.current
215+
? new URLSearchParams(pending.params)
216+
: rendered;
217+
const params = createSearchParams(typeof next === 'function' ? next(base) : next);
218+
pendingSearchRef.current = { locationKey: locationKeyRef.current, params };
219+
return params;
220+
}, options),
203221
[],
204222
);
205-
const location = useLocation();
206223
const { showDebug } = useMetadataInspector();
207224
const { t, language } = useObjectTranslation();
208225
const { objectLabel, objectPluralLabel, objectDescription: objectDesc, viewLabel, viewEmptyState, actionParamText, fieldLabel, fieldOptionLabel } = useObjectLabel();
@@ -708,6 +725,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
708725
handleResetView,
709726
clearSourceListState,
710727
readPanelFilters,
728+
listPage,
711729
} = useObjectViewListUrlState({
712730
objectName,
713731
objectDef,
@@ -1065,6 +1083,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
10651083
listIdentityKey,
10661084
listSeedRef,
10671085
writeListUrlState,
1086+
listPage,
10681087
filterScope,
10691088
actions,
10701089
handleRowClick,

0 commit comments

Comments
 (0)