fix(plugin-list): key the records fetch on its window, not on the current visualization - #10045
Conversation
…rent visualization `ListView`'s fetch effect named `currentView` in its dependency list, so every visualization switch re-ran it — while the query it builds never reads `currentView`. The visualization reaches the wire through exactly one number, the `$skip` of the window, and at page 1 that number is 0 for a flat grid and 0 for every other surface. The re-issued request was therefore byte-for-byte the one already on screen, and a board opened through a view switch cost two identical `GET /api/v1/data/<object>?top=…&select=…` round trips before its first paint (objectui#7394). The effect is keyed on `fetchSkip` instead. The two surface-shaped readings the fetch used to latch — the server total behind the grid's pager and the row-cap banner's "…but the real total is known" half — are derived at render, so they answer for the render that reads them rather than for the one that wrote them, which is what let the dependency go. Every re-fetch that moves the window is kept: turning the page still refetches, and leaving a paged grid from page 3 for a surface that consumes the whole batch still refetches. `ganttOwnsData` and `groupingConfig` stay named — the first flips this effect between fetching and standing down, the second changes the projection it asks for. Not the kanban: `ObjectKanban`'s own fetch stands down under `ListView` (`hasExternalData`) and issued zero data requests across the reproduction. Not an in-flight dedupe: the effect now runs once rather than running twice into a suppressed second request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
…assertion The "a real query change still refetches" case asserted the post-switch count as its precondition, so it went red under the ablation alongside the two cases that are ABOUT the defect — which would let a switched-off effect look like a control failure rather than what it is. It now counts from whatever the switch left behind, so it discriminates a fix from a dead effect and stays green on both sides of the change. Registry stubs get their namespaces while here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
…array it governs The #7394 note landed BETWEEN `// eslint-disable-next-line react-hooks/exhaustive-deps` and the dependency array, which detaches the directive: `eslint .` then reports it as an unused directive (an ERROR, not a warning) and the finding it was suppressing reappears elsewhere. The prose moves above the directive, and says so in place so the next edit does not repeat it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
|
changeset-claim-re-read
|
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…ban-double-mount-fetch
…t WITH `currentView` A reviewer diffing the dependency array sees two names vanish and has to re-derive why the second is safe. It is the same removal: `fetchSkip` is defined as the exact expression the effect used to compute inline, so the page, the page size and whether the surface pages at all are folded into the one number the query carries. `serverPage` is untouched elsewhere and the pager still reads it. The tighter consequence — a `serverPage` change under `paginate === false` moves nothing and no longer re-runs the effect — is stated as a claim about the definition and labelled as one, because nothing in the suite re-derives it (AGENTS.md #9). Comment only; no behaviour moves with this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Brings objectui#7394 (PR #10045, 237e5b8) in. It moves packages/plugin-list, which this branch does not touch — no conflict, and the package checks are re-run after this merge rather than before it. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
Fixes #7394
Clause-②: no
What this is
Reproduced, attributed, fixed. A view switch to the kanban issued the identical
records query twice. The second round trip came from
ListView(
packages/plugin-list), not from the kanban.Reproduction — the real components, in a browser, four channels
The pre-installed Chromium at
/opt/pw-browsers/chromiumdrove a Vite devserver whose middleware served
/api/v1/data/*and/api/v1/metadata/*andwrote its own access log. The page mounts the REAL
ListViewwith the REALObjectKanbanbehind the REALComponentRegistry, and the switch is a click onthe REAL
ViewSwitchertab — no StrictMode anywhere, because the card hadalready measured a StrictMode double effect away on a production build.
Requests were counted on four independent channels, and a hand-issued
same-subject request (
/api/v1/data/showcase_task?probe=selftest) ran in thesame command as the instrument self-test, so a zero is a reading and not a dark
instrument:
requesteventsfetchwrapperdataSource.findlogA fifth channel, E, wraps the registry entry for
object-kanbanso the realboard still mounts and renders while the wrapper records that it did — without
it, "the kanban never mounted" and "the kanban mounted and fetched nothing" are
the same reading, which is the trap objectui#7393 paid for. Every run below
reports
mounts: 1, unmounts: 0, with the board's lanes (Todo/Doing/Done)and its cards (
Alpha/Beta/Gamma) on screen.Counts —
GET /api/v1/data/showcase_task?top=100&select=…, per sessionPermissionProviderviewTypedown late (saved view arrives async), no clicktop=100isDEFAULT_LIST_PAGE_SIZE, and theselect=list is built from theview's columns — both
ListView's, and the two URLs were byte-identical.Attribution —
packages/plugin-list, and ⛔ not the kanbanObjectKanbanissued zero data requests in every run. UnderListViewitshasExternalDataguard is true (the parent hands rows down asdata), so itsown fetch effect returns at its first line. Its one round trip on a switch is
the object-definition read, which is not the request the card counted — and the
card's URL carries a
select=projection thatObjectKanbannever sends.Both of triage's and the dispatch's hypotheses were tested and are not the
cause:
useSettledSchemaholds ONE{ key, def }state, so
readyanddefflip in the same commit, and the not-ready passreturns before querying. The board never queried at all here.
perms(the objectui#7237 / finding(app-shell/plugin-view): bothObjectViewlayers remount the whole view subtree by bumping a Reactkey=after a write — the shape AGENTS.md §5 #8's corollary names verbatim #10035 / finding(plugin-gantt):reloadstill keys oneffectiveDataSource, auseMemoidentity — the dependency AGENTS.md §5 #10 bans, on the same line another was removed from for exactly that reason #10036 family). The run under a realPermissionProvidermeasured identical to the run with no provider: 2 before,1 after.
usePermissionsalready hands back one identity per context value.The cause is
ListView's fetch effect namingcurrentViewin its dependencylist. The query it builds never reads the visualization — the visualization
reaches the wire through exactly one number, the
$skipof the window, and atpage 1 that number is 0 for a flat grid and 0 for every other surface.
The fix
The effect is keyed on the window (
fetchSkip) instead of on thevisualization. Two readings the fetch used to LATCH — the server total behind
the grid's pager, and the row-cap banner's "…but the real total is known" half —
are derived at render, so they answer for the render that reads them rather than
for the one that wrote them. That is what let the dependency go without changing
a single value any consumer sees.
⛔ Not an in-flight dedupe: the effect now RUNS once, rather than running twice
into a suppressed second request.
Every re-fetch that moves the window is kept. Turning the page still refetches;
leaving a paged grid from page 3 for a surface that consumes the whole batch
still refetches;
ganttOwnsDataandgroupingConfigstay named, because thefirst flips this effect between fetching and standing down and the second
changes the projection it asks for.
Reverse verification — direction predicted before running, then observed
Predicted: putting
currentViewback in the dependency list turns §1 and §2 REDwhile §3, §4 and §5 stay GREEN.
Observed, through
ablation-replaceso the mutation is proven on disk ratherthan by an exit code:
§1 reports
expected 1 times, but got 2 times— the duplicate, at componentlevel. §3 (from page 3 the same switch STILL refetches, because the window
really moves), §4 (a real query change still refetches) and §5 (the row-cap
banner keeps the gate the fetch used to apply) are green in both worlds, which
is what stops a fix that simply switched the effect off from passing.
Checks — all taken AFTER the last edit
pnpm exec vitest run packages/plugin-list/— 82 files / 1005 tests passedpnpm --filter @object-ui/plugin-list type-check— exit 0 (tsc --noEmitand
tsc -p tsconfig.test.json)pnpm --filter @object-ui/plugin-list lint— 0 errors, 541 warnings, allpre-existing;
ListView.tsxalone reports 175 warnings on this branch and 175on
origin/main, so this branch adds nonenode scripts/check-changeset-presence.mjs— exit 0, 1 changeset declaredos-verify-lock.sh; its printed verdict line isVERDICT command-exit 0in each caseThe dependency closure (
pnpm --filter '@object-ui/plugin-list^...' build) wasbuilt first — a fresh worktree has no
dist/*.d.ts, and the type-check red thatproduces is
PREREQUISITE NOT MET, not a finding.Acceptance notes
host that answers the switch by minting a FRESH schema object hands
ListViewnew identities for
columns,kanbanandoptions, which re-issues thequery on its own. Measured in the last two rows of the table above (the
hostrerenderand late-viewTypeshapes still cost one extra request afterthis change). objectui#4567 already ruled that
ListView's by-identitydependency is correct for a real column change and that the stabilisation
belongs at the PRODUCER, so this PR does not touch it.
worktree that held it is removed.
维护者速读(草稿)
改了什么 ——
ListView取数 effect 的依赖从「当前是哪个视图」(currentView)换成「这次取数的窗口」(
fetchSkip);两个原本在取数时锁存、且形状随视图而变的读数(网格分页器背后的 server total、行数上限提示条的那半个条件)改为在渲染时推导。
为什么改 —— 切到看板时,
ListView会把屏幕上已有的那条查询原样再发一次。视图本身从不进入查询:视图唯一能影响到线上的只有窗口的
$skip,而第一页时网格和其它所有视图的
$skip都是 0,于是那次往返一个字节都没变,拿回来的答案被丢掉。风险与代价(含回滚) —— 行为面按值等价:每一个会改变窗口的重新取数都保留
(翻页、从第 3 页离开分页网格进入整批消费的视图)。回滚就是把
currentView放回依赖数组,单行,反向验证已经演练过这一步并证明了还原。风险集中在「某个消费方其实
依赖『切视图会刷新数据』这一副作用」——仓内没有这样的断言,刷新有自己的按钮
(
refreshKey)。席位意见 ——
你要做的 —— 确认「切视图不再顺带刷新数据」是可接受的:刷新数据的正路是刷新按钮
与
notifyDataChanged,切视图只是换一种画法。🤖 Generated with Claude Code
https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
Generated by Claude Code