From 9a6c2b83140baa580bf3dbb9979ab9c0f3a7ef28 Mon Sep 17 00:00:00 2001 From: divya0795 <12871391+divya0795@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:54:36 +0000 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfix(remote-popup):=20stop=20the=20refr?= =?UTF-8?q?esh=20loop=20from=20re-triggering=20itself?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateLinks assigned anchor.textContent unconditionally. Assigning textContent always removes the existing child text node and inserts a new one, so it emits a childList mutation record even when the string is identical. The script observes childList/subtree on document.documentElement, so that write fed straight back into its own observer: refresh -> updateLinks writes textContent -> childList record -> scheduleRefresh -> setTimeout(0) -> refresh -> ... Once any DOM change kicked it off, the loop sustained itself for as long as a remote-tooltip anchor was in the document, re-running querySelectorAll sweeps and text-node churn one pass per macrotask and never quiescing. Compare the href write, which is safe because attributes are not observed, and updateQrCodes, which already guards with "canvas.dataset.wandRemoteUrl === remoteUrl". Apply the same idea: compare before writing, so a link that already holds the right value is left untouched. Added bridge/src/remote-popup-cleanup.test.ts, which kicks the loop with one external mutation and then asserts the document stops changing. Against the unfixed script it records 8 mutations over 8 macrotasks -- one per pass, confirming the loop is self-sustaining -- and 0 with the fix. The suite needs DOM globals, which bridge/tsconfig.json omits because it targets the Node-side bridge, so the test file pulls the DOM lib in locally with a reference directive rather than widening the project config. --- .../scripts/default/remote-popup-cleanup.js | 14 ++++- .../bridge/src/remote-popup-cleanup.test.ts | 56 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 web-panel/bridge/src/remote-popup-cleanup.test.ts diff --git a/web-panel/bridge/scripts/default/remote-popup-cleanup.js b/web-panel/bridge/scripts/default/remote-popup-cleanup.js index 750e848..13db45e 100644 --- a/web-panel/bridge/scripts/default/remote-popup-cleanup.js +++ b/web-panel/bridge/scripts/default/remote-popup-cleanup.js @@ -100,9 +100,19 @@ import { resolveQrRenderer as findWandQrRenderer } from "./remote-popup-cleanup/ return } + const linkText = remoteUrl.replace(/\/$/, "") for (const anchor of document.querySelectorAll("remote-tooltip a[href]")) { - anchor.setAttribute("href", remoteUrl) - anchor.textContent = remoteUrl.replace(/\/$/, "") + if (anchor.getAttribute("href") !== remoteUrl) { + anchor.setAttribute("href", remoteUrl) + } + + // Assigning textContent always replaces the child text node, which emits a + // childList record even when the string is unchanged. The observer below + // watches childList on documentElement, so an unguarded write here would + // schedule the next refresh and never settle. + if (anchor.textContent !== linkText) { + anchor.textContent = linkText + } } } diff --git a/web-panel/bridge/src/remote-popup-cleanup.test.ts b/web-panel/bridge/src/remote-popup-cleanup.test.ts new file mode 100644 index 0000000..93dd5b5 --- /dev/null +++ b/web-panel/bridge/src/remote-popup-cleanup.test.ts @@ -0,0 +1,56 @@ +// bridge/tsconfig.json targets the Node-side bridge and so omits the DOM lib. +// This suite drives a renderer script under vitest's jsdom environment, so it +// pulls the DOM types in locally rather than widening the project config. +/// + +import { afterAll, describe, expect, it } from 'vitest'; + +const REMOTE_URL = 'http://192.168.1.5:8080/'; + +/** Lets the script's setTimeout(0) refresh loop run for a few turns. */ +async function settle(turns = 8) { + for (let index = 0; index < turns; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +describe('remote popup cleanup', () => { + afterAll(() => { + document.body.innerHTML = ''; + }); + + it('leaves the remote link alone once it matches, so the refresh loop settles', async () => { + document.body.innerHTML = + 'stale'; + (globalThis as Record).__wandRemoteBridgeUrl = REMOTE_URL; + + // Installs on import: runs one refresh, then observes documentElement. + await import('../scripts/default/remote-popup-cleanup.js'); + await settle(); + + const anchor = document.querySelector('remote-tooltip a[href]'); + expect(anchor?.getAttribute('href')).toBe(REMOTE_URL); + expect(anchor?.textContent).toBe('http://192.168.1.5:8080'); + + // The script's own refresh happens before it starts observing, so the loop + // needs one external mutation to kick it off. After this the link already + // holds the right value, so a correct updateLinks writes nothing further. + document.body.appendChild(document.createElement('div')); + await settle(2); + + // An unguarded `anchor.textContent = ...` replaces the child text node on + // every pass. That is a childList record on documentElement, which + // re-triggers the script's observer, which schedules another refresh -- + // a loop that never quiesces while a remote-tooltip anchor is present. + let mutationCount = 0; + const probe = new MutationObserver((records) => { + mutationCount += records.length; + }); + probe.observe(document.documentElement, { childList: true, subtree: true }); + + await settle(); + probe.disconnect(); + + expect(mutationCount).toBe(0); + }); +});