Skip to content

Performance of large lists: lazy tooltip/popover mounting, skeleton fast path, profiling tooling#3551

Open
tom2drum wants to merge 17 commits into
mainfrom
tom2drum/react-profiling-tools
Open

Performance of large lists: lazy tooltip/popover mounting, skeleton fast path, profiling tooling#3551
tom2drum wants to merge 17 commits into
mainfrom
tom2drum/react-profiling-tools

Conversation

@tom2drum

@tom2drum tom2drum commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

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:

Metric Before After
List render commit 554 ms 335 ms (−40%)
React fibers per commit 25,861 15,663 (−39%)
Data-arrival commit 88 ms 39 ms (−56%)

Proposed Changes

  • Tooltip (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/activeElement after openDelay), and a focus-activated trigger is re-focused after the mount swaps the DOM node.
  • Tx additional info (TxAdditionalInfo.tsx): the popover/dialog machine is mounted lazily the same way; a tap now opens it on the first touch via defaultOpen.
  • useLazyActivation hook (new, toolkit/hooks): gesture-safe activation shared by both — on touch devices nothing mounts until the gesture's own click has been dispatched (WebKit synthesizes the compat click in follow-up tasks after pointerup, so mounting earlier loses the first tap — reproduced in iOS Safari).
  • Skeleton (toolkit/chakra/skeleton.tsx): when loading is falsy, the Chakra skeleton (and its recipe resolution) is skipped entirely — props merge into the only child (asChild) or a plain chakra.div.
  • Nouns identicon: the generated SVG (5 crypto hashes + SVG build per mount) is cached by address with LRU eviction.
  • Time with tooltip: the full-date dayjs formatting is deferred until the tooltip first opens.
  • SVG sprite (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. The as="image" sprite preloads are removed since the sprite is now consumed via fetch().
  • Profiling tooling (tools/profiling/, documented in its CONTEXT.md): pnpm profile:preset <alias> — production build+serve with the profiling react-dom, env-wired like dev: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-toolkit consumers:

  • A tooltip/popover trigger element is unmounted and remounted once upon the first interaction (refs and focus are preserved/restored).
  • A non-loading Skeleton no longer renders the Chakra skeleton wrapper (no chakra-skeleton class, no variant="none" recipe); with asChild its 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

  • The remaining top cost after this PR is Chakra's per-instance recipe resolution (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/react bump should bring the page to ~255 ms with no code changes.
  • Playwright visual suite passed; touch behavior (QR code, copy, tx info buttons) verified in the iOS Simulator; keyboard navigation (focus preservation, tooltips on Tab) verified manually.
  • Profiler traces and the analysis methodology are reproducible via tools/profiling/CONTEXT.md.

Checklist for PR author

  • I have tested these changes locally.
  • I added tests to cover any new functionality, following this guide
  • Whenever I fix a bug, I include a regression test to ensure that the bug does not reappear silently.
  • If I have added a feature or functionality that is not privacy-compliant (e.g., tracking, analytics, third-party services), I have disabled it for private mode.
  • If I have added, changed, renamed, or removed an environment variable
    • I updated the list of environment variables in the documentation
    • I made the necessary changes to the validator script according to the guide
    • I added "ENVs" label to this pull request

tom2drum and others added 7 commits July 3, 2026 15:45
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>
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. To trigger a review, include @coderabbitai review in the PR description. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d40a3ebf-ca51-4533-ad93-2f9363c213e9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@tom2drum tom2drum added dependencies Pull requests that update a dependency file tech Issues related to building, testing, and other project tooling performance and removed dependencies Pull requests that update a dependency file labels Jul 6, 2026
tom2drum and others added 4 commits July 6, 2026 18:26
…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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 useLazyActivation hook, 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.

Comment thread src/toolkit/chakra/tooltip.tsx
Comment thread src/toolkit/chakra/skeleton.tsx
tom2drum and others added 2 commits July 9, 2026 11:16
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance tech Issues related to building, testing, and other project tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants