@@ -5,19 +5,135 @@ import type { NavigateOptions } from '@clerk/shared/types';
55import React from 'react' ;
66import { flushSync } from 'react-dom' ;
77
8- import { useWindowEventListener } from '../hooks' ;
98import { newPaths } from './newPaths' ;
109import { match } from './pathToRegexp' ;
1110import { Route } from './Route' ;
1211import { RouteContext } from './RouteContext' ;
1312
13+ // Custom events that don't exist on WindowEventMap but are handled
14+ // by wrapping history.pushState/replaceState in the fallback path.
15+ type HistoryEvent = 'pushstate' | 'replacestate' ;
16+ type RefreshEvent = keyof WindowEventMap | HistoryEvent ;
17+ type NavigationType = 'push' | 'replace' | 'traverse' ;
18+
19+ const isWindowRefreshEvent = ( event : RefreshEvent ) : event is keyof WindowEventMap => {
20+ return event !== 'pushstate' && event !== 'replacestate' ;
21+ } ;
22+
23+ // Maps refresh events to Navigation API navigationType values.
24+ const eventToNavigationType : Partial < Record < RefreshEvent , NavigationType > > = {
25+ popstate : 'traverse' ,
26+ pushstate : 'push' ,
27+ replacestate : 'replace' ,
28+ } ;
29+
30+ // Global subscription sets for the history monkey-patching fallback.
31+ // Using a single patch with subscriber sets avoids conflicts when
32+ // multiple BaseRouter instances mount simultaneously.
33+ const pushStateSubscribers = new Set < ( ) => void > ( ) ;
34+ const replaceStateSubscribers = new Set < ( ) => void > ( ) ;
35+ let originalPushState : History [ 'pushState' ] | null = null ;
36+ let originalReplaceState : History [ 'replaceState' ] | null = null ;
37+
38+ function ensurePushStatePatched ( ) : void {
39+ if ( originalPushState ) {
40+ return ;
41+ }
42+ originalPushState = history . pushState . bind ( history ) ;
43+ history . pushState = ( ...args : Parameters < History [ 'pushState' ] > ) => {
44+ originalPushState ! ( ...args ) ;
45+ pushStateSubscribers . forEach ( fn => fn ( ) ) ;
46+ } ;
47+ }
48+
49+ function ensureReplaceStatePatched ( ) : void {
50+ if ( originalReplaceState ) {
51+ return ;
52+ }
53+ originalReplaceState = history . replaceState . bind ( history ) ;
54+ history . replaceState = ( ...args : Parameters < History [ 'replaceState' ] > ) => {
55+ originalReplaceState ! ( ...args ) ;
56+ replaceStateSubscribers . forEach ( fn => fn ( ) ) ;
57+ } ;
58+ }
59+
60+ /**
61+ * Observes history changes so the router's internal state stays in sync
62+ * with the URL. Uses the Navigation API when available, falling back to
63+ * monkey-patching history.pushState/replaceState plus native window events.
64+ *
65+ * Note: `events` should be a stable array reference to avoid
66+ * re-subscribing on every render.
67+ */
68+ function useHistoryChangeObserver ( events : Array < RefreshEvent > | undefined , callback : ( ) => void ) : void {
69+ const callbackRef = React . useRef ( callback ) ;
70+ callbackRef . current = callback ;
71+
72+ React . useEffect ( ( ) => {
73+ if ( ! events ) {
74+ return ;
75+ }
76+
77+ const notify = ( ) => callbackRef . current ( ) ;
78+ const windowEvents = events . filter ( isWindowRefreshEvent ) ;
79+ const navigationTypes = events
80+ . map ( e => eventToNavigationType [ e ] )
81+ . filter ( ( type ) : type is NavigationType => Boolean ( type ) ) ;
82+
83+ const hasNavigationAPI =
84+ typeof window !== 'undefined' &&
85+ 'navigation' in window &&
86+ typeof ( window as any ) . navigation ?. addEventListener === 'function' ;
87+
88+ if ( hasNavigationAPI ) {
89+ const nav = ( window as any ) . navigation ;
90+ const allowedTypes = new Set ( navigationTypes ) ;
91+ const handler = ( e : { navigationType : NavigationType } ) => {
92+ if ( allowedTypes . has ( e . navigationType ) ) {
93+ Promise . resolve ( ) . then ( notify ) ;
94+ }
95+ } ;
96+ nav . addEventListener ( 'currententrychange' , handler ) ;
97+
98+ // Events without a navigationType mapping (e.g. hashchange) still
99+ // need native listeners even when the Navigation API is available.
100+ const unmappedEvents = windowEvents . filter ( e => ! eventToNavigationType [ e ] ) ;
101+ unmappedEvents . forEach ( e => window . addEventListener ( e , notify ) ) ;
102+
103+ return ( ) => {
104+ nav . removeEventListener ( 'currententrychange' , handler ) ;
105+ unmappedEvents . forEach ( e => window . removeEventListener ( e , notify ) ) ;
106+ } ;
107+ }
108+
109+ // Fallback: use global subscriber sets for pushState/replaceState
110+ // so that multiple BaseRouter instances don't conflict.
111+ if ( events . includes ( 'pushstate' ) ) {
112+ ensurePushStatePatched ( ) ;
113+ pushStateSubscribers . add ( notify ) ;
114+ }
115+ if ( events . includes ( 'replacestate' ) ) {
116+ ensureReplaceStatePatched ( ) ;
117+ replaceStateSubscribers . add ( notify ) ;
118+ }
119+
120+ windowEvents . forEach ( e => window . addEventListener ( e , notify ) ) ;
121+
122+ return ( ) => {
123+ pushStateSubscribers . delete ( notify ) ;
124+ replaceStateSubscribers . delete ( notify ) ;
125+ windowEvents . forEach ( e => window . removeEventListener ( e , notify ) ) ;
126+ } ;
127+ } , [ events ] ) ;
128+ }
129+
14130interface BaseRouterProps {
15131 basePath : string ;
16132 startPath : string ;
17133 getPath : ( ) => string ;
18134 getQueryString : ( ) => string ;
19135 internalNavigate : ( toURL : URL , options ?: NavigateOptions ) => Promise < any > | any ;
20- refreshEvents ?: Array < keyof WindowEventMap > ;
136+ refreshEvents ?: Array < RefreshEvent > ;
21137 preservedParams ?: string [ ] ;
22138 urlStateParam ?: {
23139 startPath : string ;
@@ -88,7 +204,23 @@ export const BaseRouter = ({
88204 }
89205 } , [ currentPath , currentQueryString , getPath , getQueryString ] ) ;
90206
91- useWindowEventListener ( refreshEvents , refresh ) ;
207+ // Suppresses the history observer during baseNavigate's internal navigation.
208+ // Without this, the observer's microtask triggers a render before setActive's
209+ // #updateAccessors sets clerk.session, causing task guards to see stale state.
210+ const isNavigatingRef = React . useRef ( false ) ;
211+
212+ const observerRefresh = React . useCallback ( ( ) : void => {
213+ if ( isNavigatingRef . current ) {
214+ return ;
215+ }
216+ const newPath = getPath ( ) ;
217+ if ( basePath && ! newPath . startsWith ( '/' + basePath ) ) {
218+ return ;
219+ }
220+ refresh ( ) ;
221+ } , [ basePath , getPath , refresh ] ) ;
222+
223+ useHistoryChangeObserver ( refreshEvents , observerRefresh ) ;
92224
93225 // TODO: Look into the real possible types of globalNavigate
94226 const baseNavigate = async ( toURL : URL | undefined ) : Promise < unknown > => {
@@ -118,15 +250,20 @@ export const BaseRouter = ({
118250
119251 toURL . search = stringifyQueryParams ( toQueryParams ) ;
120252 }
121- const internalNavRes = await internalNavigate ( toURL , { metadata : { navigationType : 'internal' } } ) ;
122- // We need to flushSync to guarantee the re-render happens before handing things back to the caller,
123- // otherwise setActive might emit, and children re-render with the old navigation state.
124- // An alternative solution here could be to return a deferred promise, set that to state together
125- // with the routeParts and resolve it in an effect. That way we could avoid the flushSync performance penalty.
126- flushSync ( ( ) => {
127- setRouteParts ( { path : toURL . pathname , queryString : toURL . search } ) ;
128- } ) ;
129- return internalNavRes ;
253+ isNavigatingRef . current = true ;
254+ try {
255+ const internalNavRes = await internalNavigate ( toURL , { metadata : { navigationType : 'internal' } } ) ;
256+ // We need to flushSync to guarantee the re-render happens before handing things back to the caller,
257+ // otherwise setActive might emit, and children re-render with the old navigation state.
258+ // An alternative solution here could be to return a deferred promise, set that to state together
259+ // with the routeParts and resolve it in an effect. That way we could avoid the flushSync performance penalty.
260+ flushSync ( ( ) => {
261+ setRouteParts ( { path : toURL . pathname , queryString : toURL . search } ) ;
262+ } ) ;
263+ return internalNavRes ;
264+ } finally {
265+ isNavigatingRef . current = false ;
266+ }
130267 } ;
131268
132269 return (
0 commit comments