From c0bd9972874cd77af3bfc46566456e964b89fe1a Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Thu, 16 Jul 2026 01:44:34 -0400 Subject: [PATCH] fix(performance): bound media discovery work --- extension/content/inject.js | 119 +++++++++++----------- extension/options/options.html | 14 +-- extension/options/options.js | 6 +- extension/shared/settings-core.js | 10 +- tests/inject-lifecycle.regression.test.js | 27 ++++- tests/inject.test.js | 4 +- tests/options.integration.test.js | 4 +- tests/settings-core.spec.js | 4 +- 8 files changed, 109 insertions(+), 79 deletions(-) diff --git a/extension/content/inject.js b/extension/content/inject.js index 83952ac..2cd6d06 100644 --- a/extension/content/inject.js +++ b/extension/content/inject.js @@ -128,7 +128,7 @@ var tc = { "subtitleNudgeEnabledByDefault", true ), - subtitleNudgeInterval: getSharedDefault("subtitleNudgeInterval", 50), + subtitleNudgeInterval: getSharedDefault("subtitleNudgeInterval", 250), subtitleNudgeAmount: getSharedDefault("subtitleNudgeAmount", 0.001), customButtonIcons: {} }, @@ -161,6 +161,7 @@ var YT_NATIVE_MIN = 0.25; var YT_NATIVE_MAX = 2.0; var YT_NATIVE_STEP = 0.05; var vscObservedRoots = new WeakSet(); +var vscObservedRootList = []; var vscConnectedScannedRoots = new WeakSet(); var vscInitializedDocuments = new WeakSet(); var vscSourceObjectIds = new WeakMap(); @@ -1608,21 +1609,6 @@ function scanNodeForMedia(node, parent, added) { observeRoot(node.shadowRoot); } - // Deep-scan descendant elements for shadow roots we haven't observed yet. - // This catches custom elements (like archive.org's ) whose shadow - // roots were created before our attachShadow patch was installed. - if (added && typeof node.querySelectorAll === "function") { - try { - var allElements = node.querySelectorAll("*"); - for (var j = 0; j < allElements.length; j++) { - if (allElements[j].shadowRoot && !vscObservedRoots.has(allElements[j].shadowRoot)) { - observeRoot(allElements[j].shadowRoot); - } - } - } catch (e) { - // querySelectorAll may throw on detached or unusual nodes - } - } } function getScanNodeForRoot(root) { @@ -1683,6 +1669,24 @@ function rescanOpenShadowRoots(root, visited, rescanObserved) { }); } +function rescanObservedMediaRoots(doc) { + var retainedRoots = []; + vscObservedRootList.forEach(function(root) { + if (!root || root.nodeType === Node.DOCUMENT_NODE) return; + var connected = Boolean(root.host ? root.host.isConnected : root.isConnected); + if (!connected) return; + retainedRoots.push(root); + if ( + !doc || + root.ownerDocument === doc || + (root.host && root.host.ownerDocument === doc) + ) { + scanRootForMedia(root); + } + }); + vscObservedRootList = retainedRoots; +} + function observeRoot(root) { if (!root) return; @@ -1698,6 +1702,7 @@ function observeRoot(root) { if (!vscObservedRoots.has(root)) { vscObservedRoots.add(root); + vscObservedRootList.push(root); setupListener(root); attachMutationObserver(root); attachMediaDetectionListeners(root); @@ -1765,24 +1770,34 @@ function installPageShadowBridge() { ); } - function startPersistentShadowFallback() { - if (window.vscPersistentShadowFallbackTimer) return; - window.vscPersistentShadowFallbackTimer = setInterval(function() { - requestIdle( - function() { - if (!document.body) return; - rescanOpenShadowRoots(document, undefined, false); - }, - { timeout: 2000 } - ); - }, 30000); + function startBoundedShadowFallback() { + if (window.vscBoundedShadowFallbackStarted) return; + window.vscBoundedShadowFallbackStarted = true; + window.vscBoundedShadowFallbackTimers = [3000, 10000, 30000].map( + function(delay) { + return setTimeout(function() { + requestIdle( + function() { + if ( + !document.body || + (delay !== 3000 && window.vscPageShadowBridgeLoaded) + ) { + return; + } + rescanOpenShadowRoots(document, undefined, false); + }, + { timeout: 2000 } + ); + }, delay); + } + ); } function handleBridgeFailure() { window.vscPageShadowBridgeRequested = false; window.vscPageShadowBridgeRetries = (Number(window.vscPageShadowBridgeRetries) || 0) + 1; - startPersistentShadowFallback(); + startBoundedShadowFallback(); if (window.vscPageShadowBridgeRetries > 3) return; clearTimeout(window.vscPageShadowBridgeRetryTimer); window.vscPageShadowBridgeRetryTimer = setTimeout(function() { @@ -1815,10 +1830,6 @@ function installPageShadowBridge() { window.vscPageShadowBridgeLoaded = true; window.vscPageShadowBridgeRetries = 0; clearTimeout(window.vscPageShadowBridgeRetryTimer); - if (window.vscPersistentShadowFallbackTimer) { - clearInterval(window.vscPersistentShadowFallbackTimer); - window.vscPersistentShadowFallbackTimer = null; - } }); bridge.addEventListener("error", function() { bridge.remove(); @@ -1826,6 +1837,9 @@ function installPageShadowBridge() { }); window.vscPageShadowBridgeRequested = true; parent.appendChild(bridge); + // The bridge reports future roots, but cannot report roots that existed + // before the extension loaded. Keep one bounded initial discovery pass. + startBoundedShadowFallback(); } catch (error) { handleBridgeFailure(); log(`Unable to install page shadow bridge: ${error.message}`, 3); @@ -1956,7 +1970,7 @@ function hydrateRuntimeSettings(rawStorage, options) { : getSharedDefault("subtitleNudgeEnabledByDefault", true); tc.settings.subtitleNudgeInterval = Math.min( 1000, - Math.max(10, Number(storage.subtitleNudgeInterval) || 50) + Math.max(250, Number(storage.subtitleNudgeInterval) || 250) ); tc.settings.subtitleNudgeAmount = Number(storage.subtitleNudgeAmount) || @@ -2810,7 +2824,7 @@ function setupControllerHostTracking(videoController, wrapper, mount) { var frameId = null; var geometryRetryTimer = null; var geometryRetryAttempts = 0; - var maxGeometryRetryAttempts = 40; + var maxGeometryRetryAttempts = 12; var update = function() { frameId = null; positionControllerHost(wrapper, videoController.video, mount); @@ -4241,9 +4255,11 @@ function attachMutationObserver(root) { function() { var mutationsToProcess = pendingMutations.splice(0); mutationProcessingScheduled = false; + var controllerMountTargets = new Set(); mutationsToProcess.forEach(function(mutation) { if (mutation.type === "childList") { + controllerMountTargets.add(mutation.target); mutation.addedNodes.forEach(function(node) { // Skip text nodes, comments, etc. — only elements can contain media if (node.nodeType !== Node.ELEMENT_NODE) return; @@ -4254,14 +4270,6 @@ function attachMutationObserver(root) { scanNodeForMedia(node, node.parentNode || mutation.target, false); }); - // Player gesture/click panes are frequently inserted after the - // video. Keep equal-z-index controller hosts last, and repair a - // host that the site removed independently of its media element. - tc.mediaElements.slice().forEach(function(video) { - if (!video || !video.vsc || !video.vsc.div) return; - if (video.vsc.controllerHostMount !== mutation.target) return; - remountControllerHost(video.vsc, mutation.target); - }); return; } @@ -4299,6 +4307,17 @@ function attachMutationObserver(root) { } }); + // Reconcile once per batch, not once per mutation. Large news/social + // pages can emit hundreds of childList records in one render pass. + if (controllerMountTargets.size > 0) { + tc.mediaElements.slice().forEach(function(video) { + if (!video || !video.vsc || !video.vsc.div) return; + var mount = video.vsc.controllerHostMount; + if (!controllerMountTargets.has(mount)) return; + remountControllerHost(video.vsc, mount); + }); + } + // Document selectors do not cross shadow boundaries. A detached // custom-element host can therefore hide connected-looking media from // the removed subtree scan; prune by composed connectivity as a final @@ -4414,26 +4433,12 @@ function initializeNow(doc, forceReinit = false) { attachKeydownListeners(doc); attachMediaTargetTracking(doc); - // Bounded fallback scans catch open page-world roots if a site's CSP blocked - // the bridge script. The event bridge handles the normal ongoing path. - if (!doc.vscDelayedShadowScanDone) { - doc.vscDelayedShadowScanDone = true; - [3000, 10000, 30000].forEach(function(delay) { - setTimeout(function() { - if (!doc.body) return; - try { - rescanOpenShadowRoots(doc); - } catch (e) {} - }, delay); - }); - } - if (forceReinit) { log("Force re-initialization requested", 4); // A root is normally scanned only once. A user-requested rescan must also // revisit media that was present but source-less during the initial scan. scanRootForMedia(doc); - rescanOpenShadowRoots(doc); + rescanObservedMediaRoots(doc); refreshAllControllerGeometry(); if (tc.settings.rememberSpeed || tc.settings.forceLastSavedSpeed) { tc.mediaElements.slice().forEach(applyRememberedSpeedPolicy); diff --git a/extension/options/options.html b/extension/options/options.html index 3794b21..5619026 100644 --- a/extension/options/options.html +++ b/extension/options/options.html @@ -427,15 +427,15 @@
@@ -909,12 +909,12 @@
- +
diff --git a/extension/options/options.js b/extension/options/options.js index 3ce9d8d..b609b6d 100644 --- a/extension/options/options.js +++ b/extension/options/options.js @@ -878,8 +878,8 @@ function save_options() { parseInt(document.getElementById("subtitleNudgeInterval").value, 10) || tcDefaults.subtitleNudgeInterval; - if (settings.subtitleNudgeInterval < 10) { - settings.subtitleNudgeInterval = 10; + if (settings.subtitleNudgeInterval < 250) { + settings.subtitleNudgeInterval = 250; } if (settings.subtitleNudgeInterval > 1000) { settings.subtitleNudgeInterval = 1000; @@ -1003,7 +1003,7 @@ function save_options() { rule.subtitleNudgeInterval = Math.min( 1000, Math.max( - 10, + 250, Number.isFinite(nudgeIv) ? nudgeIv : settings.subtitleNudgeInterval ) ); diff --git a/extension/shared/settings-core.js b/extension/shared/settings-core.js index 01ce88f..4ebe557 100644 --- a/extension/shared/settings-core.js +++ b/extension/shared/settings-core.js @@ -149,9 +149,7 @@ { title: "YouTube videos", pattern: "/^https:\\/\\/(www\\.)?youtube\\.com\\/(?!shorts\\/).*/", - enabled: true, - enableSubtitleNudge: true, - subtitleNudgeInterval: 50 + enabled: true }, { title: "YouTube Shorts", @@ -176,7 +174,7 @@ popupControllerButtons: DEFAULT_BUTTONS.slice(), enableSubtitleNudge: false, subtitleNudgeEnabledByDefault: true, - subtitleNudgeInterval: 50, + subtitleNudgeInterval: 250, subtitleNudgeAmount: 0.001 }; @@ -359,7 +357,7 @@ return clampFiniteNumber(value, 0, 200, undefined); } if (key === "subtitleNudgeInterval") { - return clampFiniteNumber(value, 10, 1000, undefined); + return clampFiniteNumber(value, 250, 1000, undefined); } if (key === "preferredSpeed") { return clampFiniteNumber(value, 0.0625, 16, undefined); @@ -914,7 +912,7 @@ }); expanded.subtitleNudgeInterval = clampFiniteNumber( expanded.subtitleNudgeInterval, - 10, + 250, 1000, DEFAULT_SETTINGS.subtitleNudgeInterval ); diff --git a/tests/inject-lifecycle.regression.test.js b/tests/inject-lifecycle.regression.test.js index e58c583..2a6af24 100644 --- a/tests/inject-lifecycle.regression.test.js +++ b/tests/inject-lifecycle.regression.test.js @@ -154,7 +154,7 @@ describe("inject.js media/controller lifecycle regressions", () => { expect(shadowEvents).toBe(1); }); - it("retries a blocked page bridge and keeps a persistent shadow fallback", async () => { + it("retries a blocked page bridge with bounded shadow fallback scans", async () => { vi.useFakeTimers(); bootInject(); const firstBridge = document.querySelector( @@ -165,7 +165,9 @@ describe("inject.js media/controller lifecycle regressions", () => { firstBridge.dispatchEvent(new Event("error")); expect(window.vscPageShadowBridgeRequested).toBe(false); - expect(window.vscPersistentShadowFallbackTimer).not.toBeNull(); + expect(window.vscBoundedShadowFallbackStarted).toBe(true); + expect(window.vscBoundedShadowFallbackTimers).toHaveLength(3); + expect(window.vscPersistentShadowFallbackTimer).toBeUndefined(); await vi.advanceTimersByTimeAsync(250); expect( document.querySelector('script[src$="content/shadow-bridge.js"]') @@ -513,6 +515,27 @@ describe("inject.js media/controller lifecycle regressions", () => { expect(mountA.querySelector(".vsc-controller")).toBeNull(); }); + it("reconciles a burst of player mutations only once per idle batch", async () => { + bootInject(); + await settleLifecycle(); + + const { mount, wrapper } = createControlledVideo(); + const originalAppendChild = mount.appendChild.bind(mount); + let controllerReorders = 0; + mount.appendChild = function(node) { + if (node === wrapper) controllerReorders += 1; + return originalAppendChild(node); + }; + + for (let index = 0; index < 25; index += 1) { + mount.appendChild(document.createElement("span")); + } + await settleLifecycle(8); + + expect(controllerReorders).toBe(1); + expect(mount.lastElementChild).toBe(wrapper); + }); + it("suppresses a zero-size host and reveals it when the video becomes visible", async () => { bootInject(); await settleLifecycle(); diff --git a/tests/inject.test.js b/tests/inject.test.js index d52d344..bee0de8 100644 --- a/tests/inject.test.js +++ b/tests/inject.test.js @@ -250,7 +250,9 @@ describe("inject runtime", () => { loadScript("extension/content/inject.js"); // Fast-forward 3000ms for delayed rescan to trigger - vi.advanceTimersByTime(3000); + await vi.advanceTimersByTimeAsync(3000); + // The bounded discovery pass yields to an idle callback before traversing. + await vi.advanceTimersByTimeAsync(2000); for (let i = 0; i < 5; i += 1) { await flushAsyncWork(); diff --git a/tests/options.integration.test.js b/tests/options.integration.test.js index f36acfa..1d95f8d 100644 --- a/tests/options.integration.test.js +++ b/tests/options.integration.test.js @@ -236,7 +236,7 @@ describe("options page", () => { expect(savedSettings.controllerMarginTop).toBe(200); expect(savedSettings.controllerMarginBottom).toBe(0); expect(savedSettings.subtitleNudgeEnabledByDefault).toBe(false); - expect(savedSettings.subtitleNudgeInterval).toBe(10); + expect(savedSettings.subtitleNudgeInterval).toBe(250); expect(savedSettings.showPopupControlBar).toBe(false); expect(savedSettings.popupMatchHoverControls).toBe(false); expect(savedSettings.popupControllerButtons).toEqual(["rewind", "faster"]); @@ -251,7 +251,7 @@ describe("options page", () => { controllerOpacity: 0, enableSubtitleNudge: true, subtitleNudgeEnabledByDefault: false, - subtitleNudgeInterval: 75, + subtitleNudgeInterval: 250, showPopupControlBar: false, popupControllerButtons: ["advance"] }) diff --git a/tests/settings-core.spec.js b/tests/settings-core.spec.js index 878e5e4..bdad22d 100644 --- a/tests/settings-core.spec.js +++ b/tests/settings-core.spec.js @@ -34,6 +34,8 @@ describe("canonical settings storage", () => { }) ); expect(settings.shortcutTargetMode).toBe("closest"); + expect(settings.subtitleNudgeInterval).toBe(250); + expect(settings.siteRules[0].enableSubtitleNudge).toBeUndefined(); }); it("round-trips global and site-specific shortcut targeting", () => { @@ -228,7 +230,7 @@ describe("canonical settings storage", () => { expect(rule.rememberSpeed).toBe(false); expect(rule.forceLastSavedSpeed).toBe(true); expect(rule.controllerOpacity).toBe(1); - expect(rule.subtitleNudgeInterval).toBe(10); + expect(rule.subtitleNudgeInterval).toBe(250); expect(rule.preferredSpeed).toBe(16); });