UNPKG

@tanstack/router-core

Version:

Modern and scalable routing for React applications

295 lines (264 loc) 9.87 kB
import invariant from 'tiny-invariant' import { batch } from '../utils/batch' import { isNotFound } from '../not-found' import { createControlledPromise } from '../utils' import { hydrateSsrMatchId } from './ssr-match-id' import type { GLOBAL_SEROVAL, GLOBAL_TSR } from './constants' import type { DehydratedMatch, TsrSsrGlobal } from './types' import type { AnyRouteMatch } from '../Matches' import type { AnyRouter } from '../router' import type { RouteContextOptions } from '../route' import type { AnySerializationAdapter } from './serializer/transformer' declare global { interface Window { [GLOBAL_TSR]?: TsrSsrGlobal [GLOBAL_SEROVAL]?: any } } function hydrateMatch( match: AnyRouteMatch, deyhydratedMatch: DehydratedMatch, ): void { match.id = deyhydratedMatch.i match.__beforeLoadContext = deyhydratedMatch.b match.loaderData = deyhydratedMatch.l match.status = deyhydratedMatch.s match.ssr = deyhydratedMatch.ssr match.updatedAt = deyhydratedMatch.u match.error = deyhydratedMatch.e // Only hydrate global-not-found when a defined value is present in the // dehydrated payload. If omitted, preserve the value computed from the // current client location (important for SPA fallback HTML served at unknown // URLs, where dehydrated matches may come from `/` but client matching marks // root as globalNotFound). if (deyhydratedMatch.g !== undefined) { match.globalNotFound = deyhydratedMatch.g } } export async function hydrate(router: AnyRouter): Promise<any> { invariant( window.$_TSR, 'Expected to find bootstrap data on window.$_TSR, but we did not. Please file an issue!', ) const serializationAdapters = router.options.serializationAdapters as | Array<AnySerializationAdapter> | undefined if (serializationAdapters?.length) { const fromSerializableMap = new Map() serializationAdapters.forEach((adapter) => { fromSerializableMap.set(adapter.key, adapter.fromSerializable) }) window.$_TSR.t = fromSerializableMap window.$_TSR.buffer.forEach((script) => script()) } window.$_TSR.initialized = true invariant( window.$_TSR.router, 'Expected to find a dehydrated data on window.$_TSR.router, but we did not. Please file an issue!', ) const dehydratedRouter = window.$_TSR.router dehydratedRouter.matches.forEach((dehydratedMatch) => { dehydratedMatch.i = hydrateSsrMatchId(dehydratedMatch.i) }) if (dehydratedRouter.lastMatchId) { dehydratedRouter.lastMatchId = hydrateSsrMatchId( dehydratedRouter.lastMatchId, ) } const { manifest, dehydratedData, lastMatchId } = dehydratedRouter router.ssr = { manifest, } const meta = document.querySelector('meta[property="csp-nonce"]') as | HTMLMetaElement | undefined const nonce = meta?.content router.options.ssr = { nonce, } // Hydrate the router state const matches = router.matchRoutes(router.state.location) // kick off loading the route chunks const routeChunkPromise = Promise.all( matches.map((match) => router.loadRouteChunk(router.looseRoutesById[match.routeId]!), ), ) function setMatchForcePending(match: AnyRouteMatch) { // usually the minPendingPromise is created in the Match component if a pending match is rendered // however, this might be too late if the match synchronously resolves const route = router.looseRoutesById[match.routeId]! const pendingMinMs = route.options.pendingMinMs ?? router.options.defaultPendingMinMs if (pendingMinMs) { const minPendingPromise = createControlledPromise<void>() match._nonReactive.minPendingPromise = minPendingPromise match._forcePending = true setTimeout(() => { minPendingPromise.resolve() // We've handled the minPendingPromise, so we can delete it router.updateMatch(match.id, (prev) => { prev._nonReactive.minPendingPromise = undefined return { ...prev, _forcePending: undefined, } }) }, pendingMinMs) } } function setRouteSsr(match: AnyRouteMatch) { const route = router.looseRoutesById[match.routeId] if (route) { route.options.ssr = match.ssr } } // Right after hydration and before the first render, we need to rehydrate each match // First step is to reyhdrate loaderData and __beforeLoadContext let firstNonSsrMatchIndex: number | undefined = undefined matches.forEach((match) => { const dehydratedMatch = dehydratedRouter.matches.find( (d) => d.i === match.id, ) if (!dehydratedMatch) { match._nonReactive.dehydrated = false match.ssr = false setRouteSsr(match) return } hydrateMatch(match, dehydratedMatch) setRouteSsr(match) match._nonReactive.dehydrated = match.ssr !== false if (match.ssr === 'data-only' || match.ssr === false) { if (firstNonSsrMatchIndex === undefined) { firstNonSsrMatchIndex = match.index setMatchForcePending(match) } } }) router.__store.setState((s) => ({ ...s, matches, })) // Allow the user to handle custom hydration data await router.options.hydrate?.(dehydratedData) // now that all necessary data is hydrated: // 1) fully reconstruct the route context // 2) execute `head()` and `scripts()` for each match await Promise.all( router.state.matches.map(async (match) => { try { const route = router.looseRoutesById[match.routeId]! const parentMatch = router.state.matches[match.index - 1] const parentContext = parentMatch?.context ?? router.options.context // `context()` was already executed by `matchRoutes`, however route context was not yet fully reconstructed // so run it again and merge route context if (route.options.context) { const contextFnContext: RouteContextOptions<any, any, any, any, any> = { deps: match.loaderDeps, params: match.params, context: parentContext ?? {}, location: router.state.location, navigate: (opts: any) => router.navigate({ ...opts, _fromLocation: router.state.location, }), buildLocation: router.buildLocation, cause: match.cause, abortController: match.abortController, preload: false, matches, routeId: route.id, } match.__routeContext = route.options.context(contextFnContext) ?? undefined } match.context = { ...parentContext, ...match.__routeContext, ...match.__beforeLoadContext, } const assetContext = { ssr: router.options.ssr, matches: router.state.matches, match, params: match.params, loaderData: match.loaderData, } const headFnContent = await route.options.head?.(assetContext) const scripts = await route.options.scripts?.(assetContext) match.meta = headFnContent?.meta match.links = headFnContent?.links match.headScripts = headFnContent?.scripts match.styles = headFnContent?.styles match.scripts = scripts } catch (err) { if (isNotFound(err)) { match.error = { isNotFound: true } console.error( `NotFound error during hydration for routeId: ${match.routeId}`, err, ) } else { match.error = err as any console.error( `Error during hydration for route ${match.routeId}:`, err, ) throw err } } }), ) const isSpaMode = matches[matches.length - 1]!.id !== lastMatchId const hasSsrFalseMatches = matches.some((m) => m.ssr === false) // all matches have data from the server and we are not in SPA mode so we don't need to kick of router.load() if (!hasSsrFalseMatches && !isSpaMode) { matches.forEach((match) => { // remove the dehydrated flag since we won't run router.load() which would remove it match._nonReactive.dehydrated = undefined }) return routeChunkPromise } // schedule router.load() to run after the next tick so we can store the promise in the match before loading starts const loadPromise = Promise.resolve() .then(() => router.load()) .catch((err) => { console.error('Error during router hydration:', err) }) // in SPA mode we need to keep the first match below the root route pending until router.load() is finished // this will prevent that other pending components are rendered but hydration is not blocked if (isSpaMode) { const match = matches[1] invariant( match, 'Expected to find a match below the root match in SPA mode.', ) setMatchForcePending(match) match._displayPending = true match._nonReactive.displayPendingPromise = loadPromise loadPromise.then(() => { batch(() => { // ensure router is not in status 'pending' anymore // this usually happens in Transitioner but if loading synchronously resolves, // Transitioner won't be rendered while loading so it cannot track the change from loading:true to loading:false if (router.__store.state.status === 'pending') { router.__store.setState((s) => ({ ...s, status: 'idle', resolvedLocation: s.location, })) } // hide the pending component once the load is finished router.updateMatch(match.id, (prev) => ({ ...prev, _displayPending: undefined, displayPendingPromise: undefined, })) }) }) } return routeChunkPromise }