Skip to content

[trees] vibe-coded scroll perf fixes#515

Open
SlexAxton wants to merge 4 commits intomainfrom
alex/trees/scroll-perf
Open

[trees] vibe-coded scroll perf fixes#515
SlexAxton wants to merge 4 commits intomainfrom
alex/trees/scroll-perf

Conversation

@SlexAxton
Copy link
Copy Markdown
Contributor

@SlexAxton SlexAxton commented Apr 17, 2026

Trees path-store scroll performance handoff

Current code state

Use the current branch as the handoff base.

Key files touched during this work:

  • packages/trees/src/path-store/view.tsx
  • packages/trees/src/path-store/virtualization.ts
  • packages/trees/src/path-store/types.ts
  • packages/trees/src/path-store/index.ts
  • packages/trees/src/style.css
  • packages/trees/test/path-store-render-scroll.test.ts

Current design decisions that should be preserved unless there is strong trace evidence otherwise:

  • Reverse-sticky window virtualization is restored and kept.
  • The translated sticky-buffer experiment was abandoned.
  • Scroll suppression is armed on wheel and refreshed on scroll.
  • Scroll-driven range updates are coalesced to one scheduled update per frame.
  • During active scroll, row-level hover interaction is suppressed.
  • Truncate marker / fade decoration is hidden during active scroll.
  • Scroll suppression uses a 100ms trailing delay to bridge short wheel gaps.

Verification state

Current code passes:

  • AGENT=1 bun test test/path-store-render-scroll.test.ts
  • AGENT=1 bun test test/path-store-composition-surfaces.test.ts
  • AGENT=1 bun run tsc
  • AGENT=1 bun run format

Trace files used during this work

  • Baseline: Trace-20260416T205528.json
  • Hover regression after removing scroll-state suppression: Trace-20260417T105930.json
  • Old list-scoped suppression restored: Trace-20260417T115158.json
  • Style-only hover suppression + rAF batching: Trace-20260417T120934.json
  • Wheel-armed suppression: Trace-20260417T121957.json
  • Full hover cleanup: Trace-20260417T123111.json
  • Truncate marker / fade hidden during scroll: Trace-20260417T123942.json

What we tried and what we learned

1. Stable sticky buffer + translated content reduced measured layout in synthetic profiling, but broke the non-blanking technique

What changed:

  • introduced computeStickyBufferLayout / computeStickyBufferRange
  • translated mounted content inside a larger sticky shell
  • removed data-is-scrolling

What happened:

  • automated profiling initially looked promising
  • fast scrolling and large jumps showed more blanking in real use
  • comparison to packages/diffs and to the original reverse-sticky design clarified the issue

Why it failed:

  • the original technique works because the mounted content itself is what the browser scrolls
  • the translated-shell version reintroduced JS-positioned content inside blank regions
  • that weakened the anti-blanking property the current sticky design was chosen for

Action taken:

  • fully reverted the sticky-buffer translation geometry
  • restored computeStickyWindowLayout
  • restored the original sticky DOM structure

Conclusion:

  • do not return to the translated sticky-shell approach unless the design goal changes and blanking is acceptable

2. Removing data-is-scrolling caused real hover/style churn

Trace:

  • Trace-20260417T105930.json

Compared to the baseline Trace-20260416T205528.json:

  • style recalculation/sec: 13.7 -> 44.9
  • pointerover+mouseover/sec: 1.1 -> 27.0
  • layout ms/sec: 165.6 -> 196.5
  • paint ms/sec: 127.7 -> 143.4

Observed in the trace window:

  • pointerover: 193
  • mouseover: 193
  • data-is-scrolling: absent

Conclusion:

  • losing the list scroll-state attribute was a real regression
  • CSS hover churn during mouse-wheel scrolling is material

3. Restoring old list-scoped data-is-scrolling helped hover churn, but did not solve the whole problem

Trace:

  • Trace-20260417T115158.json

Improvement vs the hover-regressed trace:

  • pointerover+mouseover/sec: 27.0 -> 9.0
  • style recalculation/sec: 44.9 -> 30.5

But overall scroll numbers in that run were still poor:

  • dropped/sec: 87.4
  • layout ms/sec: 252.8
  • updateLayoutTree ms/sec: 62.6

Conclusion:

  • hover suppression was necessary, but not sufficient

4. Style-only hover gating was a bad idea

What changed:

  • kept data-is-scrolling
  • removed pointer suppression
  • gated hover visuals only via CSS selectors

Trace:

  • Trace-20260417T120934.json

Result:

  • hover/pointer churn stayed high or got worse
  • pointerover+mouseover/sec: 30.6
  • style recalculation/sec: 50.1

Why:

  • style gating stops the visual effect
  • it does not stop hover hit-testing, event dispatch, or hover invalidation

Conclusion:

  • style-only suppression is not enough

5. Coalescing scroll-driven updates to one frame was a real improvement

What changed:

  • scroll and resize now schedule viewport recomputation at most once per frame
  • imperative callers using updateViewportRef.current() still update immediately for focus/drag codepaths

Why it was tried:

  • packages/diffs batches render work through a frame queue
  • trees path-store previously recomputed directly on every scroll event

Conclusion:

  • batching viewport recomputation like diffs’ render queue is a good pattern
  • keep it

6. Arming suppression on wheel before scroll helped materially

What changed:

  • begin scroll suppression from wheel
  • still refresh it on scroll

Trace:

  • Trace-20260417T121957.json

Compared to Trace-20260417T120934.json:

  • dropped/sec: 77.7 -> 58.1
  • layout ms/sec: 221.4 -> 183.5
  • updateLayoutTree ms/sec: 46.8 -> 37.5
  • paint ms/sec: 171.1 -> 131.6

Conclusion:

  • the “hover before scroll handler fires” gap was real
  • wheel-arming should stay

7. Full hover cleanup worked for hover/transition churn, but did not fix layout

What changed:

  • 100ms suppression window
  • descendant-level pointer suppression during scroll
  • spacing guide hover hidden during scroll
  • removed spacing opacity transition
  • removed context trigger color transition

Trace:

  • Trace-20260417T123111.json

Improvements vs Trace-20260417T121957.json:

  • pointerover+mouseover/sec: 26.2 -> 8.4
  • pointermove+mousemove/sec: 38.6 -> 16.9
  • transition/sec: 143.8 -> 0
  • style recalculation/sec: 43.4 -> 22.1

But overall:

  • dropped/sec: 58.1 -> 67.2
  • layout ms/sec: 183.5 -> 194.6

Conclusion:

  • hover/transition cleanup was real and worthwhile
  • the remaining bottleneck after this point was more clearly layout/main-thread scroll work, not hover noise

8. Truncate marker paint was a real hotspot, despite only a few visibly truncated rows

This was the most surprising result.

Trace before truncate suppression:

  • Trace-20260417T123111.json

Evidence:

  • PaintImage count: 18150
  • PaintImage/sec: 1825.4
  • sampled PaintImage events were CSS pseudo-elements:
    • nodeName: "::before" / "::after"
    • isCSS: true
    • tiny srcWidth: 4
  • this maps closely to truncate marker / fade pseudo-elements in style.css

What changed:

  • hide [data-truncate-marker] and [data-truncate-fade] while data-is-scrolling is active

Trace after truncate suppression:

  • Trace-20260417T123942.json

Result vs Trace-20260417T123111.json:

  • PaintImage count: 18150 -> 338
  • PaintImage/sec: 1825.4 -> 33.2
  • paint ms/sec: 149.1 -> 126.0
  • style recalculation/sec: 22.1 -> 18.1
  • pointerover+mouseover/sec: 8.4 -> 0.6
  • pointermove+mousemove/sec: 16.9 -> 0

Conclusion:

  • truncate marker/fade decoration was definitely a real paint hotspot
  • hiding it during scroll should stay

Current best read from the trace series

Baseline

Trace-20260416T205528.json

Normalized during scroll:

  • dropped/sec: 60.1
  • layout ms/sec: 165.6
  • updateLayoutTree ms/sec: 30.2
  • paint ms/sec: 127.7
  • paintImage/sec: 1451.0
  • styleRecalc/sec: 13.7
  • pointerover+mouseover/sec: 1.1

Latest cleaned-up trace

Trace-20260417T123942.json

Normalized during scroll:

  • dropped/sec: 74.7
  • layout ms/sec: 225.3
  • updateLayoutTree ms/sec: 38.8
  • paint ms/sec: 126.0
  • paintImage/sec: 33.2
  • styleRecalc/sec: 18.1
  • pointerover+mouseover/sec: 0.6

Interpretation:

  • hover / pointer / transition / truncate paint noise is now largely under control
  • overall paint is roughly back to baseline or slightly better
  • the remaining major regression is layout / main-thread scroll work

Important caveat on the manual traces

The manual scroll runs were not identical in intensity:

  • later runs often had different counts of wheel, scroll, and SCROLL_MAIN_THREAD
  • use normalized per-second metrics, but do not over-interpret any single run as perfectly apples-to-apples
  • still, the directional conclusions above were strong enough to guide the changes confidently

What is likely still hot now

The latest trace points away from hover/truncate paint and toward layout.

Most likely remaining hotspots:

  • main-thread layout / update work during scroll
  • scroll-driven rerender churn in PathStoreTreesView
  • geometry reads / state updates around floating context trigger positioning

Highest-confidence next wins

1. Investigate scroll-driven React/render churn inside PathStoreTreesView

We already coalesce viewport updates to one frame.
The next question is: what still rerenders on every range change?

Best next step:

  • instrument render counts during scroll
  • measure how many rows rerender per frame when the range changes
  • identify whether range rebases are causing more row work than expected

Likely win if confirmed:

  • reduce state churn during scroll
  • move some purely scroll-adjacent visuals out of React/state if possible

2. Freeze or defer floating context-trigger positioning during active scroll

Observed indicators across the process:

  • updateTriggerPosition showed up repeatedly in trace-string searches
  • getBoundingClientRect appeared repeatedly as well
  • trigger positioning uses layout reads and a useLayoutEffect

This is not yet proven as the top remaining cost, but it is a credible next candidate.

Best next experiment:

  • temporarily freeze or defer context trigger anchor positioning while data-is-scrolling is active
  • re-run trace and compare layout / updateLayoutTree

If it wins:

  • only reposition the trigger after scrolling settles

3. Verify whether layout is still escaping the tree subtree

The original baseline showed document-level layout:

  • all observed Layout events during the scroll window were partialLayout: false
  • layout root was #document

If the latest traces still show that, the real remaining problem is likely not paint micro-optimizations but a remaining invalidation source that escapes the tree subtree.

Best next step:

  • inspect latest trace Layout event args for partialLayout and layout root again
  • if still document-level, search for the remaining invalidation source instead of continuing CSS pruning

4. Do not revisit the translated sticky-shell approach

This is worth repeating:

  • the sticky-buffer translateY approach improved some synthetic layout numbers
  • but it broke the anti-blanking behavior the current sticky technique exists to preserve

Treat that idea as closed unless the design goal changes.

What not to change casually

Keep these unless new trace evidence proves they are harmful:

  • reverse-sticky window geometry
  • wheel-armed suppression
  • frame-coalesced scroll updates
  • descendant-level pointer suppression during scroll
  • 100ms trailing suppression delay
  • hidden truncate markers/fades during scroll

Useful reference

packages/diffs was helpful as a reference for batching/scheduling patterns, especially frame-coalesced updates.

It was not a drop-in geometry model for trees.
Treat diffs as inspiration for render scheduling, not for replacing the reverse-sticky non-blanking technique.

Most likely next best move

If there is time for only one more optimization pass:

  • investigate and reduce layout work caused by scroll-driven React state and context-trigger positioning

If there is time for two:

  1. freeze/defer floating context trigger anchor updates during active scroll and trace it
  2. instrument row rerender / commit counts inside the PathStoreTreesView.update() path and cut unnecessary scroll-time rerenders

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pierrejs-diff-demo Ready Ready Preview Apr 17, 2026 10:28pm
pierrejs-docs Ready Ready Preview Apr 17, 2026 10:28pm

Request Review

@blacksmith-sh

This comment has been minimized.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f23bb220a2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines 497 to 499
min-height: 100%;
width: 100%;
overflow-anchor: none;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore scroll-state hover suppression selector

Removing the data-is-scrolling CSS branch from the virtualized list rule disables hover suppression for all consumers of data-file-tree-virtualized-list, not just path-store/view.tsx. VirtualizedList (used by components/Root.tsx) still sets and clears container.dataset.isScrolling on scroll specifically to suppress hover paints, so this change leaves that mechanism ineffective and reintroduces scroll-time hover repaint churn in the legacy tree path. Either keep the selector for existing VirtualizedList behavior or update that path in the same commit.

Useful? React with 👍 / 👎.

if there's a good way to autoresearch on checking if these help or not,
might be worth it.  They definitely do not fix iOS Safari scrolling
issues, but might help in some other generalized cases
@blacksmith-sh
Copy link
Copy Markdown
Contributor

blacksmith-sh Bot commented Apr 17, 2026

Found 10 test failures on Blacksmith runners:

Failures

Test View Logs
path-store composition surfaces/
keeps the context-menu shell slotted in light DOM while anchoring from the shadow tree
View Logs
path-store composition surfaces/
restores keyboard navigation after closing a mouse-opened context menu
View Logs
path-store mutation proof/deletes a row through the reused context-menu shell View Logs
path-store rename proof/
context-menu rename closes the menu and Escape cancels the inline session
View Logs
path-store rename proof/
trigger-opened menu still restores focus after a prior rename flow disabled restoreFocu
s
View Logs
test/e2e/path-store-composition.pw.ts › path-store composition surfaces/
keeps the context-menu shell slotted in light DOM while anchoring from the shadow tree
View Logs
test/e2e/path-store-composition.pw.ts › path-store composition surfaces/
restores keyboard navigation after closing a mouse-opened context menu
View Logs
test/e2e/path-store-mutations.pw.ts › path-store mutation proof/
deletes a row through the reused context-menu shell
View Logs
test/e2e/path-store-rename.pw.ts › path-store rename proof/
context-menu rename closes the menu and Escape cancels the inline session
View Logs
test/e2e/path-store-rename.pw.ts › path-store rename proof/
trigger-opened menu still restores focus after a prior rename flow disabled restoreFocu
s
View Logs

Fix in Cursor


&[data-is-scrolling] {
pointer-events: none;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Honestly not sure if i accidentally removed this or if this was from an earlier vibe code sesh

stickyInset: Math.min(
0,
viewportHeight - windowHeight + randomStickyOffset
),
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

pushed the random offset to main, to avoid all the other stuff in this branch that i ruined, fyi

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.

2 participants