tinyrelaycommit

Commit eb0ce7dcbb68

browse files at this commit | history

1diff --git a/internal/webui/components.js b/internal/webui/components.js
2index 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;
30diff --git a/scripts/qa/thread-navigation-browser.js b/scripts/qa/thread-navigation-browser.js
31new file mode 100644
32index 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+}
85diff --git a/scripts/shell-navigation.test.mjs b/scripts/shell-navigation.test.mjs
86index 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+});

About

ownerc01503e8035f
visibilitypublic
headeb0ce7dcbb68

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs