| 1 | // Navigation keeps in-place refreshes comfortable while making a new route |
| 2 | // start at a predictable position on small screens. |
| 3 | import test from "node:test" ; |
| 4 | import assert from "node:assert/strict" ; |
| 5 | import fs from "node:fs" ; |
| 6 | import vm from "node:vm" ; |
| 7 | |
| 8 | const source = fs.readFileSync("tinyclient/components.js" , "utf8" ); |
| 9 | const start = source.indexOf(" const swapShell = (text, url, push) => {" ); |
| 10 | const end = source.indexOf(" const load = async (url, push) => {" , start); |
| 11 | const swapSource = source.slice(start, end); |
| 12 | const releaseSource = source.slice(source.indexOf(" const releaseNavigation = () => {" ), start); |
| 13 | |
| 14 | function setup({url = "https://relay.test/rooms/general" , active = "content" , contentScroll = 0, pageScroll = 0} = {}) { |
| 15 | const calls = {scroll: [], history: [], focus: []}; |
| 16 | const links = []; |
| 17 | const elements = { |
| 18 | "#topbar" : {outerHTML: "<header id=topbar></header>" }, |
| 19 | "#railbox" : {outerHTML: "<aside id=railbox></aside>" }, |
| 20 | "#content" : {outerHTML: "<main id=content></main>" , scrollTop: contentScroll, focus(options) { calls.focus.push({id: "content" , options}); }}, |
| 21 | "#panel" : {outerHTML: "<aside id=panel></aside>" }, |
| 22 | "#footer" : {outerHTML: "<footer id=footer></footer>" } |
| 23 | }; |
| 24 | const nav = {open: false, removeAttribute(name) { if (name === "open" ) this.open = false; }}; |
| 25 | const context = {open: false, removeAttribute(name) { if (name === "open" ) this.open = false; }}; |
| 26 | const draft = {id: "draft" , focus(options) { calls.focus.push({id: this.id, options}); }}; |
| 27 | const document = { |
| 28 | activeElement: active === "draft" ? draft : elements["#content" ], |
| 29 | title: "old" , |
| 30 | querySelector(selector) { return elements[selector] || null; }, |
| 31 | querySelectorAll(selector) { return selector === "[data-fixi-nav]" ? links : []; }, |
| 32 | getElementById(id) { return id === "nav-menu" ? nav : id === "context-menu" ? context : id === "content" ? elements["#content" ] : id === "draft" ? draft : null; }, |
| 33 | dispatchEvent() {} |
| 34 | }; |
| 35 | const parsed = { |
| 36 | title: "new" , |
| 37 | querySelector(selector) { return elements[selector] || null; } |
| 38 | }; |
| 39 | const sandbox = { |
| 40 | document, |
| 41 | DOMParser: class { parseFromString() { return parsed; } }, |
| 42 | CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init.detail; } }, |
| 43 | history: {pushState(_state, _title, href) { calls.history.push(href); }}, |
| 44 | window: {scrollY: pageScroll, scrollTo(...args) { calls.scroll.push(args); }}, |
| 45 | location: {href: url}, |
| 46 | morph() {}, |
| 47 | navTargets: ["#topbar" , "#railbox" , "#content" , "#panel" , "#footer" ], |
| 48 | navigationSerial: 0, |
| 49 | closeStreams() {}, |
| 50 | repairComponents() {} |
| 51 | }; |
| 52 | // Keep the production closure intact, while supplying only its DOM shell |
| 53 | // dependencies. The returned function is the implementation under test. |
| 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); |
| 55 | nav.open = true; |
| 56 | context.open = true; |
| 57 | return {swap, nav, context, content: elements["#content" ], document, calls, links}; |
| 58 | } |
| 59 | |
| 60 | test("a changed route resets both menu drawers and scroll" , () => { |
| 61 | const s = setup({contentScroll: 420, pageScroll: 880}); |
| 62 | s.swap("<html></html>" , new URL("https://relay.test/rooms/general/thread/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ), true); |
| 63 | assert.equal(s.nav.open, false); |
| 64 | assert.equal(s.context.open, false); |
| 65 | assert.equal(s.content.scrollTop, 0); |
| 66 | assert.deepEqual(s.calls.scroll, [[0, 0]]); |
| 67 | assert.equal(s.calls.focus.length, 1); |
| 68 | assert.equal(s.calls.focus[0].id, "content" ); |
| 69 | assert.equal(s.calls.focus[0].options.preventScroll, true); |
| 70 | assert.equal(s.calls.history.length, 1); |
| 71 | }); |
| 72 | |
| 73 | test("an in-place refresh preserves scroll and the active draft" , () => { |
| 74 | const s = setup({active: "draft" , contentScroll: 315, pageScroll: 640}); |
| 75 | s.nav.open = false; |
| 76 | s.context.open = false; |
| 77 | s.swap("<html></html>" , new URL("https://relay.test/rooms/general" ), false); |
| 78 | assert.equal(s.content.scrollTop, 315); |
| 79 | assert.deepEqual(s.calls.scroll, []); |
| 80 | assert.equal(s.calls.focus.length, 1); |
| 81 | assert.equal(s.calls.focus[0].id, "draft" ); |
| 82 | assert.equal(s.calls.focus[0].options.preventScroll, true); |
| 83 | assert.equal(s.nav.open, false); |
| 84 | assert.equal(s.context.open, false); |
| 85 | }); |
| 86 | |
| 87 | test("a browser-back route change resets position without pushing history" , () => { |
| 88 | const s = setup({url: "https://relay.test/rooms/general/thread/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" , contentScroll: 190, pageScroll: 230}); |
| 89 | s.swap("<html></html>" , new URL("https://relay.test/rooms/general" ), false); |
| 90 | assert.equal(s.content.scrollTop, 0); |
| 91 | assert.deepEqual(s.calls.scroll, [[0, 0]]); |
| 92 | assert.deepEqual(s.calls.history, []); |
| 93 | }); |
| 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 and |
| 108 | // 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 | }); |