Skip to content

Cut a render pass and keep the divider lines off the paint path - #5

Merged
Guiw5 merged 1 commit into
mainfrom
perf/render-and-paint
Sep 9, 2026
Merged

Guiw5 merged 1 commit into
mainfrom
perf/render-and-paint

Conversation

@Guiw5

@Guiw5 Guiw5 commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Two changes, both measured. The interesting part of this PR is what it does not do: profiling killed two planned optimisations.

Changes

Derive the ready flag. useReady kept a state map that every measurement wrote to twice (register false, then true), each write costing a render pass, and the callbacks were invoked during render through useMemo. Readiness is just "the viewport and the content have a size", so useSnapPoints derives it now and useReady is gone.

Composite the divider lines. The header and footer lines lived in box-shadow with calc(var(--rsbs-content-opacity) * 0.125) as the alpha. Animating a colour inside a shadow repaints both elements every frame. They are pseudo elements now, with a fixed colour and an animated opacity, which stays on the compositor. Pixel output is unchanged.

Measured

React commits from click to data-rsbs-state="open", React Profiler, 4x CPU throttle:

commits of which cascading
before 6 2
after 4 2

The two remaining cascading commits come from setMounted in index.tsx, not from this change.

What the profiling said, and what I dropped because of it

I traced a sustained drag and an open on the scrollable fixture at 6x CPU throttle and aggregated the trace by rendering phase.

Drag window, 4575 ms:

phase share
script ~84%
style recalc 9.6%
paint 2.0%
layout 1.0%

Open window, 1331 ms:

phase share
script ~82%
style recalc 5.7%
paint 2.0%
layout 1.7%

Dropped: the heightMode="transform" idea. The premise was that animating height forces layout every frame and that moving to transform would be the big win. Layout is 1-2% of the time. The change would have cost the sticky footer its position and broken the scroll viewport at small snap points, in exchange for almost nothing.

Dropped: removing XState. It is 27% of the bundle, so it looked like the obvious cut. During a drag it is 0.4% of sampled CPU, and the drag is not CPU-bound at all — a third of the trace window is idle, p50 frame time is 16.7 ms at 6x throttle. Removing it is a rewrite of the interruption semantics for a bundle saving on a chunk that consuming apps already load lazily.

What the traces do say is that both paths are script-bound, and that the expensive one is open, not the animation: 1331 ms at 6x throttle with the main thread busy essentially throughout, against a drag that idles. If more work goes into performance, that is where it belongs — the promise-actor chain, focus-trap's tabbable scan, and the aria hider's body > * walk all run before the sheet is visible.

Test plan

  • npm test: lint, 25 unit tests, library build, docs build
  • Header handle and both divider lines verified pixel-identical after the position: relative change, which was the risk
  • Sticky fixture in Chrome: open, drag, snap, content resize, close
  • Packed and installed into a clean Vite consumer on React 19: open, drag to dismiss, unmount, scroll lock released
  • Real iOS Safari and Android Chrome

useReady tracked readiness in a state map that every measurement wrote to, costing a render pass each; it is now derived from the measurements themselves. The header and footer lines animated their alpha inside box-shadow, repainting both on every frame; they are pseudo elements animating opacity now.
@Guiw5
Guiw5 merged commit 78b6e33 into main Sep 9, 2026
2 checks passed
@Guiw5
Guiw5 deleted the perf/render-and-paint branch September 9, 2026 04:08
@Guiw5 Guiw5 mentioned this pull request Sep 9, 2026
3 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant