@@ -8,6 +8,7 @@ import React, {
88 useLayoutEffect ,
99 useMemo ,
1010 useReducer ,
11+ useState ,
1112 useTransition ,
1213 type ComponentType ,
1314} from 'react' ;
@@ -106,6 +107,9 @@ const committedResources = new Set<number>();
106107const EMPTY_PARAMS : Record < string , string > = { } ;
107108
108109let activePendingNavigationId : number | null = null ;
110+ // The URL the prerendered HTML was rendered with, while it differs from the browser URL. The
111+ // hydration commit clears it before effects run, so effects and later renders read the browser URL.
112+ let prerenderedUrl : string | null = null ;
109113let scheduleVisit :
110114 | ( ( url : string , options ?: VisitOptions ) => Promise < ReactPageData | undefined > )
111115 | null = null ;
@@ -156,6 +160,51 @@ function normalizeUrl(value: string | URL): string {
156160 return url . origin === window . location . origin ? url . pathname + url . search + url . hash : url . href ;
157161}
158162
163+ /** Page data holds a decoded path. Compare it with browser URLs in their encoded form. */
164+ function encodedPageUrl ( url : string ) : string {
165+ if ( ! url ) {
166+ return url ;
167+ }
168+ const parsed = new URL ( url , window . location . origin ) ;
169+ return parsed . pathname + parsed . search ;
170+ }
171+
172+ /** Static hosts serve one page at `/a`, `/a/`, `/a.html` and `/a/index.html`. */
173+ function staticPagePath ( pathname : string ) : string {
174+ let path = pathname ;
175+ if ( path . endsWith ( '/index.html' ) ) {
176+ path = path . slice ( 0 , - 'index.html' . length ) ;
177+ } else if ( path . endsWith ( '.html' ) ) {
178+ path = path . slice ( 0 , - '.html' . length ) ;
179+ }
180+ return path . length > 1 && path . endsWith ( '/' ) ? path . slice ( 0 , - 1 ) : path ;
181+ }
182+
183+ /**
184+ * Static page data serves every query of a path, so its url has no query. Returns the query of
185+ * fetchUrl when the page data is such data for its path, or null.
186+ */
187+ function staticVisitQuery ( pageUrl : string , fetchUrl : string ) : string | null {
188+ if ( ! pageUrl || pageUrl . includes ( '?' ) ) {
189+ return null ;
190+ }
191+ const queryStart = fetchUrl . indexOf ( '?' ) ;
192+ const fetchPath = queryStart === - 1 ? fetchUrl : fetchUrl . slice ( 0 , queryStart ) ;
193+ if ( staticPagePath ( fetchPath ) !== staticPagePath ( pageUrl ) ) {
194+ return null ;
195+ }
196+ return queryStart === - 1 ? '' : fetchUrl . slice ( queryStart ) ;
197+ }
198+
199+ /**
200+ * The URL prerendered HTML was rendered with, when it lacks the visitor's query, or null.
201+ */
202+ function prerenderedRouteUrl ( pageData : ReactPageData ) : string | null {
203+ const pageUrl = encodedPageUrl ( pageData . url ) ;
204+ const query = staticVisitQuery ( pageUrl , window . location . pathname + window . location . search ) ;
205+ return query ? pageUrl + window . location . hash : null ;
206+ }
207+
159208function navigationForResource ( resource : PageNavigationResource ) : NavigationState {
160209 const method = resource . method . toUpperCase ( ) ;
161210 const isSubmitting = method !== 'GET' ;
@@ -287,21 +336,29 @@ function RouteResource({
287336 const pageData = result . pageData as ReactPageData ;
288337 const PageComponent = result . component as ReactPageComponent ;
289338 const routeParams = pageData . params ?? EMPTY_PARAMS ;
290- const routeRouter = useMemo (
291- ( ) =>
292- createRouterFacade (
293- outerRouter ! ,
294- ( ) => {
295- const outerUrl = outerRouter ! . url ;
296- const outerPath = outerUrl . pathname + outerUrl . search ;
297- return outerPath === pageData . url
298- ? outerPath + outerUrl . hash
299- : pageData . url + resource . hash ;
300- } ,
301- ( ) => routeParams ,
302- ) ,
303- [ outerRouter , pageData . url , resource . hash , routeParams ] ,
304- ) ;
339+ const routeRouter = useMemo ( ( ) => {
340+ const pageUrl = encodedPageUrl ( pageData . url ) ;
341+ return createRouterFacade (
342+ outerRouter ! ,
343+ ( ) => {
344+ const outerUrl = outerRouter ! . url ;
345+ const outerPath = outerUrl . pathname + outerUrl . search ;
346+ if ( outerPath === pageUrl ) {
347+ return outerPath + outerUrl . hash ;
348+ }
349+ // Keep the query of a visit that loaded static page data. The outer URL keeps the hash
350+ // current after a same-page hash visit; during a reveal it still names the old page.
351+ const query = staticVisitQuery ( pageUrl , resource . fetchUrl ) ;
352+ if ( query !== null ) {
353+ return (
354+ pageUrl + query + ( outerPath === resource . fetchUrl ? outerUrl . hash : resource . hash )
355+ ) ;
356+ }
357+ return pageUrl + resource . hash ;
358+ } ,
359+ ( ) => routeParams ,
360+ ) ;
361+ } , [ outerRouter , pageData . url , resource . fetchUrl , resource . hash , routeParams ] ) ;
305362 let element = React . createElement ( PageComponent , pageData . props ) ;
306363 for ( let i = result . layouts . length - 1 ; i >= 0 ; i -- ) {
307364 const Layout = result . layouts [ i ] as ReactLayoutComponent ;
@@ -356,11 +413,12 @@ function App({
356413 navigationFallback ?: ComponentType < { initialPageData : PageObject } > ;
357414} ) : React . ReactNode {
358415 const initialPageData = engine . initialPageData as ReactPageData ;
416+ const [ hydrated , setHydrated ] = useState ( prerenderedUrl === null ) ;
359417 const initialResource = useMemo < ReactPageResource > (
360418 ( ) => ( {
361419 id : 0 ,
362420 url : initialPageData . url || currentUrl ( ) ,
363- fetchUrl : initialPageData . url || currentUrl ( ) ,
421+ fetchUrl : window . location . pathname + window . location . search ,
364422 hash : window . location . hash ,
365423 method : 'GET' ,
366424 options : { } ,
@@ -374,6 +432,13 @@ function App({
374432 async commit ( ) {
375433 engine . commitInitialRender ( ) ;
376434 document . getElementById ( 'app' ) ! . setAttribute ( 'data-hydrated' , 'true' ) ;
435+ if ( prerenderedUrl !== null ) {
436+ prerenderedUrl = null ;
437+ // A transition waits for Suspense boundaries that are still dehydrated, where an urgent
438+ // update would replace their server HTML with the fallback. Those boundaries hydrate
439+ // with the browser URL, so one that renders the query falls back to a client render.
440+ React . startTransition ( ( ) => setHydrated ( true ) ) ;
441+ }
377442 } ,
378443 dispose ( ) { } ,
379444 } ) ,
@@ -598,8 +663,11 @@ function App({
598663 } , [ isTransitionPending , state . navigation ] ) ;
599664
600665 const routerValue = useMemo (
601- ( ) => createRouterFacade ( router , ( ) => state . routeUrl ) ,
602- [ router , state . routeUrl ] ,
666+ ( ) =>
667+ createRouterFacade ( router , ( ) =>
668+ hydrated ? state . routeUrl : ( prerenderedUrl ?? state . routeUrl ) ,
669+ ) ,
670+ [ router , state . routeUrl , hydrated ] ,
603671 ) ;
604672
605673 return React . createElement (
@@ -630,6 +698,7 @@ export async function startReactPages(config: ReactPagesConfig): Promise<ReactPa
630698 pendingVisitSettlers . clear ( ) ;
631699 committedResources . clear ( ) ;
632700 activePendingNavigationId = null ;
701+ prerenderedUrl = null ;
633702
634703 const engine = await createPageNavigationEngine ( {
635704 adapter : {
@@ -657,13 +726,16 @@ export async function startReactPages(config: ReactPagesConfig): Promise<ReactPa
657726 throw new Error ( 'pages: Missing #app element for Void React pages hydration.' ) ;
658727 }
659728
729+ const isSsr = appEl . getAttribute ( 'data-void-ssr' ) !== 'false' ;
730+ if ( isSsr ) {
731+ prerenderedUrl = prerenderedRouteUrl ( engine . initialPageData as ReactPageData ) ;
732+ }
660733 const appRoot = React . createElement ( App , {
661734 engine,
662735 router,
663736 viewTransitions : config . viewTransitions ,
664737 navigationFallback : config . navigationFallback ,
665738 } ) ;
666- const isSsr = appEl . getAttribute ( 'data-void-ssr' ) !== 'false' ;
667739 const hasDeferred = Boolean ( ( engine . initialPageData as ReactPageData ) . deferredKeys ) ;
668740 const app =
669741 isSsr && hasDeferred
0 commit comments