diff --git a/extension/src/lib/__tests__/subtree-watcher.test.ts b/extension/src/lib/__tests__/subtree-watcher.test.ts index 4c15768..2782dc6 100644 --- a/extension/src/lib/__tests__/subtree-watcher.test.ts +++ b/extension/src/lib/__tests__/subtree-watcher.test.ts @@ -10,7 +10,10 @@ import { PLACEHOLDER_CLASS } from "../placeholder"; import { __resetRouteChangeForTesting } from "../route-change"; -import { createSubtreeWatcher } from "../subtree-watcher"; +import { + __resetSubtreeWatcherForTesting, + createSubtreeWatcher, +} from "../subtree-watcher"; const THROTTLE_MS = 250; @@ -22,6 +25,7 @@ beforeEach(() => { document.body.innerHTML = ""; jest.useFakeTimers(); __resetRouteChangeForTesting(); + __resetSubtreeWatcherForTesting(); history.replaceState(null, "", "/initial"); }); @@ -33,6 +37,7 @@ afterEach(() => { value: false, }); __resetRouteChangeForTesting(); + __resetSubtreeWatcherForTesting(); }); describe("createSubtreeWatcher", () => { @@ -715,4 +720,264 @@ describe("createSubtreeWatcher", () => { watcher.stop(); }); }); + + describe("shared mutation router", () => { + // The router collapses N watchers on the same root into one + // MutationObserver. These tests pin down that the fan-out preserves + // per-subscriber options and that router lifecycle tracks the last + // subscriber. + + function setHidden(hidden: boolean): void { + Object.defineProperty(document, "hidden", { + configurable: true, + value: hidden, + }); + document.dispatchEvent(new Event("visibilitychange")); + } + + it("fans out a single mutation to every watcher on the same root", async () => { + const a = jest.fn(); + const b = jest.fn(); + const watcherA = createSubtreeWatcher({ onSubtrees: a }); + const watcherB = createSubtreeWatcher({ onSubtrees: b }); + watcherA.start(document.body); + watcherB.start(document.body); + + const div = document.createElement("div"); + document.body.append(div); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + const [aRoots] = a.mock.calls[0] as [Element[]]; + const [bRoots] = b.mock.calls[0] as [Element[]]; + expect(aRoots).toEqual([div]); + expect(bRoots).toEqual([div]); + + watcherA.stop(); + watcherB.stop(); + }); + + it("instantiates one MutationObserver for two watchers on the same root", () => { + const constructorSpy = jest.fn(); + const RealMutationObserver = globalThis.MutationObserver; + class CountingObserver extends RealMutationObserver { + constructor(callback: MutationCallback) { + super(callback); + constructorSpy(); + } + } + globalThis.MutationObserver = CountingObserver; + + try { + const watcherA = createSubtreeWatcher({ onSubtrees: jest.fn() }); + const watcherB = createSubtreeWatcher({ onSubtrees: jest.fn() }); + watcherA.start(document.body); + watcherB.start(document.body); + + expect(constructorSpy).toHaveBeenCalledTimes(1); + + watcherA.stop(); + watcherB.stop(); + } finally { + globalThis.MutationObserver = RealMutationObserver; + } + }); + + it("keeps per-subscriber skipPlaceholderSubtrees independent", async () => { + const skipping = jest.fn(); + const seeing = jest.fn(); + const watcherSkip = createSubtreeWatcher({ + onSubtrees: skipping, + skipPlaceholderSubtrees: true, + }); + const watcherSee = createSubtreeWatcher({ onSubtrees: seeing }); + watcherSkip.start(document.body); + watcherSee.start(document.body); + + const placeholder = document.createElement("div"); + placeholder.classList.add(PLACEHOLDER_CLASS); + document.body.append(placeholder); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + // Skipping subscriber drops the placeholder; the other still gets it. + expect(skipping).not.toHaveBeenCalled(); + expect(seeing).toHaveBeenCalledTimes(1); + + watcherSkip.stop(); + watcherSee.stop(); + }); + + it("respects independent throttleMs per subscriber", async () => { + const fast = jest.fn(); + const slow = jest.fn(); + const watcherFast = createSubtreeWatcher({ + onSubtrees: fast, + throttleMs: 100, + }); + const watcherSlow = createSubtreeWatcher({ + onSubtrees: slow, + throttleMs: 1000, + }); + watcherFast.start(document.body); + watcherSlow.start(document.body); + + document.body.append(document.createElement("div")); + await flushMutations(); + + jest.advanceTimersByTime(150); + // Fast window closed; slow one still open. + expect(fast).toHaveBeenCalledTimes(1); + expect(slow).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1000); + expect(slow).toHaveBeenCalledTimes(1); + + watcherFast.stop(); + watcherSlow.stop(); + }); + + it("keeps observing after one of two watchers on a root stops", async () => { + const a = jest.fn(); + const b = jest.fn(); + const watcherA = createSubtreeWatcher({ onSubtrees: a }); + const watcherB = createSubtreeWatcher({ onSubtrees: b }); + watcherA.start(document.body); + watcherB.start(document.body); + + watcherA.stop(); + + const div = document.createElement("div"); + document.body.append(div); + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalledTimes(1); + watcherB.stop(); + }); + + it("stops observing once the last watcher on a root stops", async () => { + const a = jest.fn(); + const b = jest.fn(); + const watcherA = createSubtreeWatcher({ onSubtrees: a }); + const watcherB = createSubtreeWatcher({ onSubtrees: b }); + watcherA.start(document.body); + watcherB.start(document.body); + watcherA.stop(); + watcherB.stop(); + + document.body.append(document.createElement("div")); + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(a).not.toHaveBeenCalled(); + expect(b).not.toHaveBeenCalled(); + }); + + it("treats Document and document.body as the same router target", async () => { + // Both call sites should share one observer because resolveTarget + // collapses Document → its body. + const constructorSpy = jest.fn(); + const RealMutationObserver = globalThis.MutationObserver; + class CountingObserver extends RealMutationObserver { + constructor(callback: MutationCallback) { + super(callback); + constructorSpy(); + } + } + globalThis.MutationObserver = CountingObserver; + + try { + const a = jest.fn(); + const b = jest.fn(); + const watcherA = createSubtreeWatcher({ onSubtrees: a }); + const watcherB = createSubtreeWatcher({ onSubtrees: b }); + watcherA.start(document); + watcherB.start(document.body); + + expect(constructorSpy).toHaveBeenCalledTimes(1); + + document.body.append(document.createElement("div")); + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + + watcherA.stop(); + watcherB.stop(); + } finally { + globalThis.MutationObserver = RealMutationObserver; + } + }); + + it("uses separate routers for distinct roots (body vs. head)", async () => { + const bodyCallback = jest.fn(); + const headCallback = jest.fn(); + const bodyWatcher = createSubtreeWatcher({ onSubtrees: bodyCallback }); + const headWatcher = createSubtreeWatcher({ onSubtrees: headCallback }); + bodyWatcher.start(document.body); + headWatcher.start(document.head); + + const meta = document.createElement("meta"); + document.head.append(meta); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(headCallback).toHaveBeenCalledTimes(1); + expect(bodyCallback).not.toHaveBeenCalled(); + + bodyWatcher.stop(); + headWatcher.stop(); + }); + + it("fans out a route-change sweep to every subscriber on the same root", () => { + const a = jest.fn(); + const b = jest.fn(); + const watcherA = createSubtreeWatcher({ onSubtrees: a }); + const watcherB = createSubtreeWatcher({ onSubtrees: b }); + watcherA.start(document.body); + watcherB.start(document.body); + + history.replaceState(null, "", "/route-b"); + globalThis.dispatchEvent(new Event("popstate")); + jest.advanceTimersToNextFrame(); + + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + const [aRoots] = a.mock.calls[0] as [Element[]]; + const [bRoots] = b.mock.calls[0] as [Element[]]; + expect(aRoots).toEqual([document.body]); + expect(bRoots).toEqual([document.body]); + + watcherA.stop(); + watcherB.stop(); + }); + + it("flushes every subscriber's pending on visibilitychange to hidden", async () => { + const a = jest.fn(); + const b = jest.fn(); + const watcherA = createSubtreeWatcher({ onSubtrees: a }); + const watcherB = createSubtreeWatcher({ onSubtrees: b }); + watcherA.start(document.body); + watcherB.start(document.body); + + document.body.append(document.createElement("div")); + await flushMutations(); + expect(a).not.toHaveBeenCalled(); + expect(b).not.toHaveBeenCalled(); + + setHidden(true); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + + watcherA.stop(); + watcherB.stop(); + }); + }); }); diff --git a/extension/src/lib/subtree-watcher.ts b/extension/src/lib/subtree-watcher.ts index 904b43f..a6d30e8 100644 --- a/extension/src/lib/subtree-watcher.ts +++ b/extension/src/lib/subtree-watcher.ts @@ -7,6 +7,13 @@ // // Each rule constructs one watcher at module scope and toggles it on/off via // start() / stop() from its apply / teardown. +// +// All watchers that observe the same root share one MutationObserver, one +// visibilitychange listener, and one route-change subscription via a +// module-level router keyed by the observed node. With 24 rules all watching +// document.body, the browser fires one mutation callback that fans out to 24 +// subscribers — instead of 24 separate observers each running the same +// `IGNORE_TAGS` / `isConnected` filtering for every record. import throttle from "lodash/throttle"; import { PLACEHOLDER_CLASS } from "./placeholder"; @@ -46,54 +53,77 @@ export interface SubtreeWatcher { stop(): void; } -export function createSubtreeWatcher( - options: SubtreeWatcherOptions, -): SubtreeWatcher { - const { - onSubtrees, - throttleMs = 250, - skipPlaceholderSubtrees = false, - } = options; +interface Subscriber { + onSubtrees: (roots: Element[]) => void; + skipPlaceholderSubtrees: boolean; + throttledScan: ReturnType; + pending: Set; +} - let observer: MutationObserver | null = null; - let throttledScan: ReturnType | null = null; - let observedTarget: Node | null = null; - let visibilityListener: (() => void) | null = null; - let unsubscribeRouteChange: (() => void) | null = null; - let routeSweepHandle: number | null = null; - const pending = new Set(); +interface Router { + target: Node; + observer: MutationObserver | null; + subscribers: Set; + visibilityListener: (() => void) | null; + unsubscribeRouteChange: (() => void) | null; + routeSweepHandle: number | null; +} - function drain(): void { - if (pending.size === 0) { - return; - } - const roots = [...pending].filter((root) => root.isConnected); - pending.clear(); - if (roots.length > 0) { - onSubtrees(roots); - } +// One router per observed root. document.body is the common case; the head +// gets its own entry because meta-injection-strip observes it separately. +const routersByTarget = new Map(); + +function resolveTarget(root: ParentNode): Node | null { + // rule-engine always passes document.body, but accept Document for + // robustness and resolve to its body. `Document.body` is typed as + // non-null, but iframe edge cases at document_idle can leave it + // missing — guard rather than trust the type. + const node = root as Node; + if (node.nodeType === Node.DOCUMENT_NODE) { + const body = (root as Document).body; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return body ?? null; } + return node; +} - function enqueue(mutations: MutationRecord[]): void { - for (const mutation of mutations) { - for (const added of mutation.addedNodes) { - if (added.nodeType !== Node.ELEMENT_NODE) { - continue; - } - const element = added as Element; - if (IGNORE_TAGS.has(element.tagName)) { - continue; - } - // React-style reconciliation routinely adds-then-removes a node in - // the same tick. By the time MutationObserver fires (microtask - // afterward), the addition record is still there but the node is - // already detached. drain() filters by isConnected too — checking - // at enqueue avoids buffering churn in pending during a 5k-node - // route swap with high in-tick removal rate. - if (!element.isConnected) { - continue; - } - if (skipPlaceholderSubtrees) { +function drainSubscriber(subscriber: Subscriber): void { + if (subscriber.pending.size === 0) { + return; + } + const roots = [...subscriber.pending].filter((root) => root.isConnected); + subscriber.pending.clear(); + if (roots.length > 0) { + subscriber.onSubtrees(roots); + } +} + +function fanOut(router: Router, mutations: MutationRecord[]): void { + // Walk every added node once and dispatch into each subscriber's pending + // set. The shared filters (nodeType, IGNORE_TAGS, isConnected) run once + // per node regardless of subscriber count — the whole point of the + // router. Per-subscriber filters (skipPlaceholderSubtrees) still run + // per (node, subscriber) pair, but they're cheap classlist reads. + for (const mutation of mutations) { + for (const added of mutation.addedNodes) { + if (added.nodeType !== Node.ELEMENT_NODE) { + continue; + } + const element = added as Element; + if (IGNORE_TAGS.has(element.tagName)) { + continue; + } + // React-style reconciliation routinely adds-then-removes a node in + // the same tick. By the time MutationObserver fires (microtask + // afterward), the addition record is still there but the node is + // already detached. drainSubscriber() filters by isConnected too — + // checking at enqueue avoids buffering churn in pending during a + // 5k-node route swap with high in-tick removal rate. + if (!element.isConnected) { + continue; + } + for (const subscriber of router.subscribers) { + if (subscriber.skipPlaceholderSubtrees) { if (element.classList.contains(PLACEHOLDER_CLASS)) { continue; } @@ -101,121 +131,210 @@ export function createSubtreeWatcher( continue; } } - pending.add(element); + subscriber.pending.add(element); } } - if (pending.size === 0) { - return; + } + + for (const subscriber of router.subscribers) { + if (subscriber.pending.size === 0) { + continue; } - if (pending.size >= BURST_FLUSH_THRESHOLD) { - // Cancel the pending trailing call and drain right now. drain() guards - // its own empty case, so an in-flight throttle that fires later is a - // no-op. - throttledScan?.cancel(); - drain(); - return; + if (subscriber.pending.size >= BURST_FLUSH_THRESHOLD) { + // Cancel the trailing throttle call and drain right now. drainSubscriber() + // guards its own empty case, so an in-flight throttle that fires later + // is a no-op. + subscriber.throttledScan.cancel(); + drainSubscriber(subscriber); + } else { + subscriber.throttledScan(); } - throttledScan?.(); } +} - function handleRouteChange(): void { - if (!observer) { +function handleVisibilityChange(router: Router): void { + if (document.hidden) { + // Flush whatever's pending so we don't sit on a stale snapshot until + // the user returns, then stop receiving mutations. Background tabs + // keep firing observer callbacks; disconnecting is the cheap way to + // opt out for the duration. + for (const subscriber of router.subscribers) { + subscriber.throttledScan.flush(); + } + router.observer?.disconnect(); + } else if (router.observer) { + router.observer.observe(router.target, { childList: true, subtree: true }); + } +} + +function handleRouteChange(router: Router): void { + if (!router.observer) { + return; + } + // Cancel anything pending — the new route's content will arrive in the + // next render and we want the user-visible hide to happen against the + // new tree, not as a tail of the old throttle window. Discard buffered + // MutationRecords for the same reason: they describe the old route's + // teardown, which we're about to re-scan past anyway. + router.observer.takeRecords(); + for (const subscriber of router.subscribers) { + subscriber.throttledScan.cancel(); + subscriber.pending.clear(); + } + + // Wait one frame so React/Vue/Svelte can finish committing the new + // route's DOM, then sweep document.body once. Passing document.body + // makes rules that scan from their root argument do a full re-scan; + // selector-hide-rule's onSubtrees already scans from document.body + // regardless — both shapes end up doing the right thing. + if (router.routeSweepHandle !== null) { + cancelAnimationFrame(router.routeSweepHandle); + } + router.routeSweepHandle = requestAnimationFrame(() => { + router.routeSweepHandle = null; + if (!router.observer || document.hidden) { return; } - // Cancel anything pending — the new route's content will arrive in the - // next render and we want the user-visible hide to happen against the - // new tree, not as a tail of the old throttle window. Discard buffered - // MutationRecords for the same reason: they describe the old route's - // teardown, which we're about to re-scan past anyway. - observer.takeRecords(); - throttledScan?.cancel(); - pending.clear(); - - // Wait one frame so React/Vue/Svelte can finish committing the new - // route's DOM, then sweep document.body once. Passing document.body - // makes rules that scan from their root argument do a full re-scan; - // selector-hide-rule's onSubtrees already scans from document.body - // regardless — both shapes end up doing the right thing. - if (routeSweepHandle !== null) { - cancelAnimationFrame(routeSweepHandle); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!document.body) { + return; } - routeSweepHandle = requestAnimationFrame(() => { - routeSweepHandle = null; - if (!observer || document.hidden) { - return; - } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!document.body) { - return; - } - onSubtrees([document.body]); - }); + for (const subscriber of router.subscribers) { + subscriber.onSubtrees([document.body]); + } + }); +} + +function startRouter(router: Router): void { + if (router.observer) { + return; } + const observer = new MutationObserver((mutations) => { + fanOut(router, mutations); + }); + router.observer = observer; + if (!document.hidden) { + observer.observe(router.target, { childList: true, subtree: true }); + } + router.visibilityListener = () => { + handleVisibilityChange(router); + }; + document.addEventListener("visibilitychange", router.visibilityListener); + router.unsubscribeRouteChange = subscribeRouteChange(() => { + handleRouteChange(router); + }); +} - function handleVisibilityChange(): void { - if (document.hidden) { - // Flush whatever's pending so we don't sit on a stale snapshot until - // the user returns, then stop receiving mutations. Background tabs - // keep firing observer callbacks; disconnecting is the cheap way to - // opt out for the duration. - throttledScan?.flush(); - observer?.disconnect(); - } else if (observer && observedTarget) { - observer.observe(observedTarget, { childList: true, subtree: true }); - } +function stopRouter(router: Router): void { + router.observer?.disconnect(); + router.observer = null; + if (router.visibilityListener) { + document.removeEventListener("visibilitychange", router.visibilityListener); + router.visibilityListener = null; + } + router.unsubscribeRouteChange?.(); + router.unsubscribeRouteChange = null; + if (router.routeSweepHandle !== null) { + cancelAnimationFrame(router.routeSweepHandle); + router.routeSweepHandle = null; } + routersByTarget.delete(router.target); +} + +function getOrCreateRouter(target: Node): Router { + let router = routersByTarget.get(target); + if (!router) { + router = { + target, + observer: null, + subscribers: new Set(), + visibilityListener: null, + unsubscribeRouteChange: null, + routeSweepHandle: null, + }; + routersByTarget.set(target, router); + } + return router; +} + +export function createSubtreeWatcher( + options: SubtreeWatcherOptions, +): SubtreeWatcher { + const { + onSubtrees, + throttleMs = 250, + skipPlaceholderSubtrees = false, + } = options; + + let subscriber: Subscriber | null = null; + let router: Router | null = null; return { start(root: ParentNode): void { - if (observer) { + if (subscriber) { return; } - // Trailing-only: a burst of additions inside one window collapses to a - // single drain at the end of it, instead of one drain at the leading - // edge plus another at the trailing edge (which is what leading+trailing - // produced — every burst scanned twice). - throttledScan = throttle(drain, throttleMs, { - leading: false, - trailing: true, - }); - observer = new MutationObserver(enqueue); - // rule-engine always passes document.body, but accept Document for - // robustness and resolve to its body. `Document.body` is typed as - // non-null, but iframe edge cases at document_idle can leave it - // missing — guard rather than trust the type. - const target = - (root as Node).nodeType === Node.DOCUMENT_NODE - ? (root as Document).body - : (root as Node); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const target = resolveTarget(root); if (!target) { return; } - observedTarget = target; - if (!document.hidden) { - observer.observe(target, { childList: true, subtree: true }); - } - visibilityListener = handleVisibilityChange; - document.addEventListener("visibilitychange", visibilityListener); - unsubscribeRouteChange = subscribeRouteChange(handleRouteChange); + router = getOrCreateRouter(target); + const ownSubscriber: Subscriber = { + onSubtrees, + skipPlaceholderSubtrees, + pending: new Set(), + // Trailing-only: a burst of additions inside one window collapses + // to a single drain at the end of it, instead of one drain at the + // leading edge plus another at the trailing edge (which is what + // leading+trailing produced — every burst scanned twice). + throttledScan: throttle( + () => { + drainSubscriber(ownSubscriber); + }, + throttleMs, + { leading: false, trailing: true }, + ), + }; + subscriber = ownSubscriber; + router.subscribers.add(ownSubscriber); + startRouter(router); }, stop(): void { - observer?.disconnect(); - observer = null; - throttledScan?.cancel(); - throttledScan = null; - pending.clear(); - observedTarget = null; - if (visibilityListener) { - document.removeEventListener("visibilitychange", visibilityListener); - visibilityListener = null; + if (!subscriber || !router) { + return; } - unsubscribeRouteChange?.(); - unsubscribeRouteChange = null; - if (routeSweepHandle !== null) { - cancelAnimationFrame(routeSweepHandle); - routeSweepHandle = null; + router.subscribers.delete(subscriber); + subscriber.throttledScan.cancel(); + subscriber.pending.clear(); + if (router.subscribers.size === 0) { + stopRouter(router); } + subscriber = null; + router = null; }, }; } + +// Test-only: tear down every shared router and clear the registry. Tests +// that exercise the route-change / visibility paths can leak router state +// across cases if a watcher's stop() is missed; this restores the module +// to the same shape as a fresh import. +export function __resetSubtreeWatcherForTesting(): void { + for (const router of routersByTarget.values()) { + router.observer?.disconnect(); + if (router.visibilityListener) { + document.removeEventListener( + "visibilitychange", + router.visibilityListener, + ); + } + router.unsubscribeRouteChange?.(); + if (router.routeSweepHandle !== null) { + cancelAnimationFrame(router.routeSweepHandle); + } + for (const subscriber of router.subscribers) { + subscriber.throttledScan.cancel(); + } + } + routersByTarget.clear(); +}