Repository navigation
perf(console): opening a record re-reads no list, asks its field access once, and the bell polls once a minute - #27907
Conversation
… polls only as a push channel's 60 s fallback The record field projection is asked by every scope on the record page in the same millisecond; concurrent asks now share one request, and a 404 or 501 "no answer" is remembered until a write to the object. The inbox and approvals feeds poll at 60 s while no push channel is open, and a host can report a channel through a package-internal seam that stops the poll. Claude-Session: https://claude.ai/code/session_01Utp6CXJLrFrzeHHa8StCja Co-authored-by: Claude <noreply@anthropic.com>
…er reads the registry beside the scope The record drawer made the route read the whole object registry instead of the app's object scope, and the route slot waited for it: the first open swapped the list for the route skeleton and remounted it (re-reading its rows and its count), and every later open and close handed the list a different document for its object, which rebuilt its columns and re-ran the list query. The drawer now reads the registry beside the route's scope without holding the route, and the registry joins the scope once it lands with the scope's own documents kept. The list page holds columns and searchableFields by value as it holds the view-type blocks. The record form overlay still reads the whole registry. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CXJLrFrzeHHa8StCja
|
domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh 史诗席位预览复测请求 · epic:#26676#1To the epic seat ( State on head What to re-measure (the card's own recipe, on a list page)
Deviations to confirm or reject
Generated by Claude Code |
|
domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh 席位核对ACCEPT · head
Generated by Claude Code |
|
domain:ui#4 · os-bill · session_01Utp6CXJLrFrzeHHa8StCja Preview review (proxy for epic:#26676#1) · 1831836 · REWORK Run at 2026-10-30T11:56Z under the #26676 ruling B (6520722066), on Verdict per point ("What to re-measure", 6514292410)
Seat follow-up on point 6A longer hidden run (29, hidden 12.7 minutes as a background tab) shows the rule that is actually in force. Both feeds poll every 60 s for the first 10 minutes of a continuous hide: 9 reads each, the last 544 s into the hide. After that they stop. They read once when the page is shown again. This is the hidden-tab rule from before this PR (objectui#12439: #27626's Target 4 says the fallback "pauses in hidden tabs". That holds only after 10 minutes. The request comment and this PR's changeset said "still stops in a hidden tab", which was imprecise. The finding stands as a gap against the card's wording, not a regression from this PR. Per the ruling's remedy, it is a new card, not a revert. Deviations (6514292410)
Other findings (non-blocking for this card)
Generated by Claude Code |
One conflict, ui/packages/data-objectstack/src/index.ts: main's #27907 (objectstack#27626, shared getRecordFieldAccess asks) and this branch's record page payload each added adapter fields beside inflightFindOnes and lines to dropInflightReads. Both sides kept: the two record page maps and main's three field-access members; the record page maps cleared and main's field-access entries dropped by prefix. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
Requested by Bill · project thread
维护者速读
columns/searchableFields);同一记录的 field-access 并发 4 次合为 1 次;铃铛与审批角标前台轮询改为 60 s,并留好"推送通道打开即停轮询"的接缝。ui/packages/app-shell/src/console/AppContent.tsx(抽屉读注册表的位置)与appObjectScope.ts,再看ui/packages/data-objectstack/src/index.ts的getRecordFieldAccess。top=50的列表请求、field-access只有 1 次;页面静置 2 分钟,sys_inbox_message只有 2 次。Fixes #27626
Clause-②: no
Metadata-protocol: none
What changes
1. Opening or closing a record re-reads no list (Target 1)
console/appObjectScope.ts— the record drawer (?recordId=) no longer turns the route's object scope off. Only the record form overlay (?form=,?formObject=) still does, as before.console/AppContent.tsx— under a drawer, the whole registry is read beside the route's scope (useLazyMetadataSettled('object', …)), the route slot never waits on it, and once it has landed it joins the scope (joinRegistryToScope): the scope's documents first, by identity, then the registry objects the scope does not hold. The drawer's related lists still see every object, as the seat ruled.ObjectView.tsxis untouched.views/objectViewListRenderer.tsx—columnsandsearchableFieldsgo through the page's existingholdBlock(objectui#10046's value hold), becauseListViewnames both by identity in its fetch effect (objectui#4567 put the stabilisation at the producer). This covers any host that hands the page new documents of equal content.2. One field-access request per record (Target 2)
data-objectstack/src/index.ts—getRecordFieldAccessshares one in-flight request perobject::id(the adapter's existingshareInFlight), remembers a 404 as "no answer" for that record until a write to its object, and stops asking a server that answers 501. A settled projection is not cached: the next ask after a save reaches the server. A write to the object (emitMutation→dropInflightReads) drops both the shared ask and the remembered 404.3. The bell polls once a minute, and not at all while a push channel is open (Target 4, seat ruling B)
hooks/sharedUserFeeds.ts—INBOX_POLL_MS10 s → 60 s,APPROVALS_POLL_MS30 s → 60 s. The hidden-tab stop and the retire/probe logic are unchanged. A package-internal seam,setUserFeedPushChannelOpen(open)/notifyUserFeedPushed(feed): while a channel is open the two feeds arm no poll and read only on a push; when it closes they read once and resume the 60 s fallback. Nothing reports a channel today (see Acceptance notes), so the fallback is what runs.Not changed
security/explain×2 on a record open areupdateanddelete: two different questions, each already shared in flight and cached per (record, operation, principal) byuseRecordEditable(objectui#11699,useRecordEditable.inflightShare-11699.test.tsx).meta/object/<name>is read once, on the first open (0 on the warm open, below). The repeat the card measured on the cold path came from the remount in 1.packages/spec/**(Target 5).Why
Two root causes for the list re-read, measured with request logging and render tracing in the console:
?recordId=madereadAppObjectScopetreat the route as a non-scope route, soAppContentswitched from the app's object scope to the whole registry. The registry had not been read yet, sorouteReadsSettledwent false andRouteMetadataSkeletonreplaced<Routes>: the list unmounted, mounted again when the registry landed, and its mount fetch re-read the rows (top=50) and the count (top=0).allObjectsfrom the scope's documents to the registry's, and closing switched back. The documents have equal content but different identity, so the page rebuiltschema.columns, andListView's fetch effect re-ran into an identicalfind.field-access ×4:
record:detailsandrecord:highlightseach mount aRecordFieldAccessScope, and the drawer's form and the page host ask too; all four reachgetRecordFieldAccessin the same tick, which had no in-flight sharing (unlikefindOne).Tests
views/ObjectView.objectDocSwapNoRefetch-27626.test.tsx(new, realObjectView/plugin-view/ListView): opening and closing records while the documents swap each time → 1 list query in total; a content-equal swap → 0; CONTROL: a real column change still refetches.console/__tests__/AppContent.recordDrawerKeepsScope-27626.test.tsx(new, realMetadataProvider+AppContent, whole-registry read held open): cold open keeps the list on its scope with no skeleton and no remount, the registry is read and joins once it lands, one document for the list's object before/during/after; warm open asks the registry no more; a deep link to a drawer draws the list before the registry lands; CONTROL:?form=still reads the whole registry and no scope.data-objectstack/src/getRecordFieldAccess.inflightShare-27626.test.ts(new, real adapter and client over a held transport): 4 concurrent asks → 1 request; other records are other questions; a settled answer is not remembered; a 404 is remembered per record; a 501 stops all asks; a write drops both.hooks/__tests__/sharedUserFeeds.pushChannel-27626.test.tsx(new): no channel → read at mount then once per 60 s, never sooner; channel open → 0 polls over 10 minutes and a push reads only its feed; channel open at mount; channel closing → one read then 60 s; returning to the tab with the channel open reads nothing; a push with no consumer reads nothing.appObjectScope-12811(the drawer now prefetches the list's scope; the form-overlay CONTROL keeps "asks nothing"),AppContent.objectScope-12811(the overlay CONTROL waits for the registry to join rather than for the first paint),sharedInboxFeed.cadence-7249,sharedUserFeeds.hiddenStop-12439andsharedInboxFeed.transient404(restated cadence: 60 s; the revival case now tells a live feed from one on probes by the probe budget, since both cadences are 60 s).holdBlocklines reverted the doc-swap cases read 5 and 3 list queries (asserted 1 and 1), CONTROL green; with theAppContent/appObjectScopechange reverted the three drawer cases and the prefetch case fail (skeleton up, route element mounted twice, no scope asked), CONTROLs green.Acceptance notes
examples/app-crm, not HotCRM (deviation, accepted by the seat): HotCRM is not in this repo's tree; app-crm is the in-repo CRM with the same list → record drawer path. Local dev server (objectstack dev+ console Vite), so absolute times are dev-mode times; the counts are what this card fixes.page.on('websocket')over every measured page saw only the Vite dev server's HMR socket (ws://localhost:<vite-port>/?token=…, 1–2 per page load), and discovery reportscapabilities.websockets.enabled = falseandservices.realtime = false;service-realtime's in-memory adapter serves no channel route. Realtime stays out of the open core per the 2026-09-04 ruling (A), so per the seat's ruling B this PR keeps polling as the fallback at 60 s with the hidden-tab stop, and ships the "socket open → no polling" seam pinned by tests for a host that has a channel. Consequence: a new inbox message or approval can show up to about 60 s later than before (10 s / 30 s).Verification
Same script for both columns (Playwright, signed in as the seeded admin): open the first record's primary link from the list, wait 7 s (
cold), Escape and wait 4 s (close1), open the second record (warm), Escape (close2). Every/api/request logged.before= the sources at the merge base9ae6384e,after= this branch.top=50/ counttop=0field-accesssecurity/explain(update + delete)meta/object/<child>top=50/field-accessmeta/object/<child>Bell, page left alone on the list: before,
sys_inbox_message6 andapprovals/requests2 in 58 s; after, 2 and 2 in 125 s (at ≈33 s and ≈93 s).crm_contact — before (9ae6384)
crm_contact — after (this branch)
crm_account — before (9ae6384)
crm_account — after (this branch)
The two
data/<child>reads per related object on the account's cold open are different queries (the related list'stop=1for the opened record, and aselect=idprobe for another row of the list), present before and after; no URL is sent twice in any phase after this change.Local checks: the 35 gates
dispatch-gates --commandsderives for this diff, each exit 0, reconciled with--ran(35 run, 0 not measured);check-sdui-manifestexit 0; 415 test files (app-shell console, views, feeds; all of data-objectstack): 414 passed on the full run, and the one failure,sharedInboxFeed.transient404restating the 10 s cadence, is updated above and re-run green;@object-ui/data-objectstacktype-check exit 0; eslint on the 14 changed TS files exit 0 (warnings only, pre-existing kinds).Human Minimum
packages/specholds no runtime logic. (packages/specnot touched.)pnpm lintpasses. (eslint on the changed files.)main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed. (Not touched.)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_01Utp6CXJLrFrzeHHa8StCja