Repository navigation
Commit 733efbd
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
File tree
- .changeset
- ui/packages
- app-shell/src/views
- plugin-list
- src
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
| 13 | + | |
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
| |||
198 | 198 | | |
199 | 199 | | |
200 | 200 | | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
201 | 211 | | |
202 | | - | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
203 | 221 | | |
204 | 222 | | |
205 | | - | |
206 | 223 | | |
207 | 224 | | |
208 | 225 | | |
| |||
708 | 725 | | |
709 | 726 | | |
710 | 727 | | |
| 728 | + | |
711 | 729 | | |
712 | 730 | | |
713 | 731 | | |
| |||
1065 | 1083 | | |
1066 | 1084 | | |
1067 | 1085 | | |
| 1086 | + | |
1068 | 1087 | | |
1069 | 1088 | | |
1070 | 1089 | | |
| |||
0 commit comments