fix(14-perf): fix scroll jumping and input latency
Root causes addressed:
- track-list had no _itemSize hint for flow layout — virtualizer
defaulted to 100px, measured actual ~33px rows, then called
_correctScrollError/scrollTo on every scroll causing visible jumps
- will-change:transform on virtualizer elements caused nested GPU
layers (virtualizer positions children with transforms internally)
adding compositor overhead instead of helping
- content-visibility:auto on album cards conflicted with virtualizer's
own DOM recycling, causing redundant layout recalculation
- track-list visibilityChanged handler wrote to store synchronously
on every event (per-item during scroll) without any throttling
- IIFE closure in renderTrackRow created a new function per row per render
Fixes applied:
- Add _itemSize:{height:33} + fixed height:33px on .track-row (matches
queue-panel pattern that already worked smoothly)
- Add overflow-anchor:none on track-list virtualizer
- Remove will-change:transform from all 6 scroll containers
- Remove content-visibility:auto from album cards
- RAF-throttle visibilityChanged scroll position saves
- Replace IIFE with direct cols.map() in template
This commit is contained in:
@@ -231,7 +231,6 @@ export class ArtistsView
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
contain: paint;
|
contain: paint;
|
||||||
will-change: transform;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lit-virtualizer {
|
lit-virtualizer {
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ const gridStyles = css`
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
contain: paint;
|
contain: paint;
|
||||||
will-change: transform;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
@@ -149,8 +148,6 @@ const gridStyles = css`
|
|||||||
transform 0.15s ease;
|
transform 0.15s ease;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: var(--card-width, 176px);
|
width: var(--card-width, 176px);
|
||||||
content-visibility: auto;
|
|
||||||
contain-intrinsic-size: auto var(--card-width, 176px) auto calc(var(--card-width, 176px) + 40px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-card:hover {
|
.album-card:hover {
|
||||||
|
|||||||
@@ -235,7 +235,6 @@ export class GenresView
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
contain: paint;
|
contain: paint;
|
||||||
will-change: transform;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lit-virtualizer {
|
lit-virtualizer {
|
||||||
|
|||||||
@@ -308,7 +308,6 @@ export class PlaylistView extends LitElement {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
contain: paint;
|
contain: paint;
|
||||||
will-change: transform;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-item {
|
.playlist-item {
|
||||||
|
|||||||
@@ -302,7 +302,6 @@ export class QueuePanel
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
contain: paint;
|
contain: paint;
|
||||||
will-change: transform;
|
|
||||||
overflow-anchor: none;
|
overflow-anchor: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -195,8 +195,15 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
|||||||
private resizeObserver: ResizeObserver | null =
|
private resizeObserver: ResizeObserver | null =
|
||||||
null;
|
null;
|
||||||
|
|
||||||
private flowLayout = flow();
|
// _itemSize matches the fixed .track-row height (33px) so lit-virtualizer
|
||||||
|
// doesn't need to measure items. Without this hint, the default 100px
|
||||||
|
// estimate causes constant scroll error correction (scrollTo() calls)
|
||||||
|
// that produce visible jumping/skipping during scroll.
|
||||||
|
private flowLayout = flow({
|
||||||
|
_itemSize: { width: 100, height: 33 },
|
||||||
|
} as Parameters<typeof flow>[0]);
|
||||||
private hasRestoredScroll = false;
|
private hasRestoredScroll = false;
|
||||||
|
private scrollSaveRAFId: number | null = null;
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// Filtered / sorted tracks (memoised)
|
// Filtered / sorted tracks (memoised)
|
||||||
@@ -933,7 +940,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
contain: paint;
|
contain: paint;
|
||||||
will-change: transform;
|
overflow-anchor: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-row {
|
.track-row {
|
||||||
@@ -947,6 +954,8 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
height: 33px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-row > * {
|
.track-row > * {
|
||||||
@@ -1227,7 +1236,16 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.libraryCtrl.setScrollPosition('tracks', first);
|
// RAF-throttle scroll position saves — at most once per frame.
|
||||||
|
// Without this, every visibilityChanged (fired per-item during
|
||||||
|
// scroll) writes to the store synchronously, adding main-thread
|
||||||
|
// work during the scroll hot path.
|
||||||
|
if (this.scrollSaveRAFId === null) {
|
||||||
|
this.scrollSaveRAFId = requestAnimationFrame(() => {
|
||||||
|
this.scrollSaveRAFId = null;
|
||||||
|
this.libraryCtrl.setScrollPosition('tracks', first);
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -1640,6 +1658,7 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
|||||||
|
|
||||||
// No inline closures — all events delegated via data-index
|
// No inline closures — all events delegated via data-index
|
||||||
// on the virtualizer element (see firstUpdated).
|
// on the virtualizer element (see firstUpdated).
|
||||||
|
const term = this.searchCtrl.term;
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class=${classMap({
|
class=${classMap({
|
||||||
@@ -1661,26 +1680,23 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
|||||||
variant=${favVariant}
|
variant=${favVariant}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
</div>
|
</div>
|
||||||
${(() => {
|
${cols.map((col) => {
|
||||||
const term = this.searchCtrl.term;
|
const val = col.accessor(track);
|
||||||
return cols.map((col) => {
|
const centered = val === '\u2014';
|
||||||
const val = col.accessor(track);
|
const display = term
|
||||||
const centered = val === '\u2014';
|
? highlightText(val, term)
|
||||||
const display = term
|
: val;
|
||||||
? highlightText(val, term)
|
|
||||||
: val;
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class=${classMap({
|
<div class=${classMap({
|
||||||
cell: true,
|
cell: true,
|
||||||
'cell-center': centered,
|
'cell-center': centered,
|
||||||
'cell-right': !centered && col.align === 'right',
|
'cell-right': !centered && col.align === 'right',
|
||||||
})}>
|
})}>
|
||||||
${display}
|
${display}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
})}
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|||||||
+2
@@ -59,3 +59,5 @@ export function SetContext(arg1:context.Context):Promise<void>;
|
|||||||
export function SetRemovalHooks(arg1:library.RemovalHooks):Promise<void>;
|
export function SetRemovalHooks(arg1:library.RemovalHooks):Promise<void>;
|
||||||
|
|
||||||
export function SetRescanHooks(arg1:library.RescanHooks):Promise<void>;
|
export function SetRescanHooks(arg1:library.RescanHooks):Promise<void>;
|
||||||
|
|
||||||
|
export function SoftScanAllLibraries():Promise<void>;
|
||||||
|
|||||||
@@ -113,3 +113,7 @@ export function SetRemovalHooks(arg1) {
|
|||||||
export function SetRescanHooks(arg1) {
|
export function SetRescanHooks(arg1) {
|
||||||
return window['go']['library']['Library']['SetRescanHooks'](arg1);
|
return window['go']['library']['Library']['SetRescanHooks'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SoftScanAllLibraries() {
|
||||||
|
return window['go']['library']['Library']['SoftScanAllLibraries']();
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export namespace library {
|
|||||||
export class ScanWarning {
|
export class ScanWarning {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
phase: string;
|
phase: string;
|
||||||
err: any;
|
err: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ScanWarning(source);
|
return new ScanWarning(source);
|
||||||
|
|||||||
Reference in New Issue
Block a user