Skip to content

perf(layout): the phone nav drawer opens in one render, and its opening frame draws only the rows that fit - #29325

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-uegfce
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-uegfce

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steedos · project thread

Fixes #29141

Clause-②: no
Metadata-protocol: none

The two lines are copied from the claim (comment 6574659596, amended in 6575269092).

维护者速读

  • 改了什么:手机宽度下 Console 左侧导航抽屉的打开路径(@object-ui/layout 的 NavigationRenderer)。每一行不再各自询问「是不是手机」,而是整棵菜单读一次;手机上不再给每行挂一个本就隐藏的悬浮提示;菜单比屏幕长时,打开那一帧只画屏幕放得下的行,其余行紧接着在抽屉滑入期间补上。
  • 为什么改:showcase(50 个入口、8 个分组)在 4 倍 CPU 节流下打开抽屉要 200 到 640 ms,20 次里没有一次低于 200 ms。剖析显示每次打开整棵菜单先按桌面尺寸渲染一遍、再按手机尺寸渲染第二遍,外加每行一个隐藏的 Radix 提示。改后 20 次里 17 次低于 200 ms,中位数 305 降到 116 ms。
  • 风险与回滚:抽屉里显示的内容不变(快照 JSON 和截图前后逐像素一致),桌面菜单仍一次画完并保留提示。风险在于手机长菜单的后半段晚一帧出现;没有侧栏 provider 时仍按视口判断一次,与原先一致。回滚即撤销本 PR。
  • 先看:ui/packages/layout/src/NavigationRenderer.tsx 的 PhoneFirstFrame 与 firstViewRowCount(首帧行数的计算)、行里的 NavIsMobileContext 读取与 rowTooltip;然后是新测试 NavigationRenderer.phoneDrawerOpen-29141.test.tsx。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/layout/src/__tests__/NavigationRenderer.phoneDrawerOpen-29141.test.tsx,预期 5 个用例全部通过。
  • 需要你做的:无,席位落地。

What changes

One file of code, ui/packages/layout/src/NavigationRenderer.tsx. No exported signature of @object-ui/layout or @object-ui/app-shell changes, nothing under components/src/ui/** is edited, and no metadata key, server route or request is added.

  • One phone answer for the whole menu. Every NavigationItemRenderer called useIsMobile(). That hook returns false on its first render and corrects itself in an effect, and the sheet mounts the menu afresh on every open. So each open rendered the whole tree at desktop size, then every row set state, and the tree rendered and restyled a second time (54 matchMedia calls per open on the showcase). The root now provides one value through a private NavIsMobileContext: the sidebar provider's isMobile when a provider is present (it settled when the shell mounted), else one useIsMobile() for the whole tree, so a NavigationRenderer mounted without a sidebar provider behaves as before.
  • No hidden tooltips on a phone. A row's tooltip names it in the collapsed icon rail, and the sidebar primitive draws that tooltip hidden on a phone. A phone row now passes no tooltip, so it mounts no Radix Tooltip. Desktop rows keep theirs.
  • The opening frame draws what fits. On a phone, a private PhoneFirstFrame wrapper draws the top-level rows whose rows (a group counts its open children) fit ceil(innerHeight / 44) + 4 in the drawer's first commit. The rest join in a React.startTransition right after it, while the drawer is still sliding in. Rows drawn first are not rendered again. On desktop, or when the menu fits, the whole menu draws at once as before. The favorites section counts toward the first frame.
  • groupOpensOnFirstView is factored out of the row's existing open-by-default logic so the root and the row compute the same answer.
  • Changeset .changeset/29141-phone-nav-drawer-open.md: @object-ui/layout patch, with the two declaration lines.

Tests

  • New ui/packages/layout/src/__tests__/NavigationRenderer.phoneDrawerOpen-29141.test.tsx (5 cases). A settled SidebarProvider at the window's size mounts the tree the way the drawer does. A resolveTargetLabel spy counts row renders, and a React.Profiler records the links in the DOM at each commit.
    • A short phone menu: every row renders once, in one commit, at phone size (min-h-[44px]).
    • A 60-entry phone menu: two commits, the first at least the rows that fit 844 px and fewer than 60, then all 60 in order, each row rendered once.
    • Two groups: Group A fills the opening frame (30 links), Group B joins after it (40), and stays expanded.
    • A 1280 px window: all 60 rows in the first commit, each rendered once, each carrying its tooltip trigger and no phone height.
    • A phone row carries no tooltip trigger.
  • The existing UnifiedSidebar and NavigationRenderer tests pass unchanged.
  • Ablations (scripts/ablation-replace.mjs, each restored to HEAD's blob, git diff HEAD empty):
    • per-row useIsMobile() put back: 3 of 5 cases red (each row renders twice);
    • deferral removed: 2 red (one commit of 60);
    • phone tooltip kept: 3 red (the row carries a trigger).

Acceptance notes

  • Budget met on the showcase: 17 of 20 opens under 200 ms, and each set of 5 has 4 or 5 under 200 ms. The group toggle stays under 90 ms in the page.
  • What is left is primitive internals, named and not widened (per the claim amendment): Radix FocusScope's focusFirst forces a style recalc of about 340 elements (34 ms self at 4x CPU), Radix Presence's getAnimationName forces another (17 ms), Collapsible's measurement sets CSS variables, and DismissableLayer sets pointer-events on the body. They all live in components/src/ui/** or Radix, which this card does not touch. The three slow opens out of 20 (219, 254, 368 ms) are the first open of a set, where icon chunks are still being fetched and parsed.
  • No change in what the drawer shows: the drawer snapshot JSON (61 rows on the showcase, 11 on CRM: labels, hrefs, icons, badges, open groups, active entry) is identical before and after in every set, and the before and after screenshots are pixel-identical.
  • No new request on open: 18 icon chunks on the showcase and 2 on CRM, the same before and after.

Verification

Readings on head 8e64e407, which merges origin/main into the branch; the change set against main is 3 files, +403 / -49 lines, under the 3000-line threshold.

Before and after: drawer open, 4x CPU, 390 px, 5 runs x 4 sets

Production Console (pnpm console:build) served by pnpm dev:showcase and pnpm dev:crm. Playwright drives a 390 x 844 phone (DPR 2, touch) with CDP CPU throttling at 4x. One open is timed from the trigger's click event to the first frame after the drawer's sidebar node appears (rAF, then a zero timeout). Before and after run interleaved on one backend: the page's hashed assets are served from a build of the base (eb14f7ad) or of this branch.

App Leg Set 1 Set 2 Set 3 Set 4 Under 200 ms Median
showcase before 424, 400, 236, 290, 232 429, 220, 312, 336, 203 571, 265, 640, 205, 224 492, 414, 260, 453, 297 0 of 20 305
showcase after 254, 126, 113, 174, 112 180, 123, 92, 85, 60 219, 118, 89, 99, 105 368, 95, 145, 156, 90 17 of 20 116
CRM before 217, 130, 94, 147, 122 302, 122, 203, 152, 159 191, 130, 131, 657, 125 263, 112, 220, 115, 173 14 of 20 150
CRM after 159, 159, 83, 99, 93 210, 108, 97, 89, 104 168, 144, 125, 139, 66 173, 136, 113, 163, 112 19 of 20 119

Group toggle (expand then collapse a showcase group, 5 runs per set): in-page max 71 to 86 ms before and 73 to 85 ms after. The wall time including the Playwright round trip has a max of 172 to 191 ms. All are under 200 ms.

Profile: top costs of one showcase open, before and after

CDP Profiler plus a Chrome trace of one open on the production bundle at 4x CPU, source-mapped against a --sourcemap build with identical chunk hashes. The click dispatch (render, commit and effects, synchronous, with profiling on) went from 337 ms to 133 ms. Style recalcs inside it went from 87.6 to 45.1 ms (three full restyles of 509, 618 and 614 elements before, then 233 and 344 elements after). GC went from 16 to 0 ms.

Cost (self time) Before After
native focus (FocusScope's forced restyle) 49.8 ms 34.3 ms
use-mobile.tsx effect (the second render's trigger) 28.4 ms gone
Radix Presence getAnimationName 21.9 ms 17.3 ms
garbage collector 19.3 ms 0 ms
react-router (per-row link resolution, twice) 15.1 ms 3.8 ms
native setAttribute 13.8 ms 7.0 ms
native getBoundingClientRect 11.6 ms 8.7 ms
Radix Slot mergeProps 10.0 ms 1.6 ms
NavigationRenderer.tsx 8.6 ms under 1.6 ms
Radix Tooltip (inclusive) 16.0 ms 2.0 ms
react-dom (inclusive) 312.9 ms 126.7 ms

Traces, cpuprofiles and the two summaries are in the project's issue-29141 folder (traces/summary-before-showcase.txt, traces/summary-after-showcase.txt), with screenshots and the per-set JSON readings.

Tests and gates
  • pnpm --dir ui exec vitest run packages/layout after the merge of main: 48 files, 392 tests passed.
  • The app-shell navigation suites plus the console AppContent test (pre-merge head): 77 files passed.
  • pnpm exec turbo run type-check --filter=@object-ui/layout: exit 0. eslint on the layout package: 0 errors, the same 21 warnings as the base.
  • node scripts/pm/dispatch-gates.mjs --ran: 34 derived, 34 run, all exit 0, including pnpm --dir ui exec vitest run scripts/ (169 files passed, 2 skipped; 4900 tests) and pnpm check:type-check-debt under the verify lock. node scripts/check-sdui-manifest.mjs: exit 0.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (Not touched.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English. (The 维护者速读 section is Chinese by the dispatch rule.)
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…renders it once

Each NavigationItemRenderer asked useIsMobile() itself. That hook answers
false on its first render and corrects itself in an effect, so every phone
drawer open mounted the whole tree at desktop size, then each row read the
viewport, set state and the tree rendered and restyled a second time.

Rows now read one answer from the tree root: the sidebar provider's settled
isMobile inside a provider, else one useIsMobile() for the whole tree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jSAhJTiv4UzK1ubKri
…r its opening frame

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jSAhJTiv4UzK1ubKri
…d-sized opening frame

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsR2jSAhJTiv4UzK1ubKri
@github-actions github-actions Bot added size/m 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 Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y

席位核对 · ACCEPT · 2026-11-02T12:21Z

Head-sha: 8e64e407a62b28ff668d05e3106d165649e2916d

  • Shape: draft → main, Fixes #29141 in the body only. 3 files, +403 / −49 (452 lines ≤ 3000). No governed path: check-governed-merges --pr 29325 hit 0 of 3 paths. Author objectstack-fleet[bot]. Clause-②: no and Metadata-protocol: none are line-initial in the body and the changeset. The new context and helpers are module-private to NavigationRenderer.tsx, and no export of @object-ui/layout changes. No contract review is owed.

  • File surface: ui/packages/layout/src/NavigationRenderer.tsx, its new test file and the changeset, inside the claim as amended (6575269092). No components/src/ui/** file is touched, and UnifiedSidebar.tsx is not edited.

  • Read in the diff:

    • The 54 per-row useIsMobile() subscriptions are gone. Rows read one private NavIsMobileContext, fed by useOptionalSidebar().isMobile inside a sidebar provider, so a drawer open renders the tree once.
    • Outside a provider (the Studio previews), one useIsMobile() at the renderer feeds the same context, so their behaviour holds, as ruled.
    • Phone rows pass no tooltip. The primitive already hides it on mobile, so nothing visible changes.
    • On a phone, the first frame draws the sections that fit one viewport plus 4 rows. The rest join in one startTransition. Group-open state is computed by the same groupOpensOnFirstView the rows use, so the first frame counts what will show. Nothing scrolls to the active row on open, so a later row is never needed in the first frame.
  • Measured (in the body), A/B, 4 sets of 5 runs at 4x CPU on the production bundle:

    • Showcase: 0 of 20 opens under 200 ms became 17 of 20, and every set reached 4 or 5 of 5 (median 305 → 116 ms).
    • CRM: 14 of 20 became 19 of 20.
    • Group toggle stays under 90 ms.
    • Drawer contents and screenshots are identical, and no request was added.

    This meets the brief's budget. What remains is primitive internals: the FocusScope restyle (34 ms) and Presence (17 ms). They are named in the report, not widened into, as ruled.

  • Tests: the new render-count file and the layout suite pass, 392 of 392 on the merged head. The three ablations turn 3, 2 and 3 cases red. 34 of 34 derived gates exited 0.

  • Gates (check-runs on this head): 44 success, 11 skipped, 0 failed or pending, including all 16 Test UI shards. All ten required contexts succeeded, or were skipped by their path filter (Build Core).

  • Serial: Console at phone width: Back with the nav drawer open leaves the page (to /home) instead of closing the drawer #29131 (PR fix(app-shell): on a phone, Back with the nav drawer open closes the drawer and stays on the page #29323) mounts its hook in ConsoleLayout.tsx, not in this file, so there is no textual overlap.

  • Landing: ordinary queue landing, pr_ready then automerge_enable. Fixes #29141 closes the card on merge.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 12:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 8dceec1 Nov 2, 2026
56 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-uegfce branch November 2, 2026 13:18
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y

Landed · 2026-11-02T13:41Z · PR #29325 merged through the merge queue as 8dceec17. Record: ACCEPT 6577011676 at 8e64e407 (Clause-②: no, no contract review owed).

probe before (8dceec17~1) now (origin/main)
const NavIsMobileContext = React.createContext<boolean>(false); in ui/packages/layout/src/NavigationRenderer.tsx (one viewport read per tree, not per row) 0 1
function PhoneFirstFrame( in the same file (the opening frame draws only the rows that fit) 0 1

The card closed through Fixes. pm:dispatched comes off with this note. The measurements are in the PR body and issue-29141/ab-table.md in the project folder.


Generated by Claude Code

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/m tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Console nav drawer takes 300–580 ms to open on the showcase at CPU 4x (46 entries); profile and bring it under 200 ms

1 participant