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
objectstack-fleet commentedon Oct 30, 2026
domain:ui#4 · os-bill · session_01Utp6CXJLrFrzeHHa8StCja
Delta report · 2026-10-30T03:17Z · draft PR #27907 · head
03b2def18b3c6a7a69ac4806248690ec65264084Request counts, app-crm, same script before (merge base
9ae6384e) and after (this head): open the first record from the list (cold), Escape, open the second (warm), Escape. Full logs in the PR body.top=50/ counttop=0field-accessfield-accesssys_inbox_message/approvals/requests){ "issue": 27626, "status": "done", "branch": "claude/project-thread-imv3an", "pr": "https://github.com/objectstack-ai/objectstack/pull/27907", "session": "session_01Utp6CXJLrFrzeHHa8StCja", "premise_still_valid": true, "summary": "Opening or closing a record re-reads no list: the record drawer reads the whole registry beside the route's object scope instead of replacing it (no route skeleton, no remount; cold cause), and the list page holds columns/searchableFields by value (warm cause: content-equal object documents swapped on every open/close). getRecordFieldAccess shares one in-flight request per record and remembers 404/501. The bell and approvals badge poll at 60 s with the hidden-tab stop kept, plus a package-internal push-channel seam (seat ruling B; Target 4's premise of an existing WebSocket feed measured false). explain x2 = update+delete, already shared (objectui#11699): unchanged.", "tests": "New: ObjectView.objectDocSwapNoRefetch-27626 (3), AppContent.recordDrawerKeepsScope-27626 (4), getRecordFieldAccess.inflightShare-27626 (6), sharedUserFeeds.pushChannel-27626 (6). Updated (restated behaviour): appObjectScope-12811, AppContent.objectScope-12811, sharedInboxFeed.cadence-7249, sharedUserFeeds.hiddenStop-12439, sharedInboxFeed.transient404. Run: 311 app-shell files (console, ObjectView/listRenderer, feeds) + all data-objectstack: Test Files 414/415, the 1 failure transient404 restating 10 s, updated and re-run 2/2 green; the 6 new/updated 27626 files 29/29 after the last edit. Ablation (on-disk revert, trap restore): holdBlock lines reverted -> doc-swap cases 5 and 3 list queries (asserted 1/1), CONTROL green; AppContent+appObjectScope reverted -> 3 drawer cases + prefetch case red, CONTROL green.", "gates": "dispatch-gates --commands: 35 derived, each run with exit captured before any pipe, all exit 0; dispatch-gates --ran: 35 run, 0 NOT-MEASURED, 0 UNRUN. check-sdui-manifest exit 0 (+ --self-test exit 0). eslint on 14 changed TS files exit 0. @object-ui/data-objectstack type-check exit 0 (after building ui types+core dists). CI check-runs on 03b2def1: not concluded at report time; driving to green.", "dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm", "line_budget": "15 files, +1183/-46 vs merge base 9ae6384e (source +227/-29; tests +926/-17; changeset +15); under the 3000 human-merge threshold", "files_changed": [ ".changeset/27626-record-open-requests.md", "ui/packages/data-objectstack/src/index.ts", "ui/packages/data-objectstack/src/getRecordFieldAccess.inflightShare-27626.test.ts", "ui/packages/app-shell/src/hooks/sharedUserFeeds.ts", "ui/packages/app-shell/src/hooks/__tests__/sharedUserFeeds.pushChannel-27626.test.tsx", "ui/packages/app-shell/src/hooks/__tests__/sharedInboxFeed.cadence-7249.test.tsx", "ui/packages/app-shell/src/hooks/__tests__/sharedUserFeeds.hiddenStop-12439.test.tsx", "ui/packages/app-shell/src/hooks/__tests__/sharedInboxFeed.transient404.test.tsx", "ui/packages/app-shell/src/views/objectViewListRenderer.tsx", "ui/packages/app-shell/src/views/ObjectView.objectDocSwapNoRefetch-27626.test.tsx", "ui/packages/app-shell/src/console/AppContent.tsx", "ui/packages/app-shell/src/console/appObjectScope.ts", "ui/packages/app-shell/src/console/__tests__/AppContent.recordDrawerKeepsScope-27626.test.tsx", "ui/packages/app-shell/src/console/__tests__/AppContent.objectScope-12811.test.tsx", "ui/packages/app-shell/src/console/__tests__/appObjectScope-12811.test.tsx" ], "deviations": [ "Measured on examples/app-crm, not HotCRM (not in this tree); accepted by the seat, declared in the PR body.", "Target 4: no platform push channel exists (page.on('websocket') saw only Vite HMR sockets; discovery websockets.enabled=false, services.realtime=false); per seat ruling B: 60 s fallback + tested seam, no server card.", "Declared narrowing: app-shell local tests = 311 of 1927 files (console, ObjectView/listRenderer, feeds); app-shell type-check not meaningful locally (dependency dists unbuilt: 1525 errors, all missing-module or downstream of it, none on changed lines). CI runs both in full.", "Five existing tests changed to restate the new behaviour (listed under tests); each change is described in the PR body.", "Harness after-PR assign/review step skipped per the dispatch order (no assignees, no review request).", "The second commit first carried a model-named co-author trailer; the pre-push check refused it before publishing and it was reworded to the model-free pair (amend of an unpublished commit, no force push)." ], "mcp_calls": "0 GitHub MCP calls. 1 non-GitHub MCP call relevant to the PR: claude-code-remote subscribe_pr_activity (#27907 events, read-only subscription).", "api_writes": "2 — POST /repos/objectstack-ai/objectstack/dispatches (fleet-write pr_create -> #27907, read back 17977/17977 bytes identical, assignees none); POST /repos/objectstack-ai/objectstack/issues/27626/comments (this report, via post-stamped). Plus git pushes of the branch (5b2a23bf, 03b2def1), no force.", "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code
objectstack-fleet commentedon Oct 30, 2026
domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh
This card is claimed by ui seat 4 and is not waiting in the queue. Its PR is #27907: green, NOT governed and
mergeable_stateclean. The PR had stayed draft only for the epic seat's preview check. The maintainer ruled at 10:39Z that the seat accepts and merges on its own. ACCEPT → PR #27907 comment 6520817577 · 2026-10-30T10:41Z. The PR is now marked ready with automerge on, and the card closes when it merges.Generated by Claude Code