| 1 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 2 | index 6a10df1..35da0e8 100644 |
| 3 | --- a/internal/webui/components.js |
| 4 | +++ b/internal/webui/components.js |
| 5 | @@ -1761,6 +1761,16 @@ |
| 6 | node.connectedCallback(); |
| 7 | }); |
| 8 | }; |
| 9 | + const releaseNavigation = () => { |
| 10 | + // Morphing preserves listeners even when the new link has no Fixi |
| 11 | + // attributes, as with a thread's back link to a message fragment. |
| 12 | + document.querySelectorAll("[data-fixi-nav]").forEach(node => { |
| 13 | + const handler = node.__fixi; |
| 14 | + if (!handler) return; |
| 15 | + node.removeEventListener(handler.evt, handler); |
| 16 | + delete node.__fixi; |
| 17 | + }); |
| 18 | + }; |
| 19 | const swapShell = (text, url, push) => { |
| 20 | if (url.__tinyNavigationSerial && url.__tinyNavigationSerial !== navigationSerial) return false; |
| 21 | const routeChanged = renderedURL !== url.href; |
| 22 | @@ -1769,6 +1779,7 @@ |
| 23 | if ([...incoming.values()].some(node => !node)) return false; |
| 24 | const focusID = document.activeElement?.id; |
| 25 | closeStreams(); |
| 26 | + releaseNavigation(); |
| 27 | navTargets.forEach(selector => morph(document.querySelector(selector), incoming.get(selector).outerHTML)); |
| 28 | repairComponents(); |
| 29 | document.title = parsed.title || document.title; |
| 30 | diff --git a/scripts/qa/thread-navigation-browser.js b/scripts/qa/thread-navigation-browser.js |
| 31 | new file mode 100644 |
| 32 | index 0000000..0eff85a |
| 33 | --- /dev/null |
| 34 | +++ b/scripts/qa/thread-navigation-browser.js |
| 35 | @@ -0,0 +1,49 @@ |
| 36 | +// Run with Playwright CLI on a disposable room page containing a thread link. |
| 37 | +// Uses real link clicks, the bundled Fixi and morph, and browser history. |
| 38 | +async page => { |
| 39 | + const roomURL = page.url().split("#")[0]; |
| 40 | + if (!/\/rooms\/[a-z0-9_-]+$/.test(roomURL)) throw Error("Open a room with a thread before running this check."); |
| 41 | + const results = [], requests = []; |
| 42 | + const record = request => requests.push(request.url().split("?")[0]); |
| 43 | + page.on("request", record); |
| 44 | + const shell = async expected => { |
| 45 | + const state = await page.evaluate(() => ({ |
| 46 | + headers: document.querySelectorAll("#topbar").length, |
| 47 | + contents: document.querySelectorAll("#content").length, |
| 48 | + sections: [...document.querySelectorAll("#content>section")].map(node => node.id), |
| 49 | + composers: document.querySelectorAll("room-compose").length |
| 50 | + })); |
| 51 | + if (state.headers !== 1 || state.contents !== 1 || state.sections.length !== 1 || state.sections[0] !== expected || state.composers > 1) throw Error(JSON.stringify(state)); |
| 52 | + return state; |
| 53 | + }; |
| 54 | + try { |
| 55 | + for (const width of [320, 390, 600]) { |
| 56 | + await page.setViewportSize({width, height:844}); |
| 57 | + await page.goto(roomURL); |
| 58 | + await page.evaluate(() => { window.__threadNavigation = true; }); |
| 59 | + await page.locator('#messages a[href*="/thread/"]').first().click(); |
| 60 | + await page.waitForURL("**/thread/**"); |
| 61 | + await shell("thread"); |
| 62 | + if (!await page.evaluate(() => window.__threadNavigation)) throw Error("Thread navigation unexpectedly reloaded the page."); |
| 63 | + const back = page.locator("#mobile-back"); |
| 64 | + const destination = await back.evaluate(node => node.href); |
| 65 | + if (!destination.includes("#msg-")) throw Error("Back link lost its message target."); |
| 66 | + await back.click(); |
| 67 | + await page.waitForURL(destination); |
| 68 | + const returned = await shell("room"); |
| 69 | + await page.locator("#mobile-back").click(); |
| 70 | + await page.waitForURL("**/chat"); |
| 71 | + const chat = await shell("chat"); |
| 72 | + if (chat.composers !== 0) throw Error("Chat list kept the thread composer."); |
| 73 | + await page.goBack(); |
| 74 | + await page.waitForFunction(() => document.querySelector("#content>section")?.id === "room"); |
| 75 | + await shell("room"); |
| 76 | + await page.goForward(); |
| 77 | + await page.waitForFunction(() => document.querySelector("#content>section")?.id === "chat"); |
| 78 | + await shell("chat"); |
| 79 | + results.push({width, returned, history:true}); |
| 80 | + } |
| 81 | + if (requests.some(path => /\/(?:null|undefined)$/.test(path))) throw Error("A stale handler requested an invalid navigation URL."); |
| 82 | + return results; |
| 83 | + } finally { page.off("request", record); } |
| 84 | +} |
| 85 | diff --git a/scripts/shell-navigation.test.mjs b/scripts/shell-navigation.test.mjs |
| 86 | index 77b46fb..483f45e 100644 |
| 87 | --- a/scripts/shell-navigation.test.mjs |
| 88 | +++ b/scripts/shell-navigation.test.mjs |
| 89 | @@ -9,9 +9,11 @@ const source = fs.readFileSync("internal/webui/components.js", "utf8"); |
| 90 | const start = source.indexOf(" const swapShell = (text, url, push) => {"); |
| 91 | const end = source.indexOf(" const load = async (url, push) => {", start); |
| 92 | const swapSource = source.slice(start, end); |
| 93 | +const releaseSource = source.slice(source.indexOf(" const releaseNavigation = () => {"), start); |
| 94 | |
| 95 | function setup({url = "https://relay.test/rooms/general", active = "content", contentScroll = 0, pageScroll = 0} = {}) { |
| 96 | const calls = {scroll: [], history: [], focus: []}; |
| 97 | + const links = []; |
| 98 | const elements = { |
| 99 | "#topbar": {outerHTML: "<header id=topbar></header>"}, |
| 100 | "#railbox": {outerHTML: "<aside id=railbox></aside>"}, |
| 101 | @@ -26,6 +28,7 @@ function setup({url = "https://relay.test/rooms/general", active = "content", co |
| 102 | activeElement: active === "draft" ? draft : elements["#content"], |
| 103 | title: "old", |
| 104 | querySelector(selector) { return elements[selector] || null; }, |
| 105 | + querySelectorAll(selector) { return selector === "[data-fixi-nav]" ? links : []; }, |
| 106 | getElementById(id) { return id === "nav-menu" ? nav : id === "context-menu" ? context : id === "content" ? elements["#content"] : id === "draft" ? draft : null; }, |
| 107 | dispatchEvent() {} |
| 108 | }; |
| 109 | @@ -48,10 +51,10 @@ function setup({url = "https://relay.test/rooms/general", active = "content", co |
| 110 | }; |
| 111 | // Keep the production closure intact, while supplying only its DOM shell |
| 112 | // dependencies. The returned function is the implementation under test. |
| 113 | - const swap = vm.runInNewContext(`(() => { let renderedURL = ${JSON.stringify(url)}; const navTargets = ${JSON.stringify(sandbox.navTargets)}; let navigationSerial = 0; const closeStreams = () => {}; const repairComponents = () => {}; const ensureModules = () => {}; const decorate = () => {}; const decorateForms = () => {}; ${swapSource.replace(" const swapShell", "const swapShell")} return swapShell; })()`, sandbox); |
| 114 | + const swap = vm.runInNewContext(`(() => { let renderedURL = ${JSON.stringify(url)}; const navTargets = ${JSON.stringify(sandbox.navTargets)}; let navigationSerial = 0; const closeStreams = () => {}; const repairComponents = () => {}; const ensureModules = () => {}; const decorate = () => {}; const decorateForms = () => {}; ${releaseSource} ${swapSource.replace(" const swapShell", "const swapShell")} return swapShell; })()`, sandbox); |
| 115 | nav.open = true; |
| 116 | context.open = true; |
| 117 | - return {swap, nav, context, content: elements["#content"], document, calls}; |
| 118 | + return {swap, nav, context, content: elements["#content"], document, calls, links}; |
| 119 | } |
| 120 | |
| 121 | test("a changed route resets both menu drawers and scroll", () => { |
| 122 | @@ -88,3 +91,23 @@ test("a browser-back route change resets position without pushing history", () = |
| 123 | assert.deepEqual(s.calls.scroll, [[0, 0]]); |
| 124 | assert.deepEqual(s.calls.history, []); |
| 125 | }); |
| 126 | + |
| 127 | +test("a retained back link does not keep its old Fixi handler after a shell swap", () => { |
| 128 | + const s = setup(); |
| 129 | + const link = new EventTarget(); |
| 130 | + let intercepted = 0, normalClicks = 0; |
| 131 | + link.__fixi = () => { intercepted++; }; |
| 132 | + link.__fixi.evt = "click"; |
| 133 | + link.addEventListener("click", link.__fixi); |
| 134 | + link.addEventListener("click", () => { normalClicks++; }); |
| 135 | + s.links.push(link); |
| 136 | + link.dispatchEvent(new Event("click")); |
| 137 | + assert.equal(intercepted, 1); |
| 138 | + s.swap("<html></html>", new URL("https://relay.test/rooms/general/thread/" + "a".repeat(64)), true); |
| 139 | + // The node survives morphing, but its next href may contain a fragment and |
| 140 | + // no longer opt in to Fixi. Only the browser's normal link action should run. |
| 141 | + link.dispatchEvent(new Event("click")); |
| 142 | + assert.equal(intercepted, 1); |
| 143 | + assert.equal(normalClicks, 2); |
| 144 | + assert.equal(link.__fixi, undefined); |
| 145 | +}); |