UNPKG

@tanstack/router-plugin

Version:

Modern and scalable routing for React applications

236 lines (206 loc) 7.86 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> routesByPath: Record<string, AnyRoute> 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 } // 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. const removedKeys = new Set<string>() Object.keys(oldRoute.options).forEach((key) => { if (!(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 // 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] } }) } oldRoute.options = newRoute.options oldRoute.update(newRoute.options) oldRoute._componentsPromise = undefined oldRoute._lazyPromise = undefined router.routesById[oldRoute.id] = oldRoute router.routesByPath[oldRoute.fullPath] = oldRoute router.processedTree.matchCache.clear() router.processedTree.flatCache?.clear() router.processedTree.singleCache.clear() router.resolvePathCache.clear() walkReplaceSegmentTree(oldRoute, router.processedTree.segmentTree) 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 walkReplaceSegmentTree( route: AnyRouteWithPrivateProps, node: AnyRouter['processedTree']['segmentTree'], ) { if (node.route?.id === route.id) node.route = route if (node.index) walkReplaceSegmentTree(route, node.index) node.static?.forEach((child) => walkReplaceSegmentTree(route, child)) node.staticInsensitive?.forEach((child) => walkReplaceSegmentTree(route, child), ) node.dynamic?.forEach((child) => walkReplaceSegmentTree(route, child)) node.optional?.forEach((child) => walkReplaceSegmentTree(route, child)) node.wildcard?.forEach((child) => walkReplaceSegmentTree(route, child)) } 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), ) }