Skip to content

Commit 08763b6

Browse files
Use the Navigation API in the client router (#72)
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent a6bf373 commit 08763b6

2 files changed

Lines changed: 436 additions & 11 deletions

File tree

client/client-router.test.ts

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import { afterEach, expect, test, vi } from 'vitest'
2+
import { type Handle } from 'remix/component'
3+
4+
type TestWindow = Window &
5+
typeof globalThis & {
6+
navigation?: EventTarget & {
7+
navigate?: (url: string) => {
8+
committed: Promise<unknown>
9+
finished: Promise<unknown>
10+
}
11+
}
12+
}
13+
14+
const originalWindow = globalThis.window
15+
const originalFetch = globalThis.fetch
16+
const originalHtmlFormElement = globalThis.HTMLFormElement
17+
const originalHtmlButtonElement = globalThis.HTMLButtonElement
18+
const originalHtmlInputElement = globalThis.HTMLInputElement
19+
const originalDocument = globalThis.document
20+
21+
afterEach(() => {
22+
vi.resetModules()
23+
globalThis.fetch = originalFetch
24+
if (originalHtmlFormElement) {
25+
globalThis.HTMLFormElement = originalHtmlFormElement
26+
} else {
27+
Reflect.deleteProperty(globalThis, 'HTMLFormElement')
28+
}
29+
if (originalHtmlButtonElement) {
30+
globalThis.HTMLButtonElement = originalHtmlButtonElement
31+
} else {
32+
Reflect.deleteProperty(globalThis, 'HTMLButtonElement')
33+
}
34+
if (originalHtmlInputElement) {
35+
globalThis.HTMLInputElement = originalHtmlInputElement
36+
} else {
37+
Reflect.deleteProperty(globalThis, 'HTMLInputElement')
38+
}
39+
if (originalDocument) {
40+
globalThis.document = originalDocument
41+
} else {
42+
Reflect.deleteProperty(globalThis, 'document')
43+
}
44+
if (originalWindow) {
45+
globalThis.window = originalWindow
46+
return
47+
}
48+
Reflect.deleteProperty(globalThis, 'window')
49+
})
50+
51+
async function loadClientRouter() {
52+
return import('./client-router.tsx')
53+
}
54+
55+
function installDocumentStub() {
56+
const documentEventTarget = new EventTarget()
57+
class MockHtmlFormElement {}
58+
class MockHtmlButtonElement {}
59+
class MockHtmlInputElement {}
60+
globalThis.HTMLFormElement =
61+
MockHtmlFormElement as unknown as typeof HTMLFormElement
62+
globalThis.HTMLButtonElement =
63+
MockHtmlButtonElement as unknown as typeof HTMLButtonElement
64+
globalThis.HTMLInputElement =
65+
MockHtmlInputElement as unknown as typeof HTMLInputElement
66+
globalThis.document = {
67+
addEventListener: documentEventTarget.addEventListener.bind(documentEventTarget),
68+
removeEventListener:
69+
documentEventTarget.removeEventListener.bind(documentEventTarget),
70+
dispatchEvent: documentEventTarget.dispatchEvent.bind(documentEventTarget),
71+
} as unknown as Document
72+
}
73+
74+
test('navigate uses the Navigation API when available', async () => {
75+
const historyPushState = vi.fn()
76+
const navigationNavigate = vi.fn(() => ({
77+
committed: Promise.resolve(),
78+
finished: Promise.resolve(),
79+
}))
80+
81+
globalThis.window = {
82+
history: {
83+
pushState: historyPushState,
84+
},
85+
location: {
86+
assign: vi.fn(),
87+
hash: '',
88+
href: 'https://example.com/',
89+
origin: 'https://example.com',
90+
pathname: '/',
91+
search: '',
92+
},
93+
navigation: {
94+
navigate: navigationNavigate,
95+
},
96+
} as unknown as TestWindow
97+
98+
const { navigate } = await loadClientRouter()
99+
navigate('/login?redirectTo=%2Faccount#start')
100+
101+
expect(navigationNavigate).toHaveBeenCalledWith('/login?redirectTo=%2Faccount#start')
102+
expect(historyPushState).not.toHaveBeenCalled()
103+
})
104+
105+
test('listenToRouterNavigation rerenders for intercepted Navigation API events', async () => {
106+
const navigationEventTarget = new EventTarget()
107+
const listenerCalls: Array<string> = []
108+
installDocumentStub()
109+
110+
globalThis.window = {
111+
location: {
112+
assign: vi.fn(),
113+
hash: '',
114+
href: 'https://example.com/',
115+
origin: 'https://example.com',
116+
pathname: '/',
117+
search: '',
118+
},
119+
navigation: {
120+
addEventListener: navigationEventTarget.addEventListener.bind(
121+
navigationEventTarget,
122+
),
123+
dispatchEvent: navigationEventTarget.dispatchEvent.bind(navigationEventTarget),
124+
navigate: vi.fn(() => ({
125+
committed: Promise.resolve(),
126+
finished: Promise.resolve(),
127+
})),
128+
},
129+
} as unknown as TestWindow
130+
131+
const { listenToRouterNavigation } = await loadClientRouter()
132+
const handle = {
133+
on(target: EventTarget, listeners: Record<string, () => void>) {
134+
for (const [eventName, eventListener] of Object.entries(listeners)) {
135+
target.addEventListener(eventName, () => {
136+
eventListener()
137+
})
138+
}
139+
},
140+
} as unknown as Handle
141+
142+
listenToRouterNavigation(handle, () => {
143+
listenerCalls.push('navigate')
144+
})
145+
146+
let intercepted = false
147+
const event = Object.assign(new Event('navigate'), {
148+
canIntercept: true,
149+
destination: {
150+
url: 'https://example.com/login',
151+
},
152+
downloadRequest: null,
153+
formData: null,
154+
hashChange: false,
155+
intercept(options?: { handler?: () => void | Promise<void> }) {
156+
intercepted = true
157+
options?.handler?.()
158+
},
159+
navigationType: 'push' as const,
160+
sourceElement: null,
161+
})
162+
163+
;(globalThis.window as TestWindow).navigation!.dispatchEvent(event)
164+
165+
expect(intercepted).toBe(true)
166+
expect(listenerCalls).toEqual(['navigate'])
167+
})
168+
169+
test('reload navigations are not intercepted', async () => {
170+
const navigationEventTarget = new EventTarget()
171+
const locationAssign = vi.fn()
172+
installDocumentStub()
173+
174+
globalThis.window = {
175+
location: {
176+
assign: locationAssign,
177+
hash: '',
178+
href: 'https://example.com/account',
179+
origin: 'https://example.com',
180+
pathname: '/account',
181+
search: '',
182+
},
183+
navigation: {
184+
addEventListener: navigationEventTarget.addEventListener.bind(
185+
navigationEventTarget,
186+
),
187+
dispatchEvent: navigationEventTarget.dispatchEvent.bind(navigationEventTarget),
188+
navigate: vi.fn(() => ({
189+
committed: Promise.resolve(),
190+
finished: Promise.resolve(),
191+
})),
192+
},
193+
} as unknown as TestWindow
194+
195+
const { listenToRouterNavigation } = await loadClientRouter()
196+
const handle = {
197+
on(target: EventTarget, listeners: Record<string, () => void>) {
198+
for (const [eventName, eventListener] of Object.entries(listeners)) {
199+
target.addEventListener(eventName, () => {
200+
eventListener()
201+
})
202+
}
203+
},
204+
} as unknown as Handle
205+
206+
let notified = false
207+
listenToRouterNavigation(handle, () => {
208+
notified = true
209+
})
210+
211+
let intercepted = false
212+
const event = Object.assign(new Event('navigate'), {
213+
canIntercept: true,
214+
destination: {
215+
url: 'https://example.com/account',
216+
},
217+
downloadRequest: null,
218+
formData: null,
219+
hashChange: false,
220+
intercept() {
221+
intercepted = true
222+
},
223+
navigationType: 'reload' as const,
224+
sourceElement: null,
225+
})
226+
227+
;(globalThis.window as TestWindow).navigation!.dispatchEvent(event)
228+
229+
expect(intercepted).toBe(false)
230+
expect(notified).toBe(false)
231+
expect(locationAssign).not.toHaveBeenCalled()
232+
})
233+
234+
test('navigate-event data-router-skip forms are not intercepted', async () => {
235+
const navigationEventTarget = new EventTarget()
236+
installDocumentStub()
237+
238+
globalThis.window = {
239+
location: {
240+
assign: vi.fn(),
241+
hash: '',
242+
href: 'https://example.com/login',
243+
origin: 'https://example.com',
244+
pathname: '/login',
245+
search: '',
246+
},
247+
navigation: {
248+
addEventListener: navigationEventTarget.addEventListener.bind(
249+
navigationEventTarget,
250+
),
251+
dispatchEvent: navigationEventTarget.dispatchEvent.bind(navigationEventTarget),
252+
navigate: vi.fn(() => ({
253+
committed: Promise.resolve(),
254+
finished: Promise.resolve(),
255+
})),
256+
},
257+
} as unknown as TestWindow
258+
259+
const { listenToRouterNavigation } = await loadClientRouter()
260+
const handle = {
261+
on(target: EventTarget, listeners: Record<string, () => void>) {
262+
for (const [eventName, eventListener] of Object.entries(listeners)) {
263+
target.addEventListener(eventName, () => {
264+
eventListener()
265+
})
266+
}
267+
},
268+
} as unknown as Handle
269+
270+
let notified = false
271+
listenToRouterNavigation(handle, () => {
272+
notified = true
273+
})
274+
275+
let intercepted = false
276+
const form = new (globalThis.HTMLFormElement as typeof HTMLFormElement)()
277+
Object.assign(form, {
278+
getAttribute(name: string) {
279+
if (name === 'method') return 'post'
280+
if (name === 'action') return '/oauth/start'
281+
return null
282+
},
283+
hasAttribute(name: string) {
284+
return name === 'data-router-skip'
285+
},
286+
})
287+
288+
const event = Object.assign(new Event('navigate'), {
289+
canIntercept: true,
290+
destination: {
291+
url: 'https://example.com/oauth/start',
292+
},
293+
downloadRequest: null,
294+
formData: new FormData(),
295+
hashChange: false,
296+
intercept() {
297+
intercepted = true
298+
},
299+
navigationType: 'push' as const,
300+
sourceElement: form,
301+
})
302+
303+
;(globalThis.window as TestWindow).navigation!.dispatchEvent(event)
304+
305+
expect(intercepted).toBe(false)
306+
expect(notified).toBe(false)
307+
})

0 commit comments

Comments
 (0)