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);
+ });
+});