From f23bb220a24822fcee63bad9aca20a572fe5ccbe Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Fri, 17 Apr 2026 10:49:32 -0500 Subject: [PATCH 1/4] vibe-coded scroll perf fixes --- packages/trees/src/path-store/view.tsx | 69 +++++++------------ packages/trees/src/style.css | 4 -- .../test/path-store-render-scroll.test.ts | 15 ++-- 3 files changed, 36 insertions(+), 52 deletions(-) diff --git a/packages/trees/src/path-store/view.tsx b/packages/trees/src/path-store/view.tsx index fd013aaee..16b656343 100644 --- a/packages/trees/src/path-store/view.tsx +++ b/packages/trees/src/path-store/view.tsx @@ -959,7 +959,6 @@ export function PathStoreTreesView({ const contextMenuAnchorRef = useRef(null); const contextMenuTriggerRef = useRef(null); const isScrollingRef = useRef(false); - const listRef = useRef(null); const renameInputRef = useRef(null); const rootRef = useRef(null); const scrollRef = useRef(null); @@ -1007,20 +1006,20 @@ export function PathStoreTreesView({ } | null>(null); const contextMenuStateRef = useRef(contextMenuState); contextMenuStateRef.current = contextMenuState; - const [itemCount, setItemCount] = useState(() => - controller.getVisibleCount() - ); + const initialItemCount = controller.getVisibleCount(); + const initialRange = computeWindowRange({ + itemCount: initialItemCount, + itemHeight, + overscan, + scrollTop: 0, + viewportHeight, + }); + const [itemCount, setItemCount] = useState(() => initialItemCount); const [resolvedViewportHeight, setResolvedViewportHeight] = useState(viewportHeight); - const [range, setRange] = useState(() => - computeWindowRange({ - itemCount: controller.getVisibleCount(), - itemHeight, - overscan, - scrollTop: 0, - viewportHeight, - }) - ); + const [range, setRange] = useState(() => initialRange); + const rangeRef = useRef(range); + rangeRef.current = range; const contextMenuEnabled = composition?.contextMenu?.enabled === true || composition?.contextMenu?.render != null || @@ -1809,7 +1808,6 @@ export function PathStoreTreesView({ useLayoutEffect(() => { let scrollTimer: ReturnType | null = null; const scrollElement = scrollRef.current; - const listElement = listRef.current; if (scrollElement == null) { return; } @@ -1838,21 +1836,20 @@ export function PathStoreTreesView({ ? previousHeight : nextViewportHeight ); - setRange((previousRange) => { - const nextRange = computeWindowRange( - { - itemCount: nextItemCount, - itemHeight, - overscan, - scrollTop, - viewportHeight: nextViewportHeight, - }, - previousRange - ); - return rangesEqual(previousRange, nextRange) - ? previousRange - : nextRange; - }); + const nextRange = computeWindowRange( + { + itemCount: nextItemCount, + itemHeight, + overscan, + scrollTop, + viewportHeight: nextViewportHeight, + }, + rangeRef.current + ); + if (!rangesEqual(rangeRef.current, nextRange)) { + rangeRef.current = nextRange; + setRange(nextRange); + } }; updateViewportRef.current = update; @@ -1869,20 +1866,10 @@ export function PathStoreTreesView({ setContextHoverPath((previousPath) => previousPath == null ? previousPath : null ); - - // Mark the list as scrolling to suppress hover styles on items. - // Applied to the list (inside the scroll container) so the container - // itself still receives scroll events. - if (listElement != null) { - listElement.dataset.isScrolling ??= ''; - } if (scrollTimer != null) { clearTimeout(scrollTimer); } scrollTimer = setTimeout(() => { - if (listElement != null) { - delete listElement.dataset.isScrolling; - } isScrollingRef.current = false; scrollTimer = null; }, 50); @@ -1905,9 +1892,6 @@ export function PathStoreTreesView({ if (scrollTimer != null) { clearTimeout(scrollTimer); } - if (listElement != null) { - delete listElement.dataset.isScrolling; - } isScrollingRef.current = false; resizeObserver?.disconnect(); }; @@ -2423,7 +2407,6 @@ export function PathStoreTreesView({ ) : null}
diff --git a/packages/trees/src/style.css b/packages/trees/src/style.css index 0632c9084..bb2c48a9a 100644 --- a/packages/trees/src/style.css +++ b/packages/trees/src/style.css @@ -497,10 +497,6 @@ min-height: 100%; width: 100%; overflow-anchor: none; - - &[data-is-scrolling] { - pointer-events: none; - } } [data-file-tree-virtualized-sticky-offset='true'] { diff --git a/packages/trees/test/path-store-render-scroll.test.ts b/packages/trees/test/path-store-render-scroll.test.ts index 9c890e642..775911030 100644 --- a/packages/trees/test/path-store-render-scroll.test.ts +++ b/packages/trees/test/path-store-render-scroll.test.ts @@ -1084,7 +1084,7 @@ describe('path-store render + scroll', () => { } }); - test('marks the virtualized list as scrolling to suppress hover styles', async () => { + test('scroll keeps sticky window geometry and hover suppression out of DOM attributes', async () => { const { cleanup, dom } = installDom(); try { const { PathStoreFileTree } = await import('../src/path-store'); @@ -1109,6 +1109,9 @@ describe('path-store render + scroll', () => { const listElement = shadowRoot?.querySelector( '[data-file-tree-virtualized-list="true"]' ); + const stickyContentElement = shadowRoot?.querySelector( + '[data-file-tree-virtualized-sticky-content="true"]' + ); if (!(scrollElement instanceof dom.window.HTMLElement)) { throw new Error('missing scroll element'); @@ -1119,16 +1122,18 @@ describe('path-store render + scroll', () => { const viewport = scrollElement as HTMLElement; const list = listElement as HTMLDivElement; - + expect(stickyContentElement).toBeNull(); expect(list.dataset.isScrolling).toBeUndefined(); viewport.scrollTop = 1500; viewport.dispatchEvent(new dom.window.Event('scroll')); await flushDom(); - expect(list.dataset.isScrolling).toBe(''); - - await new Promise((resolve) => setTimeout(resolve, 60)); + expect( + shadowRoot?.querySelector( + '[data-file-tree-virtualized-sticky-content="true"]' + ) + ).toBeNull(); expect(list.dataset.isScrolling).toBeUndefined(); fileTree.cleanUp(); From 26007467d7078be4ed52327e92cdb1bfee8c091c Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Fri, 17 Apr 2026 12:43:31 -0500 Subject: [PATCH 2/4] better hover killing on scroll. remove truncation fade on scroll. --- packages/trees/src/path-store/view.tsx | 67 +++++++++++++++++-- packages/trees/src/style.css | 42 +++++++++--- .../test/path-store-render-scroll.test.ts | 10 ++- 3 files changed, 103 insertions(+), 16 deletions(-) diff --git a/packages/trees/src/path-store/view.tsx b/packages/trees/src/path-store/view.tsx index 16b656343..e429cbf9d 100644 --- a/packages/trees/src/path-store/view.tsx +++ b/packages/trees/src/path-store/view.tsx @@ -163,6 +163,7 @@ function getPathStoreTreesRowAriaLabel(row: PathStoreTreesVisibleRow): string { return flattenedSegments.map((segment) => segment.name).join(' / '); } +const SCROLL_HOVER_SUPPRESSION_DELAY = 100; const TOUCH_LONG_PRESS_DELAY = 400; const TOUCH_LONG_PRESS_MOVE_THRESHOLD = 10; const DRAG_EDGE_SCROLL_THRESHOLD = 40; @@ -959,6 +960,7 @@ export function PathStoreTreesView({ const contextMenuAnchorRef = useRef(null); const contextMenuTriggerRef = useRef(null); const isScrollingRef = useRef(false); + const listRef = useRef(null); const renameInputRef = useRef(null); const rootRef = useRef(null); const scrollRef = useRef(null); @@ -1851,14 +1853,48 @@ export function PathStoreTreesView({ setRange(nextRange); } }; + let scheduledUpdateHandle: number | null = null; + + // Coalesce scroll-driven range updates to one paint so wheel bursts do not + // queue repeated rerenders before the browser can present the next frame. + const scheduleUpdate = (): void => { + if (scheduledUpdateHandle != null) { + return; + } + + const flushUpdate = (): void => { + scheduledUpdateHandle = null; + update(); + }; + + scheduledUpdateHandle = + typeof window.requestAnimationFrame === 'function' + ? window.requestAnimationFrame(() => { + flushUpdate(); + }) + : window.setTimeout(flushUpdate, 0); + }; + + const cancelScheduledUpdate = (): void => { + if (scheduledUpdateHandle == null) { + return; + } + + if (typeof window.cancelAnimationFrame === 'function') { + window.cancelAnimationFrame(scheduledUpdateHandle); + } else { + window.clearTimeout(scheduledUpdateHandle); + } + scheduledUpdateHandle = null; + }; updateViewportRef.current = update; const unsubscribe = controller.subscribe(() => { setControllerRevision((revision) => revision + 1); update(); }); - const onScroll = (): void => { - update(); + const listElement = listRef.current; + const beginScrollSuppression = (): void => { if (contextMenuStateRef.current != null) { closeContextMenuRef.current(); } @@ -1866,20 +1902,37 @@ export function PathStoreTreesView({ setContextHoverPath((previousPath) => previousPath == null ? previousPath : null ); + + if (listElement != null) { + listElement.dataset.isScrolling ??= ''; + } if (scrollTimer != null) { clearTimeout(scrollTimer); } + // Keep suppression active across short wheel gaps so hover styles do not + // flicker back on between consecutive input bursts. scrollTimer = setTimeout(() => { + if (listElement != null) { + delete listElement.dataset.isScrolling; + } isScrollingRef.current = false; scrollTimer = null; - }, 50); + }, SCROLL_HOVER_SUPPRESSION_DELAY); + }; + const onWheel = (): void => { + beginScrollSuppression(); + }; + const onScroll = (): void => { + scheduleUpdate(); + beginScrollSuppression(); }; + scrollElement.addEventListener('wheel', onWheel, { passive: true }); scrollElement.addEventListener('scroll', onScroll, { passive: true }); const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => { - update(); + scheduleUpdate(); }) : null; resizeObserver?.observe(scrollElement); @@ -1888,10 +1941,15 @@ export function PathStoreTreesView({ return () => { updateViewportRef.current = () => {}; unsubscribe(); + scrollElement.removeEventListener('wheel', onWheel); scrollElement.removeEventListener('scroll', onScroll); + cancelScheduledUpdate(); if (scrollTimer != null) { clearTimeout(scrollTimer); } + if (listElement != null) { + delete listElement.dataset.isScrolling; + } isScrollingRef.current = false; resizeObserver?.disconnect(); }; @@ -2407,6 +2465,7 @@ export function PathStoreTreesView({ ) : null}
diff --git a/packages/trees/src/style.css b/packages/trees/src/style.css index bb2c48a9a..a7c40e57b 100644 --- a/packages/trees/src/style.css +++ b/packages/trees/src/style.css @@ -578,12 +578,6 @@ gap: var(--trees-item-row-gap); border-radius: var(--trees-border-radius); - &:hover, - &[data-item-context-hover='true'] { - background-color: var(--trees-bg-muted); - --truncate-marker-background-color: var(--trees-bg-muted); - } - &[data-item-focused='true'], &:focus-visible { z-index: 2; @@ -621,6 +615,14 @@ } } + [data-file-tree-virtualized-list='true']:not([data-is-scrolling]) + [data-type='item']:hover, + [data-file-tree-virtualized-list='true']:not([data-is-scrolling]) + [data-type='item'][data-item-context-hover='true'] { + background-color: var(--trees-bg-muted); + --truncate-marker-background-color: var(--trees-bg-muted); + } + [data-item-selected='true']:has(+ [data-item-selected='true']) { border-bottom-left-radius: 0; border-bottom-right-radius: 0; @@ -630,18 +632,25 @@ border-top-left-radius: 0; border-top-right-radius: 0; } - /* Flattened Directory Parts */ [data-item-flattened-subitems] { display: inline-flex; align-items: center; gap: 2px; } - [data-item-flattened-subitem]:hover, + [data-file-tree-virtualized-list='true']:not([data-is-scrolling]) + [data-item-flattened-subitem]:hover, [data-item-flattened-subitem-drag-target='true'] { text-decoration: underline; } + [data-file-tree-virtualized-list='true'][data-is-scrolling] + [data-type='item'], + [data-file-tree-virtualized-list='true'][data-is-scrolling] + [data-item-flattened-subitem] { + pointer-events: none; + } + /* Icon for each item */ [data-item-section='icon'] { flex-shrink: 0; @@ -900,7 +909,6 @@ height: 100%; margin-right: calc(var(--trees-level-gap) - 1px); opacity: 0; - transition: opacity 150ms ease; & + & { margin-left: calc( @@ -909,10 +917,16 @@ } } - :host(:hover) [data-item-section='spacing-item'] { + :host(:hover) + [data-file-tree-virtualized-list='true']:not([data-is-scrolling]) + [data-item-section='spacing-item'] { opacity: 0.75; } + [data-file-tree-virtualized-list='true'][data-is-scrolling] + [data-item-section='spacing-item'] { + opacity: 0; + } /* Git status indicator */ /* This is a folder that contains a git change */ @@ -1044,7 +1058,6 @@ margin: var(--trees-focus-ring-width); height: calc(var(--trees-row-height) - var(--trees-focus-ring-width) * 2); border-width: 0; - transition: color 120ms ease; display: flex; } @@ -1251,6 +1264,13 @@ margin: var(--truncate-internal-fade-marker-width) 0; } + [data-file-tree-virtualized-list='true'][data-is-scrolling] + [data-truncate-marker], + [data-file-tree-virtualized-list='true'][data-is-scrolling] + [data-truncate-fade] { + display: none; + } + [data-truncate-group-container='middle'] { & [data-truncate-container] { --truncate-marker-opacity: var(--truncate-internal-middle-marker-opacity); diff --git a/packages/trees/test/path-store-render-scroll.test.ts b/packages/trees/test/path-store-render-scroll.test.ts index 775911030..d3ed5f125 100644 --- a/packages/trees/test/path-store-render-scroll.test.ts +++ b/packages/trees/test/path-store-render-scroll.test.ts @@ -1084,7 +1084,7 @@ describe('path-store render + scroll', () => { } }); - test('scroll keeps sticky window geometry and hover suppression out of DOM attributes', async () => { + test('wheel input marks the virtualized list as scrolling before scroll updates and still avoids a sticky content wrapper', async () => { const { cleanup, dom } = installDom(); try { const { PathStoreFileTree } = await import('../src/path-store'); @@ -1125,6 +1125,11 @@ describe('path-store render + scroll', () => { expect(stickyContentElement).toBeNull(); expect(list.dataset.isScrolling).toBeUndefined(); + viewport.dispatchEvent( + new dom.window.WheelEvent('wheel', { bubbles: true, deltaY: 80 }) + ); + expect(list.dataset.isScrolling).toBe(''); + viewport.scrollTop = 1500; viewport.dispatchEvent(new dom.window.Event('scroll')); await flushDom(); @@ -1134,6 +1139,9 @@ describe('path-store render + scroll', () => { '[data-file-tree-virtualized-sticky-content="true"]' ) ).toBeNull(); + expect(list.dataset.isScrolling).toBe(''); + + await new Promise((resolve) => setTimeout(resolve, 120)); expect(list.dataset.isScrolling).toBeUndefined(); fileTree.cleanUp(); From 752928970bfe1b2126529af641aa23671a991303 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Fri, 17 Apr 2026 11:56:47 -0700 Subject: [PATCH 3/4] polish: add random sticky offset to virtualization --- packages/trees/src/path-store/virtualization.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/trees/src/path-store/virtualization.ts b/packages/trees/src/path-store/virtualization.ts index d53e32c1c..4a666c201 100644 --- a/packages/trees/src/path-store/virtualization.ts +++ b/packages/trees/src/path-store/virtualization.ts @@ -114,6 +114,10 @@ export function computeStickyWindowLayout({ const offsetHeight = range.start * itemHeight; const windowHeight = (range.end - range.start + 1) * itemHeight; + // NOTE: Using a random value that's in the range of 0 to itemHeight, we can + // make fast scrolls don't feel like the elements are stuck on a grid (feels + // artificial) + const randomStickyOffset = (Math.random() * itemHeight) >> 0; return { totalHeight, @@ -121,6 +125,9 @@ export function computeStickyWindowLayout({ windowHeight, // The sticky window is usually taller than the viewport once overscan is // included, so a negative inset keeps the full overscanned slice pinned. - stickyInset: Math.min(0, viewportHeight - windowHeight), + stickyInset: Math.min( + 0, + viewportHeight - windowHeight + randomStickyOffset + ), }; } From 8d23d61ee45142c81657909217ca8732932f7cd5 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Fri, 17 Apr 2026 15:26:26 -0700 Subject: [PATCH 4/4] optional: css changes that may or may not have a positive impact. 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 --- packages/trees/src/style.css | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/trees/src/style.css b/packages/trees/src/style.css index a7c40e57b..5b721ccb8 100644 --- a/packages/trees/src/style.css +++ b/packages/trees/src/style.css @@ -490,6 +490,13 @@ overflow-y: auto; flex: 1 1 0; min-height: 0; + /* NOTE(amadeus): There's a chance in many cases a user could use strict + * here, however not trying to assume, therefore `content` felt like the safest + * bet */ + contain: content; + /* NOTE(amadeus): will-change _may_ help with heavy scroll state, but may + * also not be worth it. */ + will-change: scroll-position; } [data-file-tree-virtualized-list='true'] { @@ -500,7 +507,7 @@ } [data-file-tree-virtualized-sticky-offset='true'] { - contain: layout size; + contain: strict; } [data-file-tree-virtualized-sticky='true'] { @@ -509,6 +516,13 @@ display: flex; flex-direction: column; isolation: isolate; + /* NOTE(amadeus): Strict _may_ help hear with the heavey dom thrash this + * component will have */ + contain: strict; + /* NOTE(amadeus): will-change _may_ help with the noisy virtualized state */ + will-change: contents; + /* NOTE(amadeus): background color here _may_ help with compositing */ + background-color: var(--trees-bg); } [data-file-tree-search-container] {