Repository navigation
perf(layout): the phone nav drawer opens in one render, and its opening frame draws only the rows that fit - #29325
Conversation
…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
…idden there 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
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jSAhJTiv4UzK1ubKri
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jSAhJTiv4UzK1ubKri
|
domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y 席位核对 · ACCEPT · 2026-11-02T12:21ZHead-sha:
Generated by Claude Code |
|
domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y Landed · 2026-11-02T13:41Z · PR #29325 merged through the merge queue as
The card closed through Generated by Claude Code |
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).
维护者速读
@object-ui/layout的NavigationRenderer)。每一行不再各自询问「是不是手机」,而是整棵菜单读一次;手机上不再给每行挂一个本就隐藏的悬浮提示;菜单比屏幕长时,打开那一帧只画屏幕放得下的行,其余行紧接着在抽屉滑入期间补上。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/layoutor@object-ui/app-shellchanges, nothing undercomponents/src/ui/**is edited, and no metadata key, server route or request is added.NavigationItemRenderercalleduseIsMobile(). That hook returnsfalseon 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 (54matchMediacalls per open on the showcase). The root now provides one value through a privateNavIsMobileContext: the sidebar provider'sisMobilewhen a provider is present (it settled when the shell mounted), else oneuseIsMobile()for the whole tree, so aNavigationRenderermounted without a sidebar provider behaves as before.tooltipnames 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.PhoneFirstFramewrapper draws the top-level rows whose rows (a group counts its open children) fitceil(innerHeight / 44) + 4in the drawer's first commit. The rest join in aReact.startTransitionright 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.groupOpensOnFirstViewis factored out of the row's existing open-by-default logic so the root and the row compute the same answer..changeset/29141-phone-nav-drawer-open.md:@object-ui/layoutpatch, with the two declaration lines.Tests
ui/packages/layout/src/__tests__/NavigationRenderer.phoneDrawerOpen-29141.test.tsx(5 cases). A settledSidebarProviderat the window's size mounts the tree the way the drawer does. AresolveTargetLabelspy counts row renders, and aReact.Profilerrecords the links in the DOM at each commit.min-h-[44px]).scripts/ablation-replace.mjs, each restored to HEAD's blob,git diff HEADempty):useIsMobile()put back: 3 of 5 cases red (each row renders twice);Acceptance notes
FocusScope'sfocusFirstforces a style recalc of about 340 elements (34 ms self at 4x CPU), RadixPresence'sgetAnimationNameforces another (17 ms), Collapsible's measurement sets CSS variables, andDismissableLayersetspointer-eventson the body. They all live incomponents/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.Verification
Readings on head
8e64e407, which mergesorigin/maininto 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 bypnpm dev:showcaseandpnpm 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.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
--sourcemapbuild 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.focus(FocusScope's forced restyle)use-mobile.tsxeffect (the second render's trigger)getAnimationNamesetAttributegetBoundingClientRectmergePropsNavigationRenderer.tsxTraces, 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/layoutafter the merge of main: 48 files, 392 tests 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, includingpnpm --dir ui exec vitest run scripts/(169 files passed, 2 skipped; 4900 tests) andpnpm check:type-check-debtunder the verify lock.node scripts/check-sdui-manifest.mjs: exit 0.Human Minimum
packages/specholds no runtime logic.pnpm lintpasses.main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed. (Not touched.)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code