Skip to content

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

Closed

Description

@objectstack-fleet

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_message every 10 s and approvals/requests every 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

  1. 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.
  2. In-flight request dedupe at the data layer: identical GETs (same URL) and identical POST security/explain bodies share one in-flight promise and one short-lived cache entry (per page life for metadata, per record for field-access / explain); a 404 field-access is cached as "no scope" for the record's life, not retried four times.
  3. 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.
  4. 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_MS logic stays for the fallback).
  5. 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

  1. Tests: opening a record from a list fires 0 list requests; the same URL in flight is fetched once; security/explain for 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.
  2. A before / after request log on the same HotCRM contact (cold and warm), in the PR body, from the measurement recipe below.
  3. 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

  1. added
    bugSomething isn't working
    priority:p1High: required for production / M2
    area:recordsBusiness objects, records, the views that show data, usable forms, search
    on Oct 29, 2026
  2. objectstack-fleet commented on Oct 30, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:ui#4 · os-bill · session_01Utp6CbDAucNyQ4UtzCJKyh

    Claim: PM loop round 1
    Session: session_01Utp6CbDAucNyQ4UtzCJKyh
    Account: os-bill
    Branch: claude/project-thread-imv3an (the card thread's designated push branch, the recorded platform exception to claude/issue-<n>-<slug>)
    Worktree: objectstack-issue-27626
    Domain: domain:ui
    Seat: domain:ui#4
    File surface: the card's own list — app-shell objectViewListRenderer.tsx + its list data hook, data-objectstack/src/index.ts (in-flight dedupe/cache), the three recordFieldAccess consumers (plugin-detail, plugin-form, permissions), plugin-detail useRecordEditable.ts / queueClaim.ts, app-shell sharedUserFeeds.ts + useInboxBell.ts; app-shell console/AppContent.tsx + console/appObjectScope.ts (amended 2026-10-30T02:16Z: measured cold open — ?recordId= turns the scope candidate off, routeReadsKey gains object, and the route slot is swapped for RouteMetadataSkeleton until the full /meta/object lands, 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/** outside ui/; ⛔ useConsoleActionRuntime.tsx / RecordDetailView.tsx apiHandler (in-flight PR #27793) (stop on breach; explain in the report)
    Container & model: L, mode:thread, model: TIER_DEFAULT (dispatch-gates --tier on fec60e00: no path-derived mandate), effort: xhigh
    Clause-②: 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

  3. objectstack-fleet commented on Oct 30, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:ui#4 · os-bill · session_01Utp6CXJLrFrzeHHa8StCja

    Delta report · 2026-10-30T03:17Z · draft PR #27907 · head 03b2def18b3c6a7a69ac4806248690ec65264084

    Request 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.

    phase contact before → after account before → after
    cold open, total 17 → 11 26 → 20
    cold: list top=50 / count top=0 1 / 1 → 0 / 0 1 / 1 → 0 / 0
    cold: field-access 4 → 1 4 → 1
    close 1 → 0 1 → 0
    warm open, total 12 → 7 15 → 10
    warm: list / field-access 1 / 4 → 0 / 1 1 / 4 → 0 / 1
    close 1 → 0 1 → 0
    idle bell (sys_inbox_message / approvals/requests) 6 / 2 in 58 s → 2 / 2 in 125 s
    {
      "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

  4. objectstack-fleet commented on Oct 30, 2026

    @objectstack-fleet
    ContributorAuthor

    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_state clean. 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:uipm:dispatchedpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions