From dad223d4ad7e9d369db3fb1d6bfa38a857d3762b Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Thu, 23 Jul 2026 00:08:38 -0400 Subject: [PATCH] fix(controller): reset shortcut target after navigation --- extension/content/inject.js | 11 ++++++- tests/inject-lifecycle.regression.test.js | 38 +++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/extension/content/inject.js b/extension/content/inject.js index ff71620..0576437 100644 --- a/extension/content/inject.js +++ b/extension/content/inject.js @@ -4557,7 +4557,16 @@ function attachMediaDetectionListeners(root) { } function scheduleNavigationRescan() { - window.vscLastObservedHref = location.href; + var nextHref = location.href; + if (window.vscLastObservedHref !== nextHref) { + // Pointer and controller interaction state belongs to the previous route. + // YouTube keeps hover-preview and Shorts players connected across SPA + // navigations, so retaining either target can send shortcuts to an + // off-screen/stale player even though the new watch controller works. + tc.lastPointerPosition = null; + tc.lastInteractedMedia = null; + } + window.vscLastObservedHref = nextHref; clearTimeout(window.vscNavigationRescanTimer); window.vscNavigationRescanTimer = setTimeout(function() { initializeWhenReady(document, true); diff --git a/tests/inject-lifecycle.regression.test.js b/tests/inject-lifecycle.regression.test.js index 4c31e3c..1f422c8 100644 --- a/tests/inject-lifecycle.regression.test.js +++ b/tests/inject-lifecycle.regression.test.js @@ -309,6 +309,44 @@ describe("inject.js media/controller lifecycle regressions", () => { expect(second.video.playbackRate).toBe(1.2); }); + it("drops a stale hover-preview shortcut target after SPA navigation", async () => { + bootInject({ + url: "https://www.youtube.com/", + path: "/" + }); + await settleLifecycle(); + + const preview = createControlledVideo({ + src: "blob:https://www.youtube.com/hover-preview", + mountRect: makeRect(0, 0, 320, 180) + }); + document.dispatchEvent( + new MouseEvent("mousemove", { bubbles: true, clientX: 100, clientY: 90 }) + ); + expect(window.tc.lastPointerPosition).toEqual( + expect.objectContaining({ document, x: 100, y: 90 }) + ); + + window.history.pushState({}, "", "/watch?v=next-video"); + + const main = createControlledVideo({ + src: "blob:https://www.youtube.com/main-player", + mountRect: makeRect(0, 0, 1280, 720) + }); + document.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + code: "KeyD", + key: "d" + }) + ); + + expect(window.tc.lastPointerPosition).toBeNull(); + expect(preview.video.playbackRate).toBe(1); + expect(main.video.playbackRate).toBe(1.1); + }); + it("skips ambient loops by default and includes them when explicitly enabled", async () => { bootInject(); await settleLifecycle();