Performance of large lists: lazy tooltip/popover mounting, skeleton fast path, profiling tooling#3551
Performance of large lists: lazy tooltip/popover mounting, skeleton fast path, profiling tooling#3551tom2drum wants to merge 17 commits into
Conversation
New tools/profiling/ directory (documented in its CONTEXT.md): - pnpm profile:preset <alias> — production build + serve with the profiling react-dom (next build --webpack --profile), env-wired to a live instance the same way as dev:preset - pnpm profile:analyze <a.json> [b.json] — aggregates React DevTools Profiler exports into per-component cost tables, with a delta table when comparing two traces Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion Every ChakraTooltip.Root instantiates a zag.js state machine plus several context providers, which dominates the render time of pages with many closed tooltips (~10 per row in transaction/transfer tables). Until the first pointerenter/focus the component now renders only the bare trigger element; the real tooltip mounts on demand and opens manually, re-checking :hover / activeElement when openDelay elapses (the swapped trigger node misses the activating event). Focus-activated triggers are re-focused after the swap so keyboard navigation is not interrupted. Measured on an address token-transfers page (50 rows, production build): ~125ms less scripting per list render. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PopoverRoot/DialogRoot create a zag.js state machine per instance, and transaction/transfer tables render one per row. Until the first pointerenter/focus we render only the bare button — the interaction always precedes the click that opens the popover. A focus-activated button is re-focused after the trigger mounts, since mounting swaps the DOM node. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An inactive skeleton contributes no visuals (the "none" variant only sets animation: none), yet still resolves its recipe on every render — a top offender on pages with ~1000 idle instances. When loading is falsy, merge the props directly into the only child (asChild) or render a plain chakra.div (ChakraSkeleton also renders a div). All asChild call sites have chakra-styled children, so the transferred style props are always valid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generating one icon runs 5 crypto hashes and builds an SVG, and a table mounts ~100 identicons at once, often for repeating addresses. The cache is LRU-capped since the number of distinct addresses viewed in an SPA session is unbounded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hover The full-date tooltip content was formatted with dayjs during render, so every timestamp cell of a table paid for the parsing/formatting in the initial commit even though the tooltip never opened. Wrapping the dayjs call in a content component defers it until the tooltip actually mounts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mounting the lazy tooltip/popover swaps the trigger DOM node, and if the swap happens before the activating gesture has fully finished, the gesture's click is lost (reproduced in iOS Safari: the QR code, copy and tx info buttons required a second tap). WebKit dispatches pointerup at touch-end but synthesizes mousedown/mouseup/click in follow-up tasks, so even a zero timeout scheduled at pointerup swaps the node too early. The new useLazyActivation hook centralizes the rule for both consumers: during a touch gesture nothing mounts until the gesture's own click handler (the last event of the sequence), with a delayed fallback for click-less gestures; mouse hover and keyboard focus mount on a macrotask as before. As a bonus, a tap now opens the tx info popover on the first touch: the click that happened before the deferred mount is replayed via defaultOpen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. To trigger a review, include ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…ragment External <use> references are re-resolved every time an icon element is (re)mounted, and that resolution is asynchronous in WebKit even with a warm cache — icons flickered on the lazy tooltip trigger swap in Safari (and in other browsers with caching disabled). The sprite is now fetched once per session (same immutable-cached request as before, so the image preload links are removed) and injected into the document; same-document fragment references resolve synchronously in all browsers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Child fast path The skeleton's props are forwarded from the call site (e.g. w/h passed to SpriteIcon), so they must override the child's defaults (the inner svg's w="100%") — this is how the Chakra skeleton's class merge resolved these conflicts before the fast path. Fixes custom-sized sprite icons rendering at 100% (advanced filter table headers, error page illustration). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes for the first click being lost in Linux WebKit (Playwright): the activation timer never reset its id, so a pending mount could not be re-scheduled after cancellation; and the mount commit — scheduled by React as a separate task — could land in the middle of a click sequence, because some ports interleave scheduler tasks between the individual events of one click. The mousedown target then gets detached and the browser never synthesizes the click. The mount now commits via flushSync inside the timer task and re-checks that no press is in flight. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The zag tooltip machine can self-open a controlled tooltip when another tooltip is visible: the tooltip-group instant-open transition (closed + pointer.move -> open) is not guarded by isOpenControlled. Settings rejected open requests while its popover was up, so the controlled prop never changed and could never send controlled.close — the tooltip got stuck open on top of the popover (reproducible in Playwright where closeDelay is 10s, and by a fast hover-move-click in production). Disabling the tooltip while the popover is open unmounts the machine entirely, which no guard can desync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR targets app-wide list rendering performance by optimizing shared UI primitives (tooltips/popovers, skeletons, icons/identicons, and date tooltips) and adds reproducible profiling tooling to measure and compare React render costs.
Changes:
- Lazily mount expensive tooltip/popover state machines via a shared
useLazyActivationhook, preserving focus/tap behavior. - Add a Skeleton “inactive fast path” to skip Chakra Skeleton recipe work when not loading.
- Improve icon/identicon/date-cell costs (sprite injected once per session, nouns identicon SVG cache, defer full-date formatting until tooltip open) and add profiling CLI scripts/docs.
Reviewed changes
Copilot reviewed 21 out of 37 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/profiling/profile.preset.sh | New CLI to build+serve a production-like profiling build wired to instance presets. |
| tools/profiling/CONTEXT.md | Documents profiling workflow, tooling roles, and common pitfalls. |
| tools/profiling/aggregate-react-profile.mjs | New analyzer to aggregate DevTools profiler exports and compute deltas. |
| src/toolkit/hooks/useLazyActivation.tsx | New hook to safely defer mounting until a gesture is complete (touch-safe). |
| src/toolkit/chakra/tooltip.tsx | Lazy-mount tooltip machine and manually drive first open on activation. |
| src/toolkit/chakra/skeleton.tsx | Adds non-loading fast path to skip ChakraSkeleton/recipe resolution entirely. |
| src/sprite/SpriteInjector.tsx | Fetches & injects SVG sprite once per session for same-document <use href="#...">. |
| src/sprite/SpriteIcon.tsx | Switches to same-document sprite references to avoid WebKit flicker. |
| src/slices/tx/components/TxAdditionalInfo.tsx | Lazy-mount popover/dialog trigger; open on first tap via defaultOpen. |
| src/slices/contract/pages/contract-verification/ContractVerificationForm.pw.tsx | Stabilizes visual test via focus positioning. |
| src/slices/address/components/icon/AddressIdenticonNouns.tsx | Adds LRU cache for generated identicon SVG data. |
| src/shell/top-bar/settings/Settings.tsx | Disables tooltip while settings popover is open to avoid stuck controlled tooltip. |
| src/shared/date-and-time/TimeWithTooltip.tsx | Defers full-date dayjs formatting until tooltip content mounts/opens. |
| src/shared/buttons/AdditionalInfoButton.tsx | Extends button props to accept activation handlers and forwards them. |
| src/pages/_document.tsx | Removes sprite preload and related import (sprite now injected via fetch). |
| src/pages/_app.tsx | Mounts SpriteInjector in app runtime. |
| src/features/rollup/arbitrum/pages/batch-details/ArbitrumL2TxnBatch.pw.tsx | Adjusts mouse move to stabilize screenshot after interactions. |
| playwright/TestApp.tsx | Ensures sprite injection is present in Playwright harness. |
| playwright/index.html | Removes sprite preload (no longer used as an external <use> target). |
| package.json | Adds profile:preset and profile:analyze scripts. |
| .agents/AGENTS.md | Registers the new profiling context docs directory. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Tooltip: drop the now-unused triggerProps prop; call sites (AddressEntity, AddressEntityContentProxy) set minW on the child directly, so it applies in the lazy phase too instead of only after the tooltip machine mounts - Sprite: export SPRITE_URL and re-add the preload in _document as="fetch" crossOrigin so SpriteInjector's fetch reuses it (warms cold-load first paint) - useLazyActivation: add unit tests for mouse/touch/focus activation sequencing Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ui-toolkit typecheck (`tsc --noEmit`) includes ../hooks and picked up the new useLazyActivation.spec.tsx, which imports the repo-root `vitest/lib` helper that isn't resolvable in the package build context. Exclude *.spec.*/*.pw.*/ *.test.* to match the patterns the vite dts plugin already excludes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Description and Related Issue(s)
Rendering a large table (e.g. 50 token transfers on an address page) spent over half a second of pure scripting per list render in production. Profiling showed the time going into shared UI primitives rather than page code: hundreds of closed-but-mounted tooltip/popover state machines (zag.js), ~1000 idle skeleton wrappers resolving their recipe for nothing, and per-mount identicon/date computations. This PR fixes those primitives, so every list page in the app benefits.
Measured on the address token-transfers page, production profiling build, filter-change scenario:
Proposed Changes
toolkit/chakra/tooltip.tsx): the Chakra tooltip machine is mounted lazily on the first pointer/focus interaction; the first open is driven manually (re-checking:hover/activeElementafteropenDelay), and a focus-activated trigger is re-focused after the mount swaps the DOM node.TxAdditionalInfo.tsx): the popover/dialog machine is mounted lazily the same way; a tap now opens it on the first touch viadefaultOpen.useLazyActivationhook (new,toolkit/hooks): gesture-safe activation shared by both — on touch devices nothing mounts until the gesture's ownclickhas been dispatched (WebKit synthesizes the compat click in follow-up tasks afterpointerup, so mounting earlier loses the first tap — reproduced in iOS Safari).toolkit/chakra/skeleton.tsx): whenloadingis falsy, the Chakra skeleton (and its recipe resolution) is skipped entirely — props merge into the only child (asChild) or a plainchakra.div.SpriteInjector.tsx): the sprite is fetched once per session and injected into the document, and icons reference symbols by same-document fragment (#name). External<use>references are re-resolved asynchronously in WebKit on every remount, which made icons flicker on the lazy tooltip trigger swap in Safari; same-document references resolve synchronously in all browsers. Theas="image"sprite preloads are removed since the sprite is now consumed viafetch().tools/profiling/, documented in itsCONTEXT.md):pnpm profile:preset <alias>— production build+serve with the profilingreact-dom, env-wired likedev:preset;pnpm profile:analyze <a.json> [b.json]— aggregates React DevTools profiler exports into per-component cost tables with a delta mode.No ENV variables were added or changed.
Breaking or Incompatible Changes
No API changes. Two behavior notes for
@blockscout/ui-toolkitconsumers:Skeletonno longer renders the Chakra skeleton wrapper (nochakra-skeletonclass, novariant="none"recipe); withasChildits style props are merged directly into the child. Anything styled against the idle skeleton DOM would be affected — nothing in this repo was.Additional Information
Button,Badge, ~85 ms of the 335). We filed chakra-ui#10878 with the profiling data, and the fix is already implemented upstream in chakra-ui#10880 — a future@chakra-ui/reactbump should bring the page to ~255 ms with no code changes.tools/profiling/CONTEXT.md.Checklist for PR author