From e563749ace76742eda3d4d891be4ee1e628df00f Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:11:56 +0200 Subject: [PATCH] fix(queue): stop re-walking the whole queue on every track change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QueueHeader aggregated total/future duration in a useMemo keyed on queueIndex, so every skip ran a synchronous O(n) pass over the entire queue (resolveQueueTrack per item). On very large queues this blocked the main thread for seconds — the UI froze on skip and on the device-switch playTrack fallback (#1072; the freeze half of #1090). QueuePanel is always mounted, so it hit even with the queue collapsed. Build a cumulative-duration prefix keyed on queue/resolver-version only; the future-tracks total is now an O(1) lookup per skip. Display output unchanged. (cherry picked from commit 7e91a5b2a112e76829a9d483ea08186489f0826c) --- src/components/queuePanel/QueueHeader.tsx | 32 ++++++++++++++--------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/components/queuePanel/QueueHeader.tsx b/src/components/queuePanel/QueueHeader.tsx index 10014966..05d0dbd4 100644 --- a/src/components/queuePanel/QueueHeader.tsx +++ b/src/components/queuePanel/QueueHeader.tsx @@ -44,23 +44,31 @@ export function QueueHeader({ // H1 mitigation: a mass-resolve burst (queue restore, prefetch window slide) // bumps `version` dozens of times in one frame; useDeferredValue coalesces // the burst into a single low-priority commit so long queues do not block - // the main thread on every cache tick. The aggregation itself is a single - // pass — one loop produces both totals so a 50k-track queue costs one walk, - // not two. + // the main thread on every cache tick. + // + // The O(n) walk is keyed on `queue`/`deferredVersion` only — NOT `queueIndex`. + // A skip moves only `queueIndex`, so it must not re-walk the whole queue: that + // synchronous O(n) pass on every track change froze the UI for seconds on very + // large queues (#1072, and the device-switch fallback in #1090). Instead we + // build a cumulative-duration prefix (`cumSecs[i]` = summed duration of tracks + // [0, i)) once per queue/cache change, then derive the future total as an O(1) + // lookup below. A 50k-track queue costs one walk per queue/cache change, zero + // per track change. const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion); const deferredVersion = useDeferredValue(version); - const { totalSecs, futureTracksDuration } = useMemo(() => { - if (queue.length === 0) return { totalSecs: 0, futureTracksDuration: 0 }; - let total = 0; - let future = 0; + const { totalSecs, cumSecs } = useMemo(() => { + const cum = new Float64Array(queue.length + 1); for (let i = 0; i < queue.length; i += 1) { - const dur = resolveQueueTrack(queue[i]).duration || 0; - total += dur; - if (i > queueIndex) future += dur; + cum[i + 1] = cum[i] + (resolveQueueTrack(queue[i]).duration || 0); } - return { totalSecs: total, futureTracksDuration: future }; + return { totalSecs: cum[queue.length], cumSecs: cum }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [queue, queueIndex, deferredVersion]); + }, [queue, deferredVersion]); + // Tracks strictly after the current index — O(1) per skip. + const futureTracksDuration = Math.max( + 0, + totalSecs - cumSecs[Math.min(queueIndex + 1, queue.length)], + ); const currentDuration = queue[queueIndex] ? resolveQueueTrack(queue[queueIndex]).duration : 0; const remainingSecs = Math.max(0, (currentDuration ?? 0) - currentTime + futureTracksDuration);