Skip to content

Commit 8146db7

Browse files
fix(router-plugin): detect typed root route context for HMR (#7420)
1 parent d1dc4ae commit 8146db7

18 files changed

Lines changed: 437 additions & 26 deletions

.changeset/tidy-root-route-hmr.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/router-plugin': patch
3+
---
4+
5+
Fix Vite Fast Refresh detection for root routes created with `createRootRouteWithContext` type arguments.

packages/router-plugin/src/core/code-splitter/plugins/react-refresh-ignored-route-exports.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const buildReactRefreshIgnoredRouteExportsStatements = template.statements(
77
`
88
const hot = import.meta.hot
99
if (hot && typeof window !== 'undefined') {
10-
;(hot.data ??= {})
10+
hot.data ??= {}
1111
const tsrReactRefresh = window.__TSR_REACT_REFRESH__ ??= (() => {
1212
const ignoredExportsById = new Map()
1313
const previousGetIgnoredExports = window.__getReactRefreshIgnoredExports

packages/router-plugin/src/core/router-code-splitter-plugin.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
tsrSplit,
2222
} from './constants'
2323
import { decodeIdentifier } from './code-splitter/path-ids'
24-
import { debug, normalizePath } from './utils'
24+
import { debug, normalizePath, routeFactoryCallCodeFilter } from './utils'
2525
import { createRouterPluginContext } from './router-plugin-context'
2626
import type { CodeSplitGroupings, SplitRouteIdentNodes } from './constants'
2727
import type { GetRoutesByFileMapResultValue } from '@tanstack/router-generator'
@@ -240,11 +240,6 @@ export function createRouterCodeSplitterPlugin(
240240
return result
241241
}
242242

243-
const includedCode = [
244-
'createFileRoute(',
245-
'createRootRoute(',
246-
'createRootRouteWithContext(',
247-
]
248243
return [
249244
{
250245
name: 'tanstack-router:code-splitter:compile-reference-file',
@@ -258,17 +253,14 @@ export function createRouterCodeSplitterPlugin(
258253
include: /\.(m|c)?(j|t)sx?$/,
259254
},
260255
code: {
261-
include: includedCode,
256+
include: routeFactoryCallCodeFilter,
262257
},
263258
},
264259
handler(code, id) {
265260
const normalizedId = normalizePath(id)
266261
const generatorFileInfo =
267262
routerPluginContext.routesByFile.get(normalizedId)
268-
if (
269-
generatorFileInfo &&
270-
includedCode.some((included) => code.includes(included))
271-
) {
263+
if (generatorFileInfo) {
272264
return handleCompilingReferenceFile(
273265
code,
274266
normalizedId,

packages/router-plugin/src/core/router-hmr-plugin.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { generateFromAst, logDiff, parseAst } from '@tanstack/router-utils'
22
import { compileCodeSplitReferenceRoute } from './code-splitter/compilers'
33
import { getReferenceRouteCompilerPlugins } from './code-splitter/plugins/framework-plugins'
44
import { createRouteHmrStatement } from './hmr'
5-
import { debug, normalizePath } from './utils'
5+
import { debug, normalizePath, routeFactoryCallCodeFilter } from './utils'
66
import { getConfig } from './config'
77
import { createRouterPluginContext } from './router-plugin-context'
88
import type { UnpluginFactory } from 'unplugin'
@@ -15,12 +15,6 @@ import type { RouterPluginContext } from './router-plugin-context'
1515
* handles HMR for code-split routes itself.
1616
*/
1717

18-
const includeCode = [
19-
'createFileRoute(',
20-
'createRootRoute(',
21-
'createRootRouteWithContext(',
22-
]
23-
2418
export function createRouterHmrPlugin(
2519
options: Partial<Config | (() => Config)> | undefined = {},
2620
routerPluginContext: RouterPluginContext,
@@ -41,7 +35,7 @@ export function createRouterHmrPlugin(
4135
// this is necessary for webpack / rspack to avoid matching .html files
4236
id: /\.(m|c)?(j|t)sx?$/,
4337
code: {
44-
include: includeCode,
38+
include: routeFactoryCallCodeFilter,
4539
},
4640
},
4741
handler(code, id) {

packages/router-plugin/src/core/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ export function normalizePath(path: string): string {
1616
return path.replace(/\\/g, '/')
1717
}
1818

19+
export const routeFactoryCallCodeFilter = [
20+
/\bcreateFileRoute\s*\(/,
21+
/\bcreateRootRoute\s*\(/,
22+
/\bcreateRootRouteWithContext\s*(?:<|\()/,
23+
]
24+
1925
export function getObjectPropertyKeyName(
2026
prop: t.ObjectProperty,
2127
): string | undefined {

packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export const Route = createFileRoute('/posts')({
1717
});
1818
const hot = import.meta.hot;
1919
if (hot && typeof window !== 'undefined') {
20-
;
2120
hot.data ??= {};
2221
const tsrReactRefresh = window.__TSR_REACT_REFRESH__ ??= (() => {
2322
const ignoredExportsById = new Map();

packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ export const Route = createRootRoute({
66
});
77
const hot = import.meta.hot;
88
if (hot && typeof window !== 'undefined') {
9-
;
109
hot.data ??= {};
1110
const tsrReactRefresh = window.__TSR_REACT_REFRESH__ ??= (() => {
1211
const ignoredExportsById = new Map();
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import * as React from 'react'
2+
import { createRootRoute } from '@tanstack/react-router'
3+
4+
export const Route = createRootRoute({
5+
component: RootComponent,
6+
})
7+
8+
function RootComponent() {
9+
return <div>Hello</div>
10+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import * as React from 'react';
2+
import { createRootRoute } from '@tanstack/react-router';
3+
export const Route = createRootRoute({
4+
component: RootComponent
5+
});
6+
function RootComponent() {
7+
return <div>Hello</div>;
8+
}
9+
const hot = import.meta.hot;
10+
if (hot && typeof window !== 'undefined') {
11+
hot.data ??= {};
12+
const tsrReactRefresh = window.__TSR_REACT_REFRESH__ ??= (() => {
13+
const ignoredExportsById = new Map();
14+
const previousGetIgnoredExports = window.__getReactRefreshIgnoredExports;
15+
window.__getReactRefreshIgnoredExports = ctx => {
16+
const ignoredExports = previousGetIgnoredExports?.(ctx) ?? [];
17+
const moduleIgnored = ignoredExportsById.get(ctx.id) ?? [];
18+
return [...ignoredExports, ...moduleIgnored];
19+
};
20+
return {
21+
ignoredExportsById
22+
};
23+
})();
24+
tsrReactRefresh.ignoredExportsById.set("createRootRoute.tsx", ['Route']);
25+
}
26+
export function TSRFastRefreshAnchor() {
27+
return null;
28+
}
29+
if (import.meta.hot) {
30+
const hot = import.meta.hot;
31+
const hotData = hot.data ??= {};
32+
hot.accept(newModule => {
33+
if (Route && newModule && newModule.Route) {
34+
const routeId = hotData['tsr-route-id'] ?? Route.id;
35+
if (routeId) {
36+
hotData['tsr-route-id'] = routeId;
37+
}
38+
(function handleRouteUpdate(routeId, newRoute) {
39+
const router = window.__TSR_ROUTER__;
40+
const oldRoute = router.routesById[routeId];
41+
if (!oldRoute) {
42+
return;
43+
}
44+
;
45+
const removedKeys = new Set();
46+
Object.keys(oldRoute.options).forEach(key => {
47+
if (!(key in newRoute.options)) {
48+
removedKeys.add(key);
49+
delete oldRoute.options[key];
50+
}
51+
});
52+
const oldHasShellComponent = "shellComponent" in oldRoute.options;
53+
const newHasShellComponent = "shellComponent" in newRoute.options;
54+
const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent;
55+
const componentKeys = ["component", "shellComponent", "pendingComponent", "errorComponent", "notFoundComponent"];
56+
if (preserveComponentIdentity) {
57+
componentKeys.forEach(key => {
58+
if (key in oldRoute.options && key in newRoute.options) {
59+
newRoute.options[key] = oldRoute.options[key];
60+
}
61+
});
62+
}
63+
;
64+
oldRoute.options = newRoute.options;
65+
oldRoute.update(newRoute.options);
66+
oldRoute._componentsPromise = undefined;
67+
oldRoute._lazyPromise = undefined;
68+
router.routesById[oldRoute.id] = oldRoute;
69+
router.routesByPath[oldRoute.fullPath] = oldRoute;
70+
router.processedTree.matchCache.clear();
71+
router.processedTree.flatCache?.clear();
72+
router.processedTree.singleCache.clear();
73+
router.resolvePathCache.clear();
74+
walkReplaceSegmentTree(oldRoute, router.processedTree.segmentTree);
75+
const filter = m => m.routeId === oldRoute.id;
76+
const activeMatch = router.stores.matches.get().find(filter);
77+
const pendingMatch = router.stores.pendingMatches.get().find(filter);
78+
const cachedMatches = router.stores.cachedMatches.get().filter(filter);
79+
if (activeMatch || pendingMatch || cachedMatches.length > 0) {
80+
if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) {
81+
const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean);
82+
router.batch(() => {
83+
for (const matchId of matchIds) {
84+
const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId);
85+
if (store) {
86+
store.set(prev => {
87+
const next = {
88+
...prev
89+
};
90+
if (removedKeys.has("loader")) {
91+
next.loaderData = undefined;
92+
}
93+
;
94+
if (removedKeys.has("beforeLoad")) {
95+
next.__beforeLoadContext = undefined;
96+
next.context = rebuildMatchContextWithoutBeforeLoad(next);
97+
}
98+
;
99+
return next;
100+
});
101+
}
102+
}
103+
});
104+
}
105+
;
106+
router.invalidate({
107+
filter,
108+
sync: true
109+
});
110+
}
111+
;
112+
function walkReplaceSegmentTree(route, node) {
113+
if (node.route?.id === route.id) node.route = route;
114+
if (node.index) walkReplaceSegmentTree(route, node.index);
115+
node.static?.forEach(child => walkReplaceSegmentTree(route, child));
116+
node.staticInsensitive?.forEach(child => walkReplaceSegmentTree(route, child));
117+
node.dynamic?.forEach(child => walkReplaceSegmentTree(route, child));
118+
node.optional?.forEach(child => walkReplaceSegmentTree(route, child));
119+
node.wildcard?.forEach(child => walkReplaceSegmentTree(route, child));
120+
}
121+
function getStoreMatch(matchId) {
122+
return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get();
123+
}
124+
function getMatchList(matchId) {
125+
const pendingMatches = router.stores.pendingMatches.get();
126+
if (pendingMatches.some(match => match.id === matchId)) {
127+
return pendingMatches;
128+
}
129+
;
130+
const activeMatches = router.stores.matches.get();
131+
if (activeMatches.some(match => match.id === matchId)) {
132+
return activeMatches;
133+
}
134+
;
135+
const cachedMatches = router.stores.cachedMatches.get();
136+
if (cachedMatches.some(match => match.id === matchId)) {
137+
return cachedMatches;
138+
}
139+
;
140+
return [];
141+
}
142+
function getParentMatch(match) {
143+
const matchList = getMatchList(match.id);
144+
const matchIndex = matchList.findIndex(item => item.id === match.id);
145+
if (matchIndex <= 0) {
146+
return undefined;
147+
}
148+
;
149+
const parentMatch = matchList[matchIndex - 1];
150+
return getStoreMatch(parentMatch.id) || parentMatch;
151+
}
152+
function rebuildMatchContextWithoutBeforeLoad(match) {
153+
const parentMatch = getParentMatch(match);
154+
const getParentContext = router.getParentContext;
155+
const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context;
156+
return {
157+
...(parentContext ?? {}),
158+
...(match.__routeContext ?? {})
159+
};
160+
}
161+
})(routeId, newModule.Route);
162+
}
163+
});
164+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as React from 'react'
2+
import { createRootRouteWithContext } from '@tanstack/react-router'
3+
4+
interface MyRouterContext {
5+
auth: boolean
6+
}
7+
8+
export const Route = createRootRouteWithContext<MyRouterContext>()({
9+
component: RootComponent,
10+
})
11+
12+
function RootComponent() {
13+
return <div>Hello</div>
14+
}

0 commit comments

Comments
 (0)