@@ -85,11 +85,47 @@ const routeLoaderMatchers = new WeakMap<
8585let routerInitialized = false
8686let activeRouteLoaders : Record < string , RouteLoader > | null = null
8787let pendingProgrammaticNavigationPath : string | null = null
88+ let pendingProgrammaticNavigationSuppressStart = false
89+ let navigationAbortController : AbortController | null = null
8890
8991function 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+
93129function getNavigationApi ( ) {
94130 if ( typeof window === 'undefined' ) return null
95131 return (
@@ -300,9 +336,13 @@ function getPathWithSearchAndHashFromUrl(url: URL) {
300336}
301337
302338function 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
308348async 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
407481function 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
424496function 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) {
436507function 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+
465567function 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
531639type RouterHandle = Pick < Handle , 'context' | 'signal' | 'update' > & {
0 commit comments