clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

fix: release stale navigation handlers before shell swaps

Dami committed eb0ce7d
Detach owned Fixi listeners before morphing shared shell elements so a thread back link can become a normal message anchor without intercepting clicks or nesting another page.

Add retained-link regression coverage and browser checks for room-to-thread clicks, return navigation, and history at three mobile widths.
commit
eb0ce7dcbb686dafb4f92f9a5895964506441499
parent
d855534581df0160e72f79a73702d52db718cab1
author
Dami <dami.etoile@gmail.com>
3 files changed, +85 -2
Minternal/webui/components.js11+++++++
Ascripts/qa/thread-navigation-browser.js49++++++++++++++++++++++++++++++
Mscripts/shell-navigation.test.mjs27+++++++++++++++-
M internal/webui/components.js +11 -0
A scripts/qa/thread-navigation-browser.js +49 -0
@@ -0,0 +1,49 @@
1+// Run with Playwright CLI on a disposable room page containing a thread link.2+// Uses real link clicks, the bundled Fixi and morph, and browser history.3+async page => {4+ const roomURL = page.url().split("#")[0];5+ if (!/\/rooms\/[a-z0-9_-]+$/.test(roomURL)) throw Error("Open a room with a thread before running this check.");6+ const results = [], requests = [];7+ const record = request => requests.push(request.url().split("?")[0]);8+ page.on("request", record);9+ const shell = async expected => {10+ const state = await page.evaluate(() => ({11+ headers: document.querySelectorAll("#topbar").length,12+ contents: document.querySelectorAll("#content").length,13+ sections: [...document.querySelectorAll("#content>section")].map(node => node.id),14+ composers: document.querySelectorAll("room-compose").length15+ }));16+ if (state.headers !== 1 || state.contents !== 1 || state.sections.length !== 1 || state.sections[0] !== expected || state.composers > 1) throw Error(JSON.stringify(state));17+ return state;18+ };19+ try {20+ for (const width of [320, 390, 600]) {21+ await page.setViewportSize({width, height:844});22+ await page.goto(roomURL);23+ await page.evaluate(() => { window.__threadNavigation = true; });24+ await page.locator('#messages a[href*="/thread/"]').first().click();25+ await page.waitForURL("**/thread/**");26+ await shell("thread");27+ if (!await page.evaluate(() => window.__threadNavigation)) throw Error("Thread navigation unexpectedly reloaded the page.");28+ const back = page.locator("#mobile-back");29+ const destination = await back.evaluate(node => node.href);30+ if (!destination.includes("#msg-")) throw Error("Back link lost its message target.");31+ await back.click();32+ await page.waitForURL(destination);33+ const returned = await shell("room");34+ await page.locator("#mobile-back").click();35+ await page.waitForURL("**/chat");36+ const chat = await shell("chat");37+ if (chat.composers !== 0) throw Error("Chat list kept the thread composer.");38+ await page.goBack();39+ await page.waitForFunction(() => document.querySelector("#content>section")?.id === "room");40+ await shell("room");41+ await page.goForward();42+ await page.waitForFunction(() => document.querySelector("#content>section")?.id === "chat");43+ await shell("chat");44+ results.push({width, returned, history:true});45+ }46+ if (requests.some(path => /\/(?:null|undefined)$/.test(path))) throw Error("A stale handler requested an invalid navigation URL.");47+ return results;48+ } finally { page.off("request", record); }49+}
M scripts/shell-navigation.test.mjs +25 -2
@@ -9,9 +9,11 @@ const source = fs.readFileSync("internal/webui/components.js", "utf8");
99 const start = source.indexOf(" const swapShell = (text, url, push) => {");1010 const end = source.indexOf(" const load = async (url, push) => {", start);1111 const swapSource = source.slice(start, end);12+const releaseSource = source.slice(source.indexOf(" const releaseNavigation = () => {"), start);1213 1314 function setup({url = "https://relay.test/rooms/general", active = "content", contentScroll = 0, pageScroll = 0} = {}) {1415 const calls = {scroll: [], history: [], focus: []};16+ const links = [];1517 const elements = {1618 "#topbar": {outerHTML: "<header id=topbar></header>"},1719 "#railbox": {outerHTML: "<aside id=railbox></aside>"},
@@ -26,6 +28,7 @@ function setup({url = "https://relay.test/rooms/general", active = "content", co
2628 activeElement: active === "draft" ? draft : elements["#content"],2729 title: "old",2830 querySelector(selector) { return elements[selector] || null; },31+ querySelectorAll(selector) { return selector === "[data-fixi-nav]" ? links : []; },2932 getElementById(id) { return id === "nav-menu" ? nav : id === "context-menu" ? context : id === "content" ? elements["#content"] : id === "draft" ? draft : null; },3033 dispatchEvent() {}3134 };
@@ -48,10 +51,10 @@ function setup({url = "https://relay.test/rooms/general", active = "content", co
4851 };4952 // Keep the production closure intact, while supplying only its DOM shell5053 // dependencies. The returned function is the implementation under test.51- 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);54+ 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);5255 nav.open = true;5356 context.open = true;54- return {swap, nav, context, content: elements["#content"], document, calls};57+ return {swap, nav, context, content: elements["#content"], document, calls, links};5558 }5659 5760 test("a changed route resets both menu drawers and scroll", () => {
@@ -88,3 +91,23 @@ test("a browser-back route change resets position without pushing history", () =
8891 assert.deepEqual(s.calls.scroll, [[0, 0]]);8992 assert.deepEqual(s.calls.history, []);9093 });94+95+test("a retained back link does not keep its old Fixi handler after a shell swap", () => {96+ const s = setup();97+ const link = new EventTarget();98+ let intercepted = 0, normalClicks = 0;99+ link.__fixi = () => { intercepted++; };100+ link.__fixi.evt = "click";101+ link.addEventListener("click", link.__fixi);102+ link.addEventListener("click", () => { normalClicks++; });103+ s.links.push(link);104+ link.dispatchEvent(new Event("click"));105+ assert.equal(intercepted, 1);106+ s.swap("<html></html>", new URL("https://relay.test/rooms/general/thread/" + "a".repeat(64)), true);107+ // The node survives morphing, but its next href may contain a fragment and108+ // no longer opt in to Fixi. Only the browser's normal link action should run.109+ link.dispatchEvent(new Event("click"));110+ assert.equal(intercepted, 1);111+ assert.equal(normalClicks, 2);112+ assert.equal(link.__fixi, undefined);113+});