also looking at this
fix(router): a click the page already handled is not a navigation
#1948
2 files
+177
-1
| @@ -0,0 +1,161 @@ | ||
| 1 | /** | |
| 2 | * The router must not navigate a click the page already handled | |
| 3 | * (stacksjs/stacks#2393). | |
| 4 | * | |
| 5 | * The click listener ran in the CAPTURE phase, which broke the contract twice | |
| 6 | * over. It saw the click before the anchor did, so `e.defaultPrevented` was | |
| 7 | * necessarily false by the time the router read it - and it read it only to | |
| 8 | * log it, never to branch on it. Worse, the `stopPropagation()` it then called | |
| 9 | * stopped the event ever reaching the element, so a page's own click handler | |
| 10 | * on an intercepted link never ran at all: not a late handler, not a losing | |
| 11 | * handler, no handler. | |
| 12 | * | |
| 13 | * The visible symptom was an anchor that navigated away instead of opening the | |
| 14 | * inline form its handler was there to render. The only workaround was to | |
| 15 | * replace the anchor with a <button> in script, which costs the no-JS reader | |
| 16 | * the link. | |
| 17 | * | |
| 18 | * Each case is paired with a control that must STILL be intercepted. A probe | |
| 19 | * reporting "not intercepted" for both is measuring nothing. | |
| 20 | */ | |
| 21 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 22 | import { Window } from 'very-happy-dom' | |
| 23 | import { getRouterScript } from '../src/client' | |
| 24 | ||
| 25 | const originalGlobals = { | |
| 26 | window: globalThis.window, | |
| 27 | document: globalThis.document, | |
| 28 | location: globalThis.location, | |
| 29 | history: globalThis.history, | |
| 30 | fetch: globalThis.fetch, | |
| 31 | CustomEvent: globalThis.CustomEvent, | |
| 32 | Event: globalThis.Event, | |
| 33 | MouseEvent: globalThis.MouseEvent, | |
| 34 | DOMParser: globalThis.DOMParser, | |
| 35 | } | |
| 36 | ||
| 37 | afterEach(() => { | |
| 38 | Object.assign(globalThis, originalGlobals) | |
| 39 | }) | |
| 40 | ||
| 41 | interface Harness { | |
| 42 | window: any | |
| 43 | /** URLs the router actually fetched, i.e. navigations it claimed. */ | |
| 44 | navigations: string[] | |
| 45 | } | |
| 46 | ||
| 47 | function installRouter(body: string, config: Record<string, unknown> = {}): Harness { | |
| 48 | const navigations: string[] = [] | |
| 49 | const window = new Window({ url: 'http://localhost/' }) | |
| 50 | window.document.write(`<html><head></head><body><main>Home</main>${body}</body></html>`) | |
| 51 | ;(window as any).stx = {} | |
| 52 | ;(window as any).__stxRouterConfig = { | |
| 53 | cache: false, | |
| 54 | prefetch: false, | |
| 55 | progress: false, | |
| 56 | viewTransitions: false, | |
| 57 | ...config, | |
| 58 | } | |
| 59 | ||
| 60 | Object.assign(globalThis, { | |
| 61 | window, | |
| 62 | document: window.document, | |
| 63 | location: window.location, | |
| 64 | history: window.history, | |
| 65 | CustomEvent: window.CustomEvent, | |
| 66 | Event: window.Event, | |
| 67 | MouseEvent: window.MouseEvent, | |
| 68 | DOMParser: window.DOMParser, | |
| 69 | fetch: async (url: string) => { | |
| 70 | navigations.push(String(url)) | |
| 71 | return new Response('<section>next</section>', { | |
| 72 | status: 200, | |
| 73 | headers: { 'Content-Type': 'text/html' }, | |
| 74 | }) | |
| 75 | }, | |
| 76 | }) | |
| 77 | ||
| 78 | new Function(getRouterScript())() | |
| 79 | ||
| 80 | return { window, navigations } | |
| 81 | } | |
| 82 | ||
| 83 | function click(harness: Harness, selector: string): void { | |
| 84 | const element = harness.window.document.querySelector(selector) | |
| 85 | if (!element) | |
| 86 | throw new Error(`no element matched ${selector}`) | |
| 87 | element.dispatchEvent(new harness.window.MouseEvent('click', { bubbles: true, cancelable: true })) | |
| 88 | } | |
| 89 | ||
| 90 | /** Binds the progressive-enhancement handler the issue describes. */ | |
| 91 | function enhance(harness: Harness, selector: string, ran: { value: boolean }): void { | |
| 92 | harness.window.document.querySelector(selector).addEventListener('click', (event: any) => { | |
| 93 | ran.value = true | |
| 94 | event.preventDefault() | |
| 95 | }) | |
| 96 | } | |
| 97 | ||
| 98 | describe('a page handler that cancels the click (#2393)', () => { | |
| 99 | describe('a marked [data-stx-link] anchor', () => { | |
| 100 | it('control: without a handler the router still claims it', () => { | |
| 101 | const harness = installRouter(`<a id="t" data-stx-link href="/partner">Go</a>`) | |
| 102 | click(harness, '#t') | |
| 103 | expect(harness.navigations).toEqual(['/partner']) | |
| 104 | }) | |
| 105 | ||
| 106 | it('runs the page handler at all', () => { | |
| 107 | // Under capture + stopPropagation the event never reached the anchor, | |
| 108 | // so this was false: the handler did not lose the race, it never ran. | |
| 109 | const harness = installRouter(`<a id="t" data-stx-link href="/partner">Go</a>`) | |
| 110 | const ran = { value: false } | |
| 111 | enhance(harness, '#t', ran) | |
| 112 | click(harness, '#t') | |
| 113 | expect(ran.value).toBe(true) | |
| 114 | }) | |
| 115 | ||
| 116 | it('does not navigate once that handler has cancelled it', () => { | |
| 117 | const harness = installRouter(`<a id="t" data-stx-link href="/partner">Go</a>`) | |
| 118 | enhance(harness, '#t', { value: false }) | |
| 119 | click(harness, '#t') | |
| 120 | expect(harness.navigations).toEqual([]) | |
| 121 | }) | |
| 122 | }) | |
| 123 | ||
| 124 | describe('an ordinary anchor under interceptAllLinks', () => { | |
| 125 | const staticSite = { interceptAllLinks: true } | |
| 126 | ||
| 127 | it('control: without a handler the router still claims it', () => { | |
| 128 | const harness = installRouter(`<a id="t" href="/partner">Go</a>`, staticSite) | |
| 129 | click(harness, '#t') | |
| 130 | expect(harness.navigations).toEqual(['/partner']) | |
| 131 | }) | |
| 132 | ||
| 133 | it('runs the page handler at all', () => { | |
| 134 | const harness = installRouter(`<a id="t" href="/partner">Go</a>`, staticSite) | |
| 135 | const ran = { value: false } | |
| 136 | enhance(harness, '#t', ran) | |
| 137 | click(harness, '#t') | |
| 138 | expect(ran.value).toBe(true) | |
| 139 | }) | |
| 140 | ||
| 141 | it('does not navigate once that handler has cancelled it', () => { | |
| 142 | const harness = installRouter(`<a id="t" href="/partner">Go</a>`, staticSite) | |
| 143 | enhance(harness, '#t', { value: false }) | |
| 144 | click(harness, '#t') | |
| 145 | expect(harness.navigations).toEqual([]) | |
| 146 | }) | |
| 147 | ||
| 148 | it('leaves an uncancelled handler on a sibling link alone', () => { | |
| 149 | // The guard reads the event, not the element, so one enhanced link must | |
| 150 | // not stop the next plain one from routing. | |
| 151 | const harness = installRouter( | |
| 152 | `<a id="a" href="/partner">Claim</a><a id="b" href="/browse">Browse</a>`, | |
| 153 | staticSite, | |
| 154 | ) | |
| 155 | enhance(harness, '#a', { value: false }) | |
| 156 | click(harness, '#a') | |
| 157 | click(harness, '#b') | |
| 158 | expect(harness.navigations).toEqual(['/browse']) | |
| 159 | }) | |
| 160 | }) | |
| 161 | }) | |