310 lines
10 KiB
Plaintext
310 lines
10 KiB
Plaintext
import { invariant } from '../invariant'
|
|
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> {
|
|
if (!window.$_TSR) {
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
throw new Error(
|
|
'Invariant failed: Expected to find bootstrap data on window.$_TSR, but we did not. Please file an issue!',
|
|
)
|
|
}
|
|
|
|
invariant()
|
|
}
|
|
|
|
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
|
|
|
|
if (!window.$_TSR.router) {
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
throw new Error(
|
|
'Invariant failed: Expected to find a dehydrated data on window.$_TSR.router, but we did not. Please file an issue!',
|
|
)
|
|
}
|
|
|
|
invariant()
|
|
}
|
|
|
|
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.stores.location.get())
|
|
|
|
// 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.stores.setMatches(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
|
|
const activeMatches = router.stores.matches.get()
|
|
const location = router.stores.location.get()
|
|
await Promise.all(
|
|
activeMatches.map(async (match) => {
|
|
try {
|
|
const route = router.looseRoutesById[match.routeId]!
|
|
|
|
const parentMatch = activeMatches[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,
|
|
navigate: (opts: any) =>
|
|
router.navigate({
|
|
...opts,
|
|
_fromLocation: 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: activeMatches,
|
|
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
|
|
})
|
|
// Mark the current location as resolved so that later load cycles
|
|
// (e.g. preloads, invalidations) don't mistakenly detect a href change
|
|
// (resolvedLocation defaults to undefined and router.load() is skipped
|
|
// in the normal SSR hydration path).
|
|
router.stores.resolvedLocation.set(router.stores.location.get())
|
|
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]
|
|
if (!match) {
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
throw new Error(
|
|
'Invariant failed: Expected to find a match below the root match in SPA mode.',
|
|
)
|
|
}
|
|
|
|
invariant()
|
|
}
|
|
setMatchForcePending(match)
|
|
|
|
match._displayPending = true
|
|
match._nonReactive.displayPendingPromise = loadPromise
|
|
|
|
loadPromise.then(() => {
|
|
router.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.stores.status.get() === 'pending') {
|
|
router.stores.status.set('idle')
|
|
router.stores.resolvedLocation.set(router.stores.location.get())
|
|
}
|
|
// hide the pending component once the load is finished
|
|
router.updateMatch(match.id, (prev) => ({
|
|
...prev,
|
|
_displayPending: undefined,
|
|
displayPendingPromise: undefined,
|
|
}))
|
|
})
|
|
})
|
|
}
|
|
return routeChunkPromise
|
|
}
|