UNPKG

@tanstack/router-plugin

Version:

Modern and scalable routing for React applications

231 lines (201 loc) 7.57 kB
import type { AnyRoute, AnyRouteMatch, AnyRouter, RouterWritableStore, } from '@tanstack/router-core' type AnyRouteWithPrivateProps = AnyRoute & { options: Record<string, unknown> _componentsPromise?: Promise<void> _lazyPromise?: Promise<void> update: (options: Record<string, unknown>) => unknown _path: string _id: string _fullPath: string _to: string } type AnyRouterWithPrivateMaps = AnyRouter & { routesById: Record<string, AnyRoute> buildRouteTree: () => Parameters<AnyRouter['setRoutes']>[0] setRoutes: AnyRouter['setRoutes'] stores: AnyRouter['stores'] & { cachedMatchStores: Map< string, Pick<RouterWritableStore<AnyRouteMatch>, 'get' | 'set'> > pendingMatchStores: Map< string, Pick<RouterWritableStore<AnyRouteMatch>, 'get' | 'set'> > matchStores: Map< string, Pick<RouterWritableStore<AnyRouteMatch>, 'get' | 'set'> > } } type AnyRouteMatchWithPrivateProps = AnyRouteMatch & { __beforeLoadContext?: unknown __routeContext?: Record<string, unknown> context?: Record<string, unknown> } function handleRouteUpdate( routeId: string, newRoute: AnyRouteWithPrivateProps, ) { const router = window.__TSR_ROUTER__ as AnyRouterWithPrivateMaps const oldRoute = router.routesById[routeId] as | AnyRouteWithPrivateProps | undefined if (!oldRoute) { return } // Generated route-tree options are not present on the freshly imported route // module, but they must stay on the live route before rebuilding indexes. const generatedRouteOptionKeys = new Set(['id', 'path', 'getParentRoute']) const generatedRouteOptions: Record<string, unknown> = {} generatedRouteOptionKeys.forEach((key) => { if (key in oldRoute.options) { generatedRouteOptions[key] = oldRoute.options[key] } }) const removedKeys = new Set<string>() Object.keys(oldRoute.options).forEach((key) => { if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { removedKeys.add(key) delete oldRoute.options[key] } }) const oldHasShellComponent = 'shellComponent' in oldRoute.options const newHasShellComponent = 'shellComponent' in newRoute.options const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent // Keys whose identity must remain stable to prevent React from // unmounting/remounting the component tree. React Fast Refresh already // handles hot-updating the function bodies of these components — our job // is only to update non-component route options (loader, head, etc.). // For code-split (splittable) routes, the lazyRouteComponent wrapper is // already cached in the bundler hot data so its identity is stable. // For unsplittable routes (e.g. root routes), the component is a plain // function reference that gets recreated on every module re-execution, // so we must explicitly preserve the old reference. // Preserve component identity so React doesn't remount. // React Fast Refresh patches the function bodies in-place. const componentKeys = '__TSR_COMPONENT_TYPES__' as unknown as Array<string> if (preserveComponentIdentity) { componentKeys.forEach((key) => { if (key in oldRoute.options && key in newRoute.options) { newRoute.options[key] = oldRoute.options[key] } }) } const nextOptions = { ...newRoute.options, ...generatedRouteOptions, } oldRoute.options = nextOptions oldRoute.update(nextOptions) oldRoute._componentsPromise = undefined oldRoute._lazyPromise = undefined router.setRoutes(router.buildRouteTree()) router.resolvePathCache.clear() const filter = (m: AnyRouteMatch) => m.routeId === oldRoute.id const activeMatch = router.stores.matches.get().find(filter) const pendingMatch = router.stores.pendingMatches.get().find(filter) const cachedMatches = router.stores.cachedMatches.get().filter(filter) if (activeMatch || pendingMatch || cachedMatches.length > 0) { // Clear stale match data for removed route options BEFORE invalidating. // Without this, router.invalidate() -> matchRoutes() reuses the existing // match from the store (via ...existingMatch spread) and the stale // loaderData / __beforeLoadContext survives the reload cycle. // // We must update the store directly (not via router.updateMatch) because // updateMatch wraps in startTransition which may defer the state update, // and we need the clear to be visible before invalidate reads the store. if (removedKeys.has('loader') || removedKeys.has('beforeLoad')) { const matchIds = [ activeMatch?.id, pendingMatch?.id, ...cachedMatches.map((match) => match.id), ].filter(Boolean) as Array<string> router.batch(() => { for (const matchId of matchIds) { const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId) if (store) { store.set((prev) => { const next: AnyRouteMatchWithPrivateProps = { ...prev } if (removedKeys.has('loader')) { next.loaderData = undefined } if (removedKeys.has('beforeLoad')) { next.__beforeLoadContext = undefined next.context = rebuildMatchContextWithoutBeforeLoad(next) } return next }) } } }) } router.invalidate({ filter, sync: true }) } function getStoreMatch(matchId: string) { return ( router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get() ) } function getMatchList(matchId: string) { const pendingMatches = router.stores.pendingMatches.get() if (pendingMatches.some((match) => match.id === matchId)) { return pendingMatches } const activeMatches = router.stores.matches.get() if (activeMatches.some((match) => match.id === matchId)) { return activeMatches } const cachedMatches = router.stores.cachedMatches.get() if (cachedMatches.some((match) => match.id === matchId)) { return cachedMatches } return [] } function getParentMatch(match: AnyRouteMatch) { const matchList = getMatchList(match.id) const matchIndex = matchList.findIndex((item) => item.id === match.id) if (matchIndex <= 0) { return undefined } const parentMatch = matchList[matchIndex - 1]! return getStoreMatch(parentMatch.id) || parentMatch } function rebuildMatchContextWithoutBeforeLoad( match: AnyRouteMatchWithPrivateProps, ) { const parentMatch = getParentMatch(match) const getParentContext = ( router as unknown as { getParentContext?: ( parentMatch?: AnyRouteMatch, ) => Record<string, unknown> | undefined } ).getParentContext const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : (parentMatch?.context ?? router.options.context) return { ...(parentContext ?? {}), ...(match.__routeContext ?? {}), } } } const handleRouteUpdateStr = handleRouteUpdate.toString() export function getHandleRouteUpdateCode(stableRouteOptionKeys: Array<string>) { return handleRouteUpdateStr.replace( /['"]__TSR_COMPONENT_TYPES__['"]/, JSON.stringify(stableRouteOptionKeys), ) }