@tanstack/router-core
Version:
Modern and scalable routing for React applications
295 lines (264 loc) • 9.87 kB
text/typescript
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
}