Skip to content

perf(plugin-detail,app-shell): the record page's related column loads counts first, then rows only for the cards in view, and never blocks the details - #27636

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-g0nswr
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-g0nswr

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Sales · project thread
Fixes #27056

Clause-②: yes (widening: three new exports on the published @object-ui/plugin-detail main entry, a public-surface addition; no accept set moves)
Metadata-protocol: none

维护者速读

  • 改了什么:记录页右侧关联列改为分步加载。打开记录时,所有关联卡的计数作为一组请求一起发出(每个只取总数和 1 条 id),到齐前卡片是占位;计数为 0 的卡只剩标题行,不取行;有数的卡进入视口(下方 200 px 内也算)才取前 3 行,只取名称字段加一个状态或日期字段;关掉时间线后折叠的「动态」卡打开前什么都不读;抽屉里 ↑ ↓ 会预取下一条记录的计数。
  • 为什么改:落实裁决「关联都放在右侧,记录打开时是不是会很慢」。原来打开记录时每个子表各取 5 行、带一串 populate,和记录本身的读取抢时间。
  • 风险与回滚:只改 plugin-detail 和 app-shell 前端的读取顺序,不动协议和服务端。计数失败的卡回到自己取行,没有 IntersectionObserver 的环境立即取行,两种都有测试。回滚就是 revert 本 PR。新增三个导出(Clause-② yes),纯增量。
  • 先看:① ui/packages/plugin-detail/src/useRelatedCounts.ts 里的 useRelatedCounts(一组计数、统一占位、在 transition 里放行卡片);② RelatedList.tsx 的 compact 分支(rowsWanted、compactSelect、countAnswers);③ app-shell/src/views/recordDrawerStack.tsx 的 PrefetchUpcomingRelatedCounts。
  • 两分钟验证:在 ui/ 下跑 pnpm exec vitest run packages/app-shell/src/views/RecordDetailView.relatedCountsFirst-27056.test.tsx。预期 2 个测试通过:6 个关联列表先一起发 6 个计数,之后只有视口内且有数的 2 张卡各取 3 行。
  • 需要你做的:无,席位落地。

What changes

The record page's related column (the column tiers, from 1024 px) loads in steps, and the details column never waits for it.

  • Counts first (useRelatedCounts, new). RelatedCardStack counts every card it places in one request set, sent beside the record read through the existing count store (RelatedCountStore, the store the tab badges read, invalidated by the data-change bus). Each probe asks for the total and one row's id (top=1&select=id on the wire). Every card shows one placeholder (data-related-card-pending) until the whole set has answered, so there is one loading state. The faces then mount in a React transition: a record read that lands at the same moment renders the details first. A card counted at 0 folds to its title row and never reads rows. A failed count leaves its card to read for itself, as before.
  • Rows on demand (RelatedList, compact branch). A card reads at most three rows, in its inherited sort. It asks only for id, the name field (declared or derived) and one chip field. The chip field comes from the authored relatedListColumns when they are declared, else the object's status field, else its first date field. A card reads only once it has been in view (useSeenOnce, an IntersectionObserver latch, rootMargin 200 px below the fold). While the count answers a card, its header draws from that count. The folded 「动态」 card (timeline switched off) is closed until opened, and its feed is not mounted until then.
  • Details first. The details column renders from the record read alone. The related column's placeholders hold their place until the counts land.
  • Prefetch on step (recordDrawerStack). The drawer works out the record that ↑ / ↓ would show next. Once the current record has loaded, PrefetchUpcomingRelatedCounts warms that record's counts (relatedCountTargetsIn over the page drawn now), so the next page's cards draw their counts from the store.
  • Shape stays independent of the card face. useRelatedCounts returns per-card states (pending / counted / uncounted). The card face is not part of that shape, so Record page v4: same-shaped related panels with no collapse, a persistent tab row under the header (the enlarged state is the active tab), Activity as the first panel and a tab, one top bar on every face, tiers by container width #27524 can read the same hook.

Additive API: prefetchRelatedCounts, relatedCountTargetsIn, relatedCountTargetOf and the types RelatedCountTarget and RelatedCountsSource are exported for the drawer prefetch. RecordPageColumns also takes an optional relatedFieldsOf, which lets the host pass the child field definitions it already holds so the counts read no schema first.

Tests

  • app-shell/.../RecordDetailView.relatedCountsFirst-27056.test.tsx renders the real page at 1440 px over a data source that holds every related read, for a record with 6 related lists:
    • The details (name, highlight, details column) draw while every related read is still out, with 6 placeholders.
    • The 6 counts go out as one set before any answers, with no row read.
    • After they answer, rows are read only for the 2 counted cards in view ($top 3, $select ['id','name','status']). Nothing is read for the 0-count card in view or for the 3 cards below the fold. That is 8 reads in total, in 2 round trips.
  • plugin-detail/.../useRelatedCounts-27056.test.tsx (8 tests):
    • one request set with id-only probes
    • one loading state until all answer
    • the store filling up does not release a first set
    • stepping back to a settled record draws its counts at once, with no placeholder flash
    • a failed count leaves its card uncounted
    • no source counts nothing
    • a prefetched parent draws on its first render with no read
    • a declared scope is resolved
    • the relatedCountTargetsIn walk skips gated and bound nodes
  • plugin-detail/.../RelatedList.compactLazy-27056.test.tsx:
    • a 0-count card never queries rows and reports itself empty
    • a card out of view holds 3 row placeholders and reads nothing
    • a card in view reads $top 3, $skip 0 with the projection
    • without IntersectionObserver the card reads at once
  • app-shell/.../recordDrawerStack.prefetch-27056.test.tsx: the next record follows the step direction, and the prefetch fires only once the current record is ready and the object matches.
  • RecordPageColumns.switches-27052.test.tsx: updated so that the folded 「动态」 card starts closed with nothing mounted, and opening it mounts the feed.

Ablations, each run against the tests above:

Ablation Result
stateOf forced to uncounted 2 acceptance tests fail
useSeenOnce forced to true 3 tests fail
the first-ask rule dropped "the store answering is not the set settling" fails
the settled-parent clear dropped "stepping back" fails

Acceptance notes

  • "One counts call" is one request set. There is no batched read door on main (/batch takes writes only), and adding one is a server and protocol change outside this card. The set is N $count probes of one id each, all sent together before any answers, behind one loading state. On HotCRM they go out within 8 ms of each other, in parallel with the record read (waterfall below).
  • "Primary cards first." On the synthesized page the primary lists are body tables in the details column, so the related column holds no primary cards. Its cards are counted and read in page order, and only the ones in view read rows.
  • Request count vs round trips. On HotCRM a cold open sends 14–16 related requests where main sent 10, in the same 2 round trips. Each count carries one id, and each row read carries 3 projected rows. On main each read carried 5 rows with up to 9 populated lookups. The trade-off is that a card's first rows land 260–380 ms after the record read (the last card's by 330–490 ms), where on main they landed before it. Meanwhile each card shows its count in its header.
  • Details time is dominated by one render of the page when the record lands: a single 100–200 ms long task on both builds (longtask entries). The transition keeps the card faces out of that render. Without it, Acme measured 302–312 ms on this branch, where the faces had mounted just before the record landed.

Verification

Budget (HotCRM, measured)

Budget Target main (6892888) this PR
Details interactive, after the record read returns ≤ 300 ms median 189 ms, max 326 ms (2 of 12 opens over) median 217 ms, max 260 ms (0 of 12 over) ✅
All card headers drawn, after the record read returns ≤ 500 ms median −20 ms, max 28 ms (the headers come from the 5-row reads) median 181 ms, max 235 ms (the headers come from the counts, after the details) ✅
Related round trips on open ≤ 2 2: 9 row reads, then a lookup-label read 2: 9 counts, then 5–7 three-row reads ✅
Related requests on open — 10 16 / 15 / 16 at 1600×1000; 14 at 1600×700 (fewer cards in view)
How it was measured
  • Backend. HotCRM on a fresh DB (objectstack dev --fresh). The crm_account record page has 9 related lists in the related column: contacts, contracts, quotes, child accounts, events, leads, opportunities, tasks and cases.
  • Console. Production builds (vite build) of main at 6892888 and of this branch, served by vite preview, which proxies to the backend. Measured in headless Chromium at 1600×1000 (the three-column tier) and once at 1600×700.
  • Opens. A warm-up open of a fourth account (Wayne) first, so modules and metadata are loaded, as for a reader arriving from the list. Then an SPA navigation to each of Acme, Globex and Stark. Three alternating main/branch rounds plus the 700 px round give 12 opens per build.
  • Numbers. Every number comes from the Resource Timing entries (the entries DevTools' network panel draws), relative to the responseEnd of the record read.
    • "Details interactive": the details column shows the record's phone and the main thread has run a task after the next frame.
    • "Headers": every card in the related column is past its placeholder and loading state.
    • "Round trip": a related read that went out after an earlier related response had landed starts a new round.
Account main details ms (3 rounds · 700 px) this PR details ms this PR headers ms this PR requests (counts + rows)
Acme 303 / 195 / 188 · 326 197 / 237 / 227 · 198 178 / 212 / 204 · 176 9+7 · 9+5
Globex 170 / 232 / 162 · 177 215 / 219 / 215 · 225 30 / 194 / 196 · 31 9+6 · 9+5
Stark 130 / 189 / 195 · 179 260 / 205 / 114 · 232 183 / 121 / 235 · 37 9+7 · 9+5
Network waterfall, Acme, round 1 (ms from navigation; record read 178→465 on main, 122→316 on this PR)

main:

Round Object Query Start → end
1 crm_contact top=5 + populate of 5 lookups 172 → 217
1 crm_contract top=5 + populate of 7 lookups 173 → 238
1 crm_quote top=5 select=id,quote_number,name,status,total_price 174 → 255
1 crm_account top=5 select=id,account_number,name,type,owner_id populate=owner_id 175 → 267
1 crm_event top=5 + populate of 9 lookups 175 → 306
1 crm_lead top=5 + populate of 9 lookups 175 → 322
1 crm_opportunity top=5 + populate of 7 lookups 176 → 341
1 crm_task top=5 + populate of 9 lookups 176 → 376
1 crm_case top=5 + populate of 8 lookups 176 → 389
2 crm_account top=1 lookup label 422 → 564

This PR. Details are interactive at 513 and headers draw at 494:

Round Object Query Start → end
1 crm_contact top=1 select=id 119 → 198
1 crm_contract top=1 select=id 119 → 209
1 crm_quote top=1 select=id 119 → 224
1 crm_account top=1 select=id (child accounts: 0) 120 → 245
1 crm_event top=1 select=id 120 → 253
1 crm_lead top=1 select=id (0) 120 → 275
1 crm_opportunity top=1 select=id 120 → 286
1 crm_task top=1 select=id 121 → 295
1 crm_case top=1 select=id 122 → 304
2 crm_contact top=3 select=id,full_name,title,created_at 615 → 651
2 crm_contract top=3 select=id,contract_number,signed_by,status 616 → 660
2 crm_quote top=3 select=id,display_title,name,status 616 → 672
2 crm_event top=3 select=id,subject,status 617 → 682
2 crm_opportunity top=3 select=id,name,stage 618 → 695
2 crm_task top=3 select=id,subject,status 618 → 712
2 crm_case top=3 select=id,display_title,subject,status 619 → 724

At 1600×700 the crm_task and crm_case cards sit below the fold, and their row reads are not sent.

Local checks (head 5d85c50)

  • pnpm --dir ui exec vitest run packages/plugin-detail: 336 files passed (1 skipped), 3070 tests passed (18 skipped).

  • app-shell: the 180 test files that reference RecordDetailView or recordDrawerStack all passed (1357 tests). This is a declared narrowing: the full app-shell suite is 1880 files and runs longer than an hour here, so CI runs all of it.

  • type-check passed for @object-ui/plugin-detail and @object-ui/app-shell, and @object-ui/plugin-detail builds.

  • node scripts/pm/dispatch-gates.mjs --ran: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN. Three gates were run a second time, and the table records the second runs:

    • vitest run scripts/ had one 15 s test time out while a locked app-shell run held the box.
    • check:skill-examples exited 3 because the client dists were not built yet.
    • check:type-check-debt exited 3 after queueing on the lock.

    All three exited 0 on the rerun.

Gate record (exit · command)
0	node scripts/check-adr-0087-registration.mjs --base origin/main
0	node scripts/check-adr-0087-registration.mjs --self-test
0	node scripts/check-adr-links.mjs
0	node scripts/check-adr-links.mjs --self-test
0	node scripts/check-changeset-no-major.mjs --base origin/main
0	node scripts/check-closing-keyword-parity.mjs
0	node scripts/check-closing-keyword-parity.mjs --self-test
0	node scripts/check-comment-mask-corpus.mjs
0	node scripts/check-doc-route-spelling.mjs --advisory
0	node scripts/check-doc-route-spelling.mjs --self-test
0	node scripts/check-docs-section-name.mjs
0	node scripts/check-docs-section-name.mjs --self-test
0	node scripts/check-empty-changeset.mjs --base origin/main
0	node scripts/check-empty-changeset.mjs --self-test
0	node scripts/check-sdui-manifest.mjs
0	node scripts/check-sdui-manifest.mjs --self-test
0	node scripts/check-section-landing-index.mjs
0	node scripts/check-section-landing-index.mjs --self-test
0	node scripts/ci/plan-test-core-shards.mjs --self-test
0	node scripts/pm/release-rehearsal-clone.mjs --self-test
0	node scripts/release-pending-publish.mjs --self-test
0	pnpm --dir ui exec vitest run scripts/
0	pnpm --filter @objectstack/lint run check:doc-formula-expressions
0	pnpm --filter @objectstack/lint run check:doc-security-posture
0	pnpm --filter @objectstack/spec run check:docs
0	pnpm --filter @objectstack/spec run check:duration-unit-keys
0	pnpm --filter @objectstack/spec run check:empty-state
0	pnpm --filter @objectstack/spec run check:liveness
0	pnpm --filter @objectstack/spec run check:skill-examples
0	pnpm --filter @objectstack/spec run check:strictness-ledger
0	pnpm --filter @objectstack/spec run check:variant-docs
0	pnpm --filter @objectstack/spec run check:yaml-examples
0	pnpm check:changeset-gate-self-tests
0	pnpm check:ci-filter-parity
0	pnpm check:closed-ceremony-text
0	pnpm check:corpus-claim-drift
0	pnpm check:cross-package-test-inputs
0	pnpm check:dispatcher-error-vocabulary
0	pnpm check:doc-anchors
0	pnpm check:doc-authoring
0	pnpm check:doc-frontmatter
0	pnpm check:docs-audit-scope
0	pnpm check:docs-redirects
0	pnpm check:docs-single-h1
0	pnpm check:docs-spec-enumerations
0	pnpm check:docs-transcript-drift
0	pnpm check:driver-memory-census
0	pnpm check:engine-double-contract
0	pnpm check:gitlink-declared
0	pnpm check:nul-bytes
0	pnpm check:pm-changeset-deadline-census
0	pnpm check:published-files
0	pnpm check:published-readme-links
0	pnpm check:query-options-erasure
0	pnpm check:react-page-adapter-contract
0	pnpm check:refd-timer-probe
0	pnpm check:role-word
0	pnpm check:skill-identifier-liveness
0	pnpm check:test-source-alias
0	pnpm check:tier-file-adoption
0	pnpm check:type-check-coverage
0	pnpm check:type-check-debt
0	pnpm check:undeclared-dep-imports
0	pnpm check:vendor-version-stamps
0	pnpm check:watch-hint-literal
0	pnpm check:where-matcher

Generated by Claude Code

…ounts; exports for the drawer prefetch documented (#27056)

The first count set settles in a transition, so a record read that lands at
the same moment draws the details first. A store that already holds every
count does not release a first set on its own, and a settled record draws
its counts at once when the drawer steps back to it.

Claude-Session: https://claude.ai/code/session_01L6bhgVDURMCgpQ3vtrchrM
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Oct 29, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#1 · os-sales · session_01L6bhgXU26UBEjd1KKHgNEx

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 5d85c503a58508a7417d6ecf2cb45291706f7d5e
Local-runs: none

Face: declaration (Clause-②: yes (widening) on claim 6497958898, PR body and changeset; no packages/spec/src/** or governed path in the tree). Inputs: card #27056 with order 6495380265 and claims 6495584045 / 6497958898, the PR body, the net diff (16 files), the 59 check-runs on the head.

① Derived judgments

  • @object-ui/plugin-detail main entry (exports "." only) gains prefetchRelatedCounts, relatedCountTargetsIn, relatedCountTargetOf and the types RelatedCountTarget, RelatedCountsSource. RIGHT: additive, each documented for the app-shell drawer prefetch, its one consumer in this diff (recordDrawerStack.tsx); nothing else newly exported.
  • RecordPageColumns gains an optional prop relatedFieldsOf. RIGHT: optional, absent keeps the old read path; it is a host prop, not an authored key.
  • No authored-metadata accept set moves (no schema, no zod, no new authorable key). Metadata-protocol: none is RIGHT.
  • Behaviour: counts read through the existing RelatedCountStore $count probe (one row, $select: ['id']) in one Promise.all per parent; a failed count stays unset (no invented 0) and the card reads for itself. Not a contract surface; judged in the ACCEPT.

② Semver level

Metadata-protocol: none ⇒ patch per AGENTS.md Post-Task Checklist step 3 ("a non-protocol change, widening or breaking, ships patch"); the changeset is patch on @object-ui/plugin-detail and @object-ui/app-shell. The seat's earlier ask for minor was wrong and the dev's patch (probe: check-changeset-no-major refuses none+minor) is RIGHT. Declaration lines match PR body and changeset. PASS.

③ Boundary flags

Dev report 6501278306: open_questions none; the one deviation (patch, not minor) is answered in ②. File-surface widening (index exports, README, content/docs/ui/pages.mdx) was approved before the push and recorded in claim 6497958898.

Implemented-by: claude/project-thread-g0nswr
Reviewed-by: session_01L6bhgXU26UBEjd1KKHgNEx

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#1 · os-sales · session_01L6bhgXU26UBEjd1KKHgNEx

席位核对 · ACCEPT · 2026-10-29T16:01Z

Head-sha: 5d85c503a58508a7417d6ecf2cb45291706f7d5e · card #27056 · claim 6497958898 (supersedes 6495584045) · dev report 6501278306.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 19:33
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 29, 2026 19:33
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 8243694 Oct 29, 2026
60 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-g0nswr branch October 29, 2026 20:03
os-support-ai pushed a commit that referenced this pull request Oct 30, 2026
Reconciles with #27636 (objectstack#27056): RelatedList's compact branch
keeps the payload adoption ahead of the in-view gate and reads the card
context once; the record page prefetches the drawer's upcoming record
(RecordDrawerUpcomingContext) instead of its own neighbours context, and
skips the related-count warm-up where the adapter answers the payload.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzhCjfxaYJQyy6tFjVr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant