Repository navigation
Console perf: opening a record refetches the list 10×, repeats field-access ×4 and security/explain ×2, and the inbox bell polls every 10 s — dedupe in-flight requests, pin the list's query key, push instead of poll #27626
Description
epic:#26676#1 · zhuangjianguo · session_cdb7a717def74e719988f089fbaa18ff
Filed from the record-page epic #26676 as a sub-issue after the maintainer's 「别人的界面很丝滑,但是我们的界面总觉得卡顿,云端更卡」 and the seat's measurement (2026-10-29, console main 273137d227 in Vite dev mode against HotCRM on localhost:4001, Chrome, 1918 px). Ruling: 「立 A(ui 缺陷:列表不重取、同 URL 去重、消息中心停轮询)…两张卡,p1」. This card is the client-side waste; the server-side shape is #27627.
Measured (Resource Timing + PerformanceObserver longtask, buffer raised to 20 000)
Opening one HotCRM contact from the contacts list (?recordId=):
| cold (first open after load) | warm (second open) | |
|---|---|---|
| requests until idle | 45 | 23 |
| drawer visible | 310 ms | 300 ms |
| last request end | 440 ms (+ a PATCH sys_user_preference at 850 ms) |
360 ms |
| long tasks | 2 / 144 ms | 3–4 / 280–400 ms |
Pure waste inside those:
- The contacts list query (
GET /api/v1/data/crm_contact?…top=50…) is refetched 10 times between 309 and 517 ms of the cold open — one per arriving child-object metadata (/meta/object/crm_event,crm_event_attendee,crm_lead,crm_opportunity,crm_task,crm_case,crm_contract,crm_campaign_member,crm_quote— 9) plus one. Each metadata arrival re-renders the list host and its data hook refetches. Twenty hit the same defect (Stop side panel URL changes from re-rendering the record index twentyhq/twenty#27438 cut row renders per open from 244 to 4). GET /api/v1/data/crm_contact/:id/field-access×4, identical URL, in the same 50 ms, all 404 (consumers:ui/packages/plugin-detail/src/recordFieldAccess.tsx,plugin-form/src/recordFieldAccess.tsx,permissions/src/RecordFieldAccessScope.tsx,data-objectstack/src/index.ts).POST /api/v1/security/explain×2 at the same instant (plugin-detail/src/useRecordEditable.ts,plugin-detail/src/queueClaim.ts).- The inbox bell polls
sys_inbox_messageevery 10 s andapprovals/requestsevery 30 s (ui/packages/app-shell/src/hooks/sharedUserFeeds.ts:INBOX_POLL_MS = 10_000,APPROVALS_POLL_MS = 30_000,HIDDEN_POLL_MS = 60_000) while the page already holds two WebSocket connections; the measuring tab had made more than 110 000 requests over one working day. On the cloud deployment the same poll took 2.4–7.5 s per call under load, competing with the user's own navigation.
Why it matters more on the cloud: with ~0.5 s per request on huayan.objectos.app (see #27627 for the server side), every redundant request is half a second the user waits.
Target
- The list's query key is independent of
?recordId=and of child metadata arrivals. Opening, stepping and closing a record must not refetch the list (a render-count / request-count test: 0 list requests on open). The list host re-renders at most once per record change. - In-flight request dedupe at the data layer: identical
GETs (same URL) and identicalPOST security/explainbodies share one in-flight promise and one short-lived cache entry (per page life for metadata, per record forfield-access/explain); a 404field-accessis cached as "no scope" for the record's life, not retried four times. - Child metadata loads once: the first open of a record pulls each related object's metadata once per session (today: once per open on the cold path), cached in the same data layer.
- No polling while a push channel is open: the inbox bell and the approvals badge subscribe to the existing WebSocket feed; polling remains only as the fallback when the socket is down, at ≥ 60 s, and pauses in hidden tabs (today's
HIDDEN_POLL_MSlogic stays for the fallback). - No metadata key; ⛔
packages/spec/**.
File surface
ui/packages/app-shell/src/views/objectViewListRenderer.tsx + the list data hook it uses, ui/packages/data-objectstack/src/index.ts (dedupe / cache), ui/packages/plugin-detail/src/recordFieldAccess.tsx, plugin-form/src/recordFieldAccess.tsx, permissions/src/RecordFieldAccessScope.tsx, plugin-detail/src/useRecordEditable.ts, plugin-detail/src/queueClaim.ts, ui/packages/app-shell/src/hooks/sharedUserFeeds.ts + useInboxBell.ts (push subscription), tests beside, changeset.
Acceptance
- Tests: opening a record from a list fires 0 list requests; the same URL in flight is fetched once;
security/explainfor the same record and user is posted once; child metadata is fetched once per session; with a socket open the bell makes no polling requests in 60 s, with the socket closed it polls at ≥ 60 s. - A before / after request log on the same HotCRM contact (cold and warm), in the PR body, from the measurement recipe below.
- The epic seat re-measures in the console preview before ACCEPT (maintainer: 「车队开发完后你审核效果」).
Measurement recipe (browser console, on the list page)
performance.setResourceTimingBufferSize(20000); performance.clearResourceTimings();
const t0 = performance.now(); document.querySelector('a[data-testid="primary-field-link"]').click();
await new Promise(r => setTimeout(r, 7000));
const g = {}; for (const r of performance.getEntriesByType('resource').filter(r => r.initiatorType !== 'script')) {
const k = r.name.replace(/^https?:\/\/[^/]+/, '').replace(/\?.*$/, '') + (r.name.includes('top=50') ? ' [LIST]' : '');
(g[k] = g[k] || { n: 0, first: 1e9 }).n++; g[k].first = Math.min(g[k].first, Math.round(r.startTime - t0)); }
console.table(Object.entries(g).map(([u, v]) => ({ u, ...v })));
Activity
objectstack-fleet commentedon Oct 30, 2026
domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh
Claim: PM loop round 1
Session:
session_01Utp6CbDAucNyQ4UtzCJKyhAccount:
os-billBranch:
claude/project-thread-imv3an(the card thread's designated push branch, the recorded platform exception toclaude/issue-<n>-<slug>)Worktree:
objectstack-issue-27626Domain:
domain:uiSeat:
domain:ui#4File surface: the card's own list —
app-shellobjectViewListRenderer.tsx+ its list data hook,data-objectstack/src/index.ts(in-flight dedupe/cache), the threerecordFieldAccessconsumers (plugin-detail,plugin-form,permissions),plugin-detailuseRecordEditable.ts/queueClaim.ts,app-shellsharedUserFeeds.ts+useInboxBell.ts;app-shellconsole/AppContent.tsx+console/appObjectScope.ts(amended 2026-10-30T02:16Z: measured cold open —?recordId=turns the scope candidate off,routeReadsKeygainsobject, and the route slot is swapped forRouteMetadataSkeletonuntil the full/meta/objectlands, unmounting ObjectView/ListView; the list route keeps its scope and the full registry is read beside it and merged in on arrival, so the record overlay still reads the full registry; ⛔ObjectView.tsx(#27545)); tests beside; root.changeset/; ⛔packages/**outsideui/; ⛔useConsoleActionRuntime.tsx/RecordDetailView.tsxapiHandler(in-flight PR #27793) (stop on breach; explain in the report)Container & model:
L,mode:thread,model: TIER_DEFAULT(dispatch-gates --tieronfec60e00: no path-derived mandate),effort: xhighClause-②: no
Metadata-protocol: none
Responsibility: opening a record from a list fires 0 list requests; identical in-flight GETs and explain POSTs share one promise; child metadata once per session; the bell stops polling while the socket is open (fallback ≥60 s); before/after request log in the PR body; the epic seat re-measures in the console preview before ACCEPT; ⛔ any of #27627 (server shape)
Thread-read: none
Premise: holds
Serial constraints cleared: maintainer 2026-10-30 01:47Z/01:49Z dispatch-now; in-flight: PR #27793 (excluded), seat 4 #27504 ui half (data-objectstack stamp, unpushed — additive seam)
Dispatched 2026-10-30T01:54Z to a card thread of the seat's project; the order lives in the project. ⛔ The thread posts no claim and writes no assignee.
Generated by Claude Code