Skip to content

Commit f68b310

Browse files
Add navigation progress bar (#77)
* Add navigation progress bar Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Keep progress pending through form redirects Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent ee5f45e commit f68b310

5 files changed

Lines changed: 358 additions & 23 deletions

File tree

client/app.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type SessionInfo,
1212
type SessionStatus,
1313
} from './session.ts'
14+
import { NavigationProgress } from './navigation-progress.tsx'
1415
import { SessionProvider } from './session-context.tsx'
1516
import { buildAuthLink } from './auth-links.ts'
1617
import { colors, mq, spacing, typography } from './styles/tokens.ts'
@@ -200,6 +201,7 @@ export function App(handle: Handle<AppProps>) {
200201
) : null}
201202
</nav>
202203
<SessionProvider session={session}>
204+
<NavigationProgress />
203205
<Router
204206
loaders={clientRouteLoaders}
205207
routes={clientRoutes}

client/client-router.tsx

Lines changed: 131 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,47 @@ const routeLoaderMatchers = new WeakMap<
8585
let routerInitialized = false
8686
let activeRouteLoaders: Record<string, RouteLoader> | null = null
8787
let pendingProgrammaticNavigationPath: string | null = null
88+
let pendingProgrammaticNavigationSuppressStart = false
89+
let navigationAbortController: AbortController | null = null
8890

8991
function notify() {
9092
routerEvents.dispatchEvent(new Event('navigate'))
9193
}
9294

95+
function dispatchNavigationStart() {
96+
routerEvents.dispatchEvent(
97+
new CustomEvent('navigationstart', {
98+
detail: { location: getCurrentPathWithSearchAndHash() },
99+
}),
100+
)
101+
}
102+
103+
function dispatchNavigationEnd(location = getCurrentPathWithSearchAndHash()) {
104+
routerEvents.dispatchEvent(
105+
new CustomEvent('navigationend', {
106+
detail: { location },
107+
}),
108+
)
109+
}
110+
111+
function beginNavigation(options: { suppressStart?: boolean } = {}) {
112+
navigationAbortController?.abort()
113+
const controller = new AbortController()
114+
navigationAbortController = controller
115+
if (!options.suppressStart) {
116+
dispatchNavigationStart()
117+
}
118+
return controller
119+
}
120+
121+
function finishNavigation(controller: AbortController, location?: string) {
122+
if (navigationAbortController !== controller) return
123+
navigationAbortController = null
124+
if (!controller.signal.aborted) {
125+
dispatchNavigationEnd(location)
126+
}
127+
}
128+
93129
function getNavigationApi() {
94130
if (typeof window === 'undefined') return null
95131
return (
@@ -300,9 +336,13 @@ function getPathWithSearchAndHashFromUrl(url: URL) {
300336
}
301337

302338
function consumeProgrammaticNavigation(path: string) {
303-
if (pendingProgrammaticNavigationPath !== path) return false
339+
if (pendingProgrammaticNavigationPath !== path) {
340+
return { matched: false, suppressStart: false }
341+
}
342+
const suppressStart = pendingProgrammaticNavigationSuppressStart
304343
pendingProgrammaticNavigationPath = null
305-
return true
344+
pendingProgrammaticNavigationSuppressStart = false
345+
return { matched: true, suppressStart }
306346
}
307347

308348
async function preloadRouteData(destination: URL, signal: AbortSignal) {
@@ -333,10 +373,9 @@ async function preloadAndCommitNavigationData(
333373
signal: AbortSignal,
334374
) {
335375
try {
336-
return commitRouteLoaderResult(
337-
destination,
338-
await preloadRouteData(destination, signal),
339-
)
376+
const result = await preloadRouteData(destination, signal)
377+
if (signal.aborted) return false
378+
return commitRouteLoaderResult(destination, result)
340379
} catch (error) {
341380
if (signal.aborted) return false
342381
markNavigationDataStale(getPathWithSearchAndHashFromUrl(destination))
@@ -345,24 +384,38 @@ async function preloadAndCommitNavigationData(
345384
}
346385
}
347386

348-
async function navigateWithRefreshForSamePath(destination: URL) {
387+
async function navigateWithRefreshForSamePath(
388+
destination: URL,
389+
options: { signal?: AbortSignal; suppressStart?: boolean } = {},
390+
) {
349391
const path = getPathWithSearchAndHashFromUrl(destination)
350392
if (path === getCurrentPathWithSearchAndHash()) {
393+
const controller = options.signal
394+
? null
395+
: beginNavigation({ suppressStart: options.suppressStart })
351396
const redirected = await preloadAndCommitNavigationData(
352397
destination,
353-
new AbortController().signal,
398+
options.signal ?? controller?.signal ?? new AbortController().signal,
354399
)
355400
if (!redirected) notify()
356-
return
401+
if (controller) {
402+
finishNavigation(controller, path)
403+
}
404+
return true
357405
}
358-
navigate(destination.toString())
406+
navigate(destination.toString(), { suppressStart: options.suppressStart })
407+
return false
359408
}
360409

361-
async function submitPostFormThroughRouter(details: FormSubmitDetails) {
410+
async function submitPostFormThroughRouter(
411+
details: FormSubmitDetails,
412+
signal?: AbortSignal,
413+
) {
362414
const init: RequestInit = {
363415
method: details.method.toUpperCase(),
364416
credentials: 'include',
365417
redirect: 'follow',
418+
signal,
366419
}
367420

368421
if (details.enctype === 'application/x-www-form-urlencoded') {
@@ -400,8 +453,29 @@ async function submitFormThroughRouter(details: FormSubmitDetails) {
400453
return
401454
}
402455

403-
const destination = await submitPostFormThroughRouter(details)
404-
await navigateWithRefreshForSamePath(destination)
456+
const controller = beginNavigation()
457+
try {
458+
const destination = await submitPostFormThroughRouter(
459+
details,
460+
controller.signal,
461+
)
462+
if (controller.signal.aborted) return
463+
const completedNavigation = await navigateWithRefreshForSamePath(
464+
destination,
465+
{
466+
signal: controller.signal,
467+
suppressStart: true,
468+
},
469+
)
470+
if (completedNavigation) {
471+
finishNavigation(controller, getPathWithSearchAndHashFromUrl(destination))
472+
}
473+
} catch (error) {
474+
if (!controller.signal.aborted) {
475+
console.error('Router form submit failed', error)
476+
finishNavigation(controller)
477+
}
478+
}
405479
}
406480

407481
function handleDocumentSubmit(event: Event) {
@@ -416,16 +490,13 @@ function handleDocumentSubmit(event: Event) {
416490
if (!details) return
417491

418492
event.preventDefault()
419-
void submitFormThroughRouter(details).catch((error: unknown) => {
420-
console.error('Router form submit failed', error)
421-
})
493+
void submitFormThroughRouter(details)
422494
}
423495

424496
function shouldInterceptNavigationEvent(event: RouterNavigateEvent) {
425497
if (typeof window === 'undefined') return false
426498
if (!event.canIntercept) return false
427499
if (event.downloadRequest !== null) return false
428-
if (event.hashChange) return false
429500
if (event.navigationType === 'reload') return false
430501

431502
const destination = new URL(event.destination.url, window.location.href)
@@ -436,6 +507,22 @@ function shouldInterceptNavigationEvent(event: RouterNavigateEvent) {
436507
function handleNavigationEvent(event: RouterNavigateEvent) {
437508
if (!shouldInterceptNavigationEvent(event)) return
438509

510+
if (event.hashChange) {
511+
event.intercept({
512+
handler() {
513+
const controller = beginNavigation()
514+
notify()
515+
finishNavigation(
516+
controller,
517+
getPathWithSearchAndHashFromUrl(
518+
new URL(event.destination.url, window.location.href),
519+
),
520+
)
521+
},
522+
})
523+
return
524+
}
525+
439526
const { form } = getFormForSourceElement(event.sourceElement)
440527
if (form) {
441528
// Keep form submissions on the submit-handler path until precommit
@@ -444,24 +531,39 @@ function handleNavigationEvent(event: RouterNavigateEvent) {
444531
}
445532
const destination = new URL(event.destination.url, window.location.href)
446533
const nextPath = getPathWithSearchAndHashFromUrl(destination)
447-
const isProgrammaticNavigation = consumeProgrammaticNavigation(nextPath)
534+
const programmaticNavigation = consumeProgrammaticNavigation(nextPath)
535+
const controller = programmaticNavigation.matched
536+
? beginNavigation({
537+
suppressStart: programmaticNavigation.suppressStart,
538+
})
539+
: beginNavigation()
448540

449541
event.intercept({
450542
async handler() {
451-
if (isProgrammaticNavigation) {
543+
if (programmaticNavigation.matched) {
452544
notify()
545+
finishNavigation(controller, nextPath)
453546
return
454547
}
455-
const controller = new AbortController()
456548
const redirected = await preloadAndCommitNavigationData(
457549
destination,
458550
controller.signal,
459551
)
460-
if (!redirected) notify()
552+
if (controller.signal.aborted) return
553+
if (!redirected) {
554+
notify()
555+
}
556+
finishNavigation(controller, nextPath)
461557
},
462558
})
463559
}
464560

561+
function handlePopstate() {
562+
const controller = beginNavigation()
563+
notify()
564+
finishNavigation(controller)
565+
}
566+
465567
function ensureRouter() {
466568
if (routerInitialized) return
467569
routerInitialized = true
@@ -474,7 +576,7 @@ function ensureRouter() {
474576
return
475577
}
476578

477-
window.addEventListener('popstate', notify)
579+
window.addEventListener('popstate', handlePopstate)
478580
document.addEventListener('click', handleDocumentClick)
479581
}
480582

@@ -506,7 +608,10 @@ function getCurrentPathWithSearchAndHash() {
506608
return `${window.location.pathname}${window.location.search}${window.location.hash}`
507609
}
508610

509-
export function navigate(to: string) {
611+
export function navigate(
612+
to: string,
613+
options: { suppressStart?: boolean } = {},
614+
) {
510615
if (typeof window === 'undefined') return
511616
const destination = new URL(to, window.location.href)
512617
if (destination.origin !== window.location.origin) {
@@ -520,12 +625,15 @@ export function navigate(to: string) {
520625
const navigationApi = getNavigationApi()
521626
if (navigationApi) {
522627
pendingProgrammaticNavigationPath = nextPath
628+
pendingProgrammaticNavigationSuppressStart = options.suppressStart === true
523629
navigationApi.navigate(nextPath)
524630
return
525631
}
526632

633+
const controller = beginNavigation({ suppressStart: options.suppressStart })
527634
window.history.pushState({}, '', nextPath)
528635
notify()
636+
finishNavigation(controller, nextPath)
529637
}
530638

531639
type RouterHandle = Pick<Handle, 'context' | 'signal' | 'update'> & {

0 commit comments

Comments
 (0)