Skip to content

Commit 76c4f61

Browse files
authored
fix(solid-router): implement navigation transitions (#5691)
1 parent cbf10db commit 76c4f61

File tree

16 files changed

+505
-108
lines changed

16 files changed

+505
-108
lines changed

e2e/react-router/basic-react-query-file-based/src/routeTree.gen.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
1212
import { Route as PostsRouteImport } from './routes/posts'
1313
import { Route as LayoutRouteImport } from './routes/_layout'
1414
import { Route as IndexRouteImport } from './routes/index'
15+
import { Route as TransitionIndexRouteImport } from './routes/transition/index'
1516
import { Route as PostsIndexRouteImport } from './routes/posts.index'
1617
import { Route as PostsPostIdRouteImport } from './routes/posts.$postId'
1718
import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2'
@@ -32,6 +33,11 @@ const IndexRoute = IndexRouteImport.update({
3233
path: '/',
3334
getParentRoute: () => rootRouteImport,
3435
} as any)
36+
const TransitionIndexRoute = TransitionIndexRouteImport.update({
37+
id: '/transition/',
38+
path: '/transition/',
39+
getParentRoute: () => rootRouteImport,
40+
} as any)
3541
const PostsIndexRoute = PostsIndexRouteImport.update({
3642
id: '/',
3743
path: '/',
@@ -62,13 +68,15 @@ export interface FileRoutesByFullPath {
6268
'/posts': typeof PostsRouteWithChildren
6369
'/posts/$postId': typeof PostsPostIdRoute
6470
'/posts/': typeof PostsIndexRoute
71+
'/transition': typeof TransitionIndexRoute
6572
'/layout-a': typeof LayoutLayout2LayoutARoute
6673
'/layout-b': typeof LayoutLayout2LayoutBRoute
6774
}
6875
export interface FileRoutesByTo {
6976
'/': typeof IndexRoute
7077
'/posts/$postId': typeof PostsPostIdRoute
7178
'/posts': typeof PostsIndexRoute
79+
'/transition': typeof TransitionIndexRoute
7280
'/layout-a': typeof LayoutLayout2LayoutARoute
7381
'/layout-b': typeof LayoutLayout2LayoutBRoute
7482
}
@@ -80,6 +88,7 @@ export interface FileRoutesById {
8088
'/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren
8189
'/posts/$postId': typeof PostsPostIdRoute
8290
'/posts/': typeof PostsIndexRoute
91+
'/transition/': typeof TransitionIndexRoute
8392
'/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute
8493
'/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute
8594
}
@@ -90,10 +99,17 @@ export interface FileRouteTypes {
9099
| '/posts'
91100
| '/posts/$postId'
92101
| '/posts/'
102+
| '/transition'
93103
| '/layout-a'
94104
| '/layout-b'
95105
fileRoutesByTo: FileRoutesByTo
96-
to: '/' | '/posts/$postId' | '/posts' | '/layout-a' | '/layout-b'
106+
to:
107+
| '/'
108+
| '/posts/$postId'
109+
| '/posts'
110+
| '/transition'
111+
| '/layout-a'
112+
| '/layout-b'
97113
id:
98114
| '__root__'
99115
| '/'
@@ -102,6 +118,7 @@ export interface FileRouteTypes {
102118
| '/_layout/_layout-2'
103119
| '/posts/$postId'
104120
| '/posts/'
121+
| '/transition/'
105122
| '/_layout/_layout-2/layout-a'
106123
| '/_layout/_layout-2/layout-b'
107124
fileRoutesById: FileRoutesById
@@ -110,6 +127,7 @@ export interface RootRouteChildren {
110127
IndexRoute: typeof IndexRoute
111128
LayoutRoute: typeof LayoutRouteWithChildren
112129
PostsRoute: typeof PostsRouteWithChildren
130+
TransitionIndexRoute: typeof TransitionIndexRoute
113131
}
114132

115133
declare module '@tanstack/react-router' {
@@ -135,6 +153,13 @@ declare module '@tanstack/react-router' {
135153
preLoaderRoute: typeof IndexRouteImport
136154
parentRoute: typeof rootRouteImport
137155
}
156+
'/transition/': {
157+
id: '/transition/'
158+
path: '/transition'
159+
fullPath: '/transition'
160+
preLoaderRoute: typeof TransitionIndexRouteImport
161+
parentRoute: typeof rootRouteImport
162+
}
138163
'/posts/': {
139164
id: '/posts/'
140165
path: '/'
@@ -214,6 +239,7 @@ const rootRouteChildren: RootRouteChildren = {
214239
IndexRoute: IndexRoute,
215240
LayoutRoute: LayoutRouteWithChildren,
216241
PostsRoute: PostsRouteWithChildren,
242+
TransitionIndexRoute: TransitionIndexRoute,
217243
}
218244
export const routeTree = rootRouteImport
219245
._addFileChildren(rootRouteChildren)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Link, createFileRoute } from '@tanstack/react-router'
2+
import { Suspense, useMemo } from 'react'
3+
import { queryOptions, useQuery } from '@tanstack/react-query'
4+
import { z } from 'zod'
5+
6+
const searchSchema = z.object({
7+
n: z.number().default(1),
8+
})
9+
10+
const doubleQueryOptions = (n: number) =>
11+
queryOptions({
12+
queryKey: ['transition-double', n],
13+
queryFn: async () => {
14+
await new Promise((resolve) => setTimeout(resolve, 1000))
15+
return n * 2
16+
},
17+
placeholderData: (oldData) => oldData,
18+
})
19+
20+
export const Route = createFileRoute('/transition/')({
21+
validateSearch: searchSchema,
22+
loader: ({ context: { queryClient }, location }) => {
23+
const { n } = searchSchema.parse(location.search)
24+
return queryClient.ensureQueryData(doubleQueryOptions(n))
25+
},
26+
component: TransitionPage,
27+
})
28+
29+
function TransitionPage() {
30+
const search = Route.useSearch()
31+
32+
const doubleQuery = useQuery(doubleQueryOptions(search.n))
33+
34+
return (
35+
<Suspense fallback="Loading...">
36+
<div className="p-2">
37+
<Link
38+
data-testid="increase-button"
39+
className="border bg-gray-50 px-3 py-1"
40+
from="/transition"
41+
search={(s) => ({ n: s.n + 1 })}
42+
>
43+
Increase
44+
</Link>
45+
46+
<div className="mt-2">
47+
<div data-testid="n-value">n: {search.n}</div>
48+
<div data-testid="double-value">double: {doubleQuery.data}</div>
49+
</div>
50+
</div>
51+
</Suspense>
52+
)
53+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { expect, test } from '@playwright/test'
2+
3+
test('react-query transitions keep previous data during navigation', async ({
4+
page,
5+
}) => {
6+
await page.goto('/transition')
7+
8+
await expect(page.getByTestId('n-value')).toContainText('n: 1')
9+
await expect(page.getByTestId('double-value')).toContainText('double: 2')
10+
11+
const bodySnapshots: Array<string> = []
12+
13+
const interval = setInterval(async () => {
14+
const text = await page
15+
.locator('body')
16+
.textContent()
17+
.catch(() => '')
18+
if (text) bodySnapshots.push(text)
19+
}, 50)
20+
21+
await page.getByTestId('increase-button').click()
22+
23+
await page.waitForTimeout(200)
24+
25+
clearInterval(interval)
26+
27+
await expect(page.getByTestId('n-value')).toContainText('n: 2', {
28+
timeout: 2_000,
29+
})
30+
await expect(page.getByTestId('double-value')).toContainText('double: 4', {
31+
timeout: 2_000,
32+
})
33+
34+
const sawLoading = bodySnapshots.some((text) => text.includes('Loading...'))
35+
36+
expect(sawLoading).toBeFalsy()
37+
})

e2e/solid-router/basic-file-based/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { Route as LayoutRouteImport } from './routes/_layout'
2121
import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route'
2222
import { Route as NonNestedRouteRouteImport } from './routes/non-nested/route'
2323
import { Route as IndexRouteImport } from './routes/index'
24+
import { Route as TransitionIndexRouteImport } from './routes/transition/index'
2425
import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index'
2526
import { Route as RelativeIndexRouteImport } from './routes/relative/index'
2627
import { Route as RedirectIndexRouteImport } from './routes/redirect/index'
@@ -158,6 +159,11 @@ const IndexRoute = IndexRouteImport.update({
158159
path: '/',
159160
getParentRoute: () => rootRouteImport,
160161
} as any)
162+
const TransitionIndexRoute = TransitionIndexRouteImport.update({
163+
id: '/transition/',
164+
path: '/transition/',
165+
getParentRoute: () => rootRouteImport,
166+
} as any)
161167
const SearchParamsIndexRoute = SearchParamsIndexRouteImport.update({
162168
id: '/',
163169
path: '/',
@@ -600,6 +606,7 @@ export interface FileRoutesByFullPath {
600606
'/redirect': typeof RedirectIndexRoute
601607
'/relative': typeof RelativeIndexRoute
602608
'/search-params/': typeof SearchParamsIndexRoute
609+
'/transition': typeof TransitionIndexRoute
603610
'/non-nested/named/$baz': typeof NonNestedNamedBazRouteRouteWithChildren
604611
'/non-nested/path/baz': typeof NonNestedPathBazRouteRouteWithChildren
605612
'/non-nested/prefix/prefix{$baz}': typeof NonNestedPrefixPrefixChar123bazChar125RouteRouteWithChildren
@@ -684,6 +691,7 @@ export interface FileRoutesByTo {
684691
'/redirect': typeof RedirectIndexRoute
685692
'/relative': typeof RelativeIndexRoute
686693
'/search-params': typeof SearchParamsIndexRoute
694+
'/transition': typeof TransitionIndexRoute
687695
'/params-ps/named/$foo': typeof ParamsPsNamedFooRouteRouteWithChildren
688696
'/params-ps/non-nested/$foo': typeof ParamsPsNonNestedFooRouteRouteWithChildren
689697
'/insidelayout': typeof groupLayoutInsidelayoutRoute
@@ -771,6 +779,7 @@ export interface FileRoutesById {
771779
'/redirect/': typeof RedirectIndexRoute
772780
'/relative/': typeof RelativeIndexRoute
773781
'/search-params/': typeof SearchParamsIndexRoute
782+
'/transition/': typeof TransitionIndexRoute
774783
'/non-nested/named/$baz': typeof NonNestedNamedBazRouteRouteWithChildren
775784
'/non-nested/path/baz': typeof NonNestedPathBazRouteRouteWithChildren
776785
'/non-nested/prefix/prefix{$baz}': typeof NonNestedPrefixPrefixChar123bazChar125RouteRouteWithChildren
@@ -860,6 +869,7 @@ export interface FileRouteTypes {
860869
| '/redirect'
861870
| '/relative'
862871
| '/search-params/'
872+
| '/transition'
863873
| '/non-nested/named/$baz'
864874
| '/non-nested/path/baz'
865875
| '/non-nested/prefix/prefix{$baz}'
@@ -944,6 +954,7 @@ export interface FileRouteTypes {
944954
| '/redirect'
945955
| '/relative'
946956
| '/search-params'
957+
| '/transition'
947958
| '/params-ps/named/$foo'
948959
| '/params-ps/non-nested/$foo'
949960
| '/insidelayout'
@@ -1030,6 +1041,7 @@ export interface FileRouteTypes {
10301041
| '/redirect/'
10311042
| '/relative/'
10321043
| '/search-params/'
1044+
| '/transition/'
10331045
| '/non-nested/named/$baz'
10341046
| '/non-nested/path/baz'
10351047
| '/non-nested/prefix/prefix{$baz}'
@@ -1112,6 +1124,7 @@ export interface RootRouteChildren {
11121124
ParamsPsIndexRoute: typeof ParamsPsIndexRoute
11131125
RedirectIndexRoute: typeof RedirectIndexRoute
11141126
RelativeIndexRoute: typeof RelativeIndexRoute
1127+
TransitionIndexRoute: typeof TransitionIndexRoute
11151128
ParamsPsNamedFooRouteRoute: typeof ParamsPsNamedFooRouteRouteWithChildren
11161129
groupSubfolderInsideRoute: typeof groupSubfolderInsideRoute
11171130
ParamsPsNamedPrefixChar123fooChar125Route: typeof ParamsPsNamedPrefixChar123fooChar125Route
@@ -1216,6 +1229,13 @@ declare module '@tanstack/solid-router' {
12161229
preLoaderRoute: typeof IndexRouteImport
12171230
parentRoute: typeof rootRouteImport
12181231
}
1232+
'/transition/': {
1233+
id: '/transition/'
1234+
path: '/transition'
1235+
fullPath: '/transition'
1236+
preLoaderRoute: typeof TransitionIndexRouteImport
1237+
parentRoute: typeof rootRouteImport
1238+
}
12191239
'/search-params/': {
12201240
id: '/search-params/'
12211241
path: '/'
@@ -2104,6 +2124,7 @@ const rootRouteChildren: RootRouteChildren = {
21042124
ParamsPsIndexRoute: ParamsPsIndexRoute,
21052125
RedirectIndexRoute: RedirectIndexRoute,
21062126
RelativeIndexRoute: RelativeIndexRoute,
2127+
TransitionIndexRoute: TransitionIndexRoute,
21072128
ParamsPsNamedFooRouteRoute: ParamsPsNamedFooRouteRouteWithChildren,
21082129
groupSubfolderInsideRoute: groupSubfolderInsideRoute,
21092130
ParamsPsNamedPrefixChar123fooChar125Route:
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Link, createFileRoute } from '@tanstack/solid-router'
2+
import { Suspense, createResource } from 'solid-js'
3+
import { z } from 'zod'
4+
5+
export const Route = createFileRoute('/transition/')({
6+
validateSearch: z.object({
7+
n: z.number().default(1),
8+
}),
9+
component: Home,
10+
})
11+
12+
function Home() {
13+
return (
14+
<div class="p-2">
15+
<Link
16+
data-testid="increase-button"
17+
class="border bg-gray-50 px-3 py-1"
18+
from="/transition"
19+
search={(s) => ({ n: s.n + 1 })}
20+
>
21+
Increase
22+
</Link>
23+
24+
<Result />
25+
</div>
26+
)
27+
}
28+
29+
function Result() {
30+
const searchQuery = Route.useSearch()
31+
32+
const [doubleQuery] = createResource(
33+
() => searchQuery().n,
34+
async (n) => {
35+
await new Promise((r) => setTimeout(r, 1000))
36+
return n * 2
37+
},
38+
)
39+
40+
return (
41+
<div class="mt-2">
42+
<Suspense fallback="Loading...">
43+
<div data-testid="n-value">n: {searchQuery().n}</div>
44+
<div data-testid="double-value">double: {doubleQuery()}</div>
45+
</Suspense>
46+
</div>
47+
)
48+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { expect, test } from '@playwright/test'
2+
3+
test('transitions should keep old values visible during navigation', async ({
4+
page,
5+
}) => {
6+
await page.goto('/transition')
7+
8+
await expect(page.getByTestId('n-value')).toContainText('n: 1')
9+
await expect(page.getByTestId('double-value')).toContainText('double: 2')
10+
11+
const bodyTexts: Array<string> = []
12+
13+
const pollInterval = setInterval(async () => {
14+
const text = await page
15+
.locator('body')
16+
.textContent()
17+
.catch(() => '')
18+
if (text) bodyTexts.push(text)
19+
}, 50)
20+
21+
await page.getByTestId('increase-button').click()
22+
23+
await page.waitForTimeout(200)
24+
25+
clearInterval(pollInterval)
26+
27+
await expect(page.getByTestId('n-value')).toContainText('n: 2', {
28+
timeout: 2000,
29+
})
30+
await expect(page.getByTestId('double-value')).toContainText('double: 4', {
31+
timeout: 2000,
32+
})
33+
34+
// With proper transitions, old values should remain visible until new ones arrive
35+
const hasLoadingText = bodyTexts.some((text) => text.includes('Loading...'))
36+
37+
if (hasLoadingText) {
38+
throw new Error(
39+
'FAILED: "Loading..." appeared during navigation. ' +
40+
'Solid Router should use transitions to keep old values visible.',
41+
)
42+
}
43+
})

0 commit comments

Comments
 (0)