diff --git a/e2e/react-router/basic-react-query-file-based/src/routes/transition/count/query.tsx b/e2e/react-router/basic-react-query-file-based/src/routes/transition/count/query.tsx
index ebd580af9ad..5bd2ff4341a 100644
--- a/e2e/react-router/basic-react-query-file-based/src/routes/transition/count/query.tsx
+++ b/e2e/react-router/basic-react-query-file-based/src/routes/transition/count/query.tsx
@@ -1,5 +1,5 @@
import { Link, createFileRoute } from '@tanstack/react-router'
-import { Suspense, useMemo } from 'react'
+import { Suspense } from 'react'
import { queryOptions, useQuery } from '@tanstack/react-query'
import { z } from 'zod'
@@ -12,7 +12,7 @@ const doubleQueryOptions = (n: number) =>
queryKey: ['transition-double', n],
queryFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000))
- return n * 2
+ return { n, double: n * 2 }
},
placeholderData: (oldData) => oldData,
})
@@ -44,8 +44,10 @@ function TransitionPage() {
-
n: {search.n}
-
double: {doubleQuery.data}
+
n: {doubleQuery.data?.n}
+
+ double: {doubleQuery.data?.double}
+
diff --git a/e2e/react-router/basic-react-query-file-based/tests/transition.spec.ts b/e2e/react-router/basic-react-query-file-based/tests/transition.spec.ts
index 86110dcdc85..27ea079ca4e 100644
--- a/e2e/react-router/basic-react-query-file-based/tests/transition.spec.ts
+++ b/e2e/react-router/basic-react-query-file-based/tests/transition.spec.ts
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'
-test('react-query transitions keep previous data during navigation', async ({
+test('transitions/count/query should keep old values visible during navigation', async ({
page,
}) => {
await page.goto('/transition/count/query')
@@ -8,30 +8,88 @@ test('react-query transitions keep previous data during navigation', async ({
await expect(page.getByTestId('n-value')).toContainText('n: 1')
await expect(page.getByTestId('double-value')).toContainText('double: 2')
- const bodySnapshots: Array = []
+ const bodyTexts: Array = []
- const interval = setInterval(async () => {
+ const pollInterval = setInterval(async () => {
const text = await page
.locator('body')
.textContent()
.catch(() => '')
- if (text) bodySnapshots.push(text)
+ if (text) bodyTexts.push(text)
}, 50)
- await page.getByTestId('increase-button').click()
+ // 1 click
+
+ page.getByTestId('increase-button').click()
+
+ await expect(page.getByTestId('n-value')).toContainText('n: 1', {
+ timeout: 2_000,
+ })
+ await expect(page.getByTestId('double-value')).toContainText('double: 2', {
+ timeout: 2_000,
+ })
await page.waitForTimeout(200)
- clearInterval(interval)
+ await expect(page.getByTestId('n-value')).toContainText('n: 2', {
+ timeout: 2000,
+ })
+ await expect(page.getByTestId('double-value')).toContainText('double: 4', {
+ timeout: 2000,
+ })
+
+ // 2 clicks
+
+ page.getByTestId('increase-button').click()
+ page.getByTestId('increase-button').click()
await expect(page.getByTestId('n-value')).toContainText('n: 2', {
- timeout: 2_000,
+ timeout: 2000,
})
await expect(page.getByTestId('double-value')).toContainText('double: 4', {
- timeout: 2_000,
+ timeout: 2000,
+ })
+
+ await page.waitForTimeout(200)
+
+ await expect(page.getByTestId('n-value')).toContainText('n: 4', {
+ timeout: 2000,
+ })
+ await expect(page.getByTestId('double-value')).toContainText('double: 8', {
+ timeout: 2000,
+ })
+
+ // 3 clicks
+
+ page.getByTestId('increase-button').click()
+ page.getByTestId('increase-button').click()
+ page.getByTestId('increase-button').click()
+
+ await expect(page.getByTestId('n-value')).toContainText('n: 4', {
+ timeout: 2000,
})
+ await expect(page.getByTestId('double-value')).toContainText('double: 8', {
+ timeout: 2000,
+ })
+
+ await page.waitForTimeout(200)
+
+ await expect(page.getByTestId('n-value')).toContainText('n: 7', {
+ timeout: 2000,
+ })
+ await expect(page.getByTestId('double-value')).toContainText('double: 14', {
+ timeout: 2000,
+ })
+
+ clearInterval(pollInterval)
- const sawLoading = bodySnapshots.some((text) => text.includes('Loading...'))
+ // With proper transitions, old values should remain visible until new ones arrive
+ const hasLoadingText = bodyTexts.some((text) => text.includes('Loading...'))
- expect(sawLoading).toBeFalsy()
+ if (hasLoadingText) {
+ throw new Error(
+ 'FAILED: "Loading..." appeared during navigation. ' +
+ 'Solid Router should use transitions to keep old values visible.',
+ )
+ }
})
diff --git a/e2e/react-start/basic-react-query/package.json b/e2e/react-start/basic-react-query/package.json
index 5a5d538fba1..72881176eab 100644
--- a/e2e/react-start/basic-react-query/package.json
+++ b/e2e/react-start/basic-react-query/package.json
@@ -21,7 +21,8 @@
"react-dom": "^19.0.0",
"redaxios": "^0.5.1",
"tailwind-merge": "^2.6.0",
- "vite": "^7.1.7"
+ "vite": "^7.1.7",
+ "zod": "^4.1.12"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
diff --git a/e2e/react-start/basic-react-query/src/routeTree.gen.ts b/e2e/react-start/basic-react-query/src/routeTree.gen.ts
index c39b0ec44e0..c140825485d 100644
--- a/e2e/react-start/basic-react-query/src/routeTree.gen.ts
+++ b/e2e/react-start/basic-react-query/src/routeTree.gen.ts
@@ -22,6 +22,7 @@ import { Route as UsersUserIdRouteImport } from './routes/users.$userId'
import { Route as PostsPostIdRouteImport } from './routes/posts.$postId'
import { Route as ApiUsersRouteImport } from './routes/api.users'
import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2'
+import { Route as TransitionCountQueryRouteImport } from './routes/transition/count/query'
import { Route as PostsPostIdDeepRouteImport } from './routes/posts_.$postId.deep'
import { Route as ApiUsersIdRouteImport } from './routes/api/users.$id'
import { Route as LayoutLayout2LayoutBRouteImport } from './routes/_layout/_layout-2/layout-b'
@@ -90,6 +91,11 @@ const LayoutLayout2Route = LayoutLayout2RouteImport.update({
id: '/_layout-2',
getParentRoute: () => LayoutRoute,
} as any)
+const TransitionCountQueryRoute = TransitionCountQueryRouteImport.update({
+ id: '/transition/count/query',
+ path: '/transition/count/query',
+ getParentRoute: () => rootRouteImport,
+} as any)
const PostsPostIdDeepRoute = PostsPostIdDeepRouteImport.update({
id: '/posts_/$postId/deep',
path: '/posts/$postId/deep',
@@ -127,6 +133,7 @@ export interface FileRoutesByFullPath {
'/layout-b': typeof LayoutLayout2LayoutBRoute
'/api/users/$id': typeof ApiUsersIdRoute
'/posts/$postId/deep': typeof PostsPostIdDeepRoute
+ '/transition/count/query': typeof TransitionCountQueryRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -142,6 +149,7 @@ export interface FileRoutesByTo {
'/layout-b': typeof LayoutLayout2LayoutBRoute
'/api/users/$id': typeof ApiUsersIdRoute
'/posts/$postId/deep': typeof PostsPostIdDeepRoute
+ '/transition/count/query': typeof TransitionCountQueryRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -162,6 +170,7 @@ export interface FileRoutesById {
'/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute
'/api/users/$id': typeof ApiUsersIdRoute
'/posts_/$postId/deep': typeof PostsPostIdDeepRoute
+ '/transition/count/query': typeof TransitionCountQueryRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -181,6 +190,7 @@ export interface FileRouteTypes {
| '/layout-b'
| '/api/users/$id'
| '/posts/$postId/deep'
+ | '/transition/count/query'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -196,6 +206,7 @@ export interface FileRouteTypes {
| '/layout-b'
| '/api/users/$id'
| '/posts/$postId/deep'
+ | '/transition/count/query'
id:
| '__root__'
| '/'
@@ -215,6 +226,7 @@ export interface FileRouteTypes {
| '/_layout/_layout-2/layout-b'
| '/api/users/$id'
| '/posts_/$postId/deep'
+ | '/transition/count/query'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -227,6 +239,7 @@ export interface RootRouteChildren {
UsersRoute: typeof UsersRouteWithChildren
ApiUsersRoute: typeof ApiUsersRouteWithChildren
PostsPostIdDeepRoute: typeof PostsPostIdDeepRoute
+ TransitionCountQueryRoute: typeof TransitionCountQueryRoute
}
declare module '@tanstack/react-router' {
@@ -322,6 +335,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutLayout2RouteImport
parentRoute: typeof LayoutRoute
}
+ '/transition/count/query': {
+ id: '/transition/count/query'
+ path: '/transition/count/query'
+ fullPath: '/transition/count/query'
+ preLoaderRoute: typeof TransitionCountQueryRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/posts_/$postId/deep': {
id: '/posts_/$postId/deep'
path: '/posts/$postId/deep'
@@ -424,6 +444,7 @@ const rootRouteChildren: RootRouteChildren = {
UsersRoute: UsersRouteWithChildren,
ApiUsersRoute: ApiUsersRouteWithChildren,
PostsPostIdDeepRoute: PostsPostIdDeepRoute,
+ TransitionCountQueryRoute: TransitionCountQueryRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/e2e/react-start/basic-react-query/src/routes/transition/count/query.tsx b/e2e/react-start/basic-react-query/src/routes/transition/count/query.tsx
new file mode 100644
index 00000000000..5bd2ff4341a
--- /dev/null
+++ b/e2e/react-start/basic-react-query/src/routes/transition/count/query.tsx
@@ -0,0 +1,55 @@
+import { Link, createFileRoute } from '@tanstack/react-router'
+import { Suspense } from 'react'
+import { queryOptions, useQuery } from '@tanstack/react-query'
+import { z } from 'zod'
+
+const searchSchema = z.object({
+ n: z.number().default(1),
+})
+
+const doubleQueryOptions = (n: number) =>
+ queryOptions({
+ queryKey: ['transition-double', n],
+ queryFn: async () => {
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+ return { n, double: n * 2 }
+ },
+ placeholderData: (oldData) => oldData,
+ })
+
+export const Route = createFileRoute('/transition/count/query')({
+ validateSearch: searchSchema,
+ loader: ({ context: { queryClient }, location }) => {
+ const { n } = searchSchema.parse(location.search)
+ return queryClient.ensureQueryData(doubleQueryOptions(n))
+ },
+ component: TransitionPage,
+})
+
+function TransitionPage() {
+ const search = Route.useSearch()
+
+ const doubleQuery = useQuery(doubleQueryOptions(search.n))
+
+ return (
+
+
+
({ n: s.n + 1 })}
+ >
+ Increase
+
+
+
+
n: {doubleQuery.data?.n}
+
+ double: {doubleQuery.data?.double}
+
+
+
+
+ )
+}
diff --git a/e2e/react-start/basic-react-query/tests/transition.spec.ts b/e2e/react-start/basic-react-query/tests/transition.spec.ts
new file mode 100644
index 00000000000..2c28fc72a28
--- /dev/null
+++ b/e2e/react-start/basic-react-query/tests/transition.spec.ts
@@ -0,0 +1,95 @@
+import { expect, test } from '@playwright/test'
+
+test('transitions/count/query should keep old values visible during navigation', async ({
+ page,
+}) => {
+ await page.goto('/transition/count/query')
+
+ await expect(page.getByTestId('n-value')).toContainText('n: 1')
+ await expect(page.getByTestId('double-value')).toContainText('double: 2')
+
+ const bodyTexts: Array = []
+
+ const pollInterval = setInterval(async () => {
+ const text = await page
+ .locator('body')
+ .textContent()
+ .catch(() => '')
+ if (text) bodyTexts.push(text)
+ }, 50)
+
+ // 1 click
+
+ page.getByTestId('increase-button').click()
+
+ await expect(page.getByTestId('n-value')).toContainText('n: 1', {
+ timeout: 2_000,
+ })
+ await expect(page.getByTestId('double-value')).toContainText('double: 2', {
+ timeout: 2_000,
+ })
+
+ await page.waitForTimeout(200)
+
+ await expect(page.getByTestId('n-value')).toContainText('n: 2', {
+ timeout: 2000,
+ })
+ await expect(page.getByTestId('double-value')).toContainText('double: 4', {
+ timeout: 2000,
+ })
+
+ // 2 clicks
+
+ // page.getByTestId('increase-button').click()
+ // page.getByTestId('increase-button').click()
+
+ // await expect(page.getByTestId('n-value')).toContainText('n: 2', {
+ // timeout: 2000,
+ // })
+ // await expect(page.getByTestId('double-value')).toContainText('double: 4', {
+ // timeout: 2000,
+ // })
+
+ // await page.waitForTimeout(200)
+
+ // await expect(page.getByTestId('n-value')).toContainText('n: 4', {
+ // timeout: 2000,
+ // })
+ // await expect(page.getByTestId('double-value')).toContainText('double: 8', {
+ // timeout: 2000,
+ // })
+
+ // // 3 clicks
+
+ // page.getByTestId('increase-button').click()
+ // page.getByTestId('increase-button').click()
+ // page.getByTestId('increase-button').click()
+
+ // await expect(page.getByTestId('n-value')).toContainText('n: 4', {
+ // timeout: 2000,
+ // })
+ // await expect(page.getByTestId('double-value')).toContainText('double: 8', {
+ // timeout: 2000,
+ // })
+
+ // await page.waitForTimeout(200)
+
+ // await expect(page.getByTestId('n-value')).toContainText('n: 7', {
+ // timeout: 2000,
+ // })
+ // await expect(page.getByTestId('double-value')).toContainText('double: 14', {
+ // timeout: 2000,
+ // })
+
+ clearInterval(pollInterval)
+
+ // With proper transitions, old values should remain visible until new ones arrive
+ const hasLoadingText = bodyTexts.some((text) => text.includes('Loading...'))
+
+ if (hasLoadingText) {
+ throw new Error(
+ 'FAILED: "Loading..." appeared during navigation. ' +
+ 'Solid Router should use transitions to keep old values visible.',
+ )
+ }
+})
diff --git a/e2e/solid-router/basic-solid-query-file-based/src/routes/transition/count/query.tsx b/e2e/solid-router/basic-solid-query-file-based/src/routes/transition/count/query.tsx
index 14964faad0d..256b097dd94 100644
--- a/e2e/solid-router/basic-solid-query-file-based/src/routes/transition/count/query.tsx
+++ b/e2e/solid-router/basic-solid-query-file-based/src/routes/transition/count/query.tsx
@@ -12,7 +12,7 @@ const doubleQueryOptions = (n: number) =>
queryKey: ['transition-double', n],
queryFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000))
- return n * 2
+ return { n, double: n * 2 }
},
placeholderData: (oldData) => oldData,
})
@@ -44,8 +44,10 @@ function TransitionPage() {
-
n: {search().n}
-
double: {doubleQuery.data}
+
n: {doubleQuery.data?.n}
+
+ double: {doubleQuery.data?.double}
+
diff --git a/e2e/solid-router/basic-solid-query-file-based/tests/transition.spec.ts b/e2e/solid-router/basic-solid-query-file-based/tests/transition.spec.ts
index 78a0e4341b7..27ea079ca4e 100644
--- a/e2e/solid-router/basic-solid-query-file-based/tests/transition.spec.ts
+++ b/e2e/solid-router/basic-solid-query-file-based/tests/transition.spec.ts
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'
-test('solid-query transitions keep previous data during navigation', async ({
+test('transitions/count/query should keep old values visible during navigation', async ({
page,
}) => {
await page.goto('/transition/count/query')
@@ -8,30 +8,88 @@ test('solid-query transitions keep previous data during navigation', async ({
await expect(page.getByTestId('n-value')).toContainText('n: 1')
await expect(page.getByTestId('double-value')).toContainText('double: 2')
- const bodySnapshots: Array = []
+ const bodyTexts: Array = []
- const interval = setInterval(async () => {
+ const pollInterval = setInterval(async () => {
const text = await page
.locator('body')
.textContent()
.catch(() => '')
- if (text) bodySnapshots.push(text)
+ if (text) bodyTexts.push(text)
}, 50)
- await page.getByTestId('increase-button').click()
+ // 1 click
+
+ page.getByTestId('increase-button').click()
+
+ await expect(page.getByTestId('n-value')).toContainText('n: 1', {
+ timeout: 2_000,
+ })
+ await expect(page.getByTestId('double-value')).toContainText('double: 2', {
+ timeout: 2_000,
+ })
await page.waitForTimeout(200)
- clearInterval(interval)
+ await expect(page.getByTestId('n-value')).toContainText('n: 2', {
+ timeout: 2000,
+ })
+ await expect(page.getByTestId('double-value')).toContainText('double: 4', {
+ timeout: 2000,
+ })
+
+ // 2 clicks
+
+ page.getByTestId('increase-button').click()
+ page.getByTestId('increase-button').click()
await expect(page.getByTestId('n-value')).toContainText('n: 2', {
- timeout: 2_000,
+ timeout: 2000,
})
await expect(page.getByTestId('double-value')).toContainText('double: 4', {
- timeout: 2_000,
+ timeout: 2000,
+ })
+
+ await page.waitForTimeout(200)
+
+ await expect(page.getByTestId('n-value')).toContainText('n: 4', {
+ timeout: 2000,
+ })
+ await expect(page.getByTestId('double-value')).toContainText('double: 8', {
+ timeout: 2000,
+ })
+
+ // 3 clicks
+
+ page.getByTestId('increase-button').click()
+ page.getByTestId('increase-button').click()
+ page.getByTestId('increase-button').click()
+
+ await expect(page.getByTestId('n-value')).toContainText('n: 4', {
+ timeout: 2000,
})
+ await expect(page.getByTestId('double-value')).toContainText('double: 8', {
+ timeout: 2000,
+ })
+
+ await page.waitForTimeout(200)
+
+ await expect(page.getByTestId('n-value')).toContainText('n: 7', {
+ timeout: 2000,
+ })
+ await expect(page.getByTestId('double-value')).toContainText('double: 14', {
+ timeout: 2000,
+ })
+
+ clearInterval(pollInterval)
- const sawLoading = bodySnapshots.some((text) => text.includes('Loading...'))
+ // With proper transitions, old values should remain visible until new ones arrive
+ const hasLoadingText = bodyTexts.some((text) => text.includes('Loading...'))
- expect(sawLoading).toBeFalsy()
+ if (hasLoadingText) {
+ throw new Error(
+ 'FAILED: "Loading..." appeared during navigation. ' +
+ 'Solid Router should use transitions to keep old values visible.',
+ )
+ }
})
diff --git a/e2e/solid-start/basic-solid-query/package.json b/e2e/solid-start/basic-solid-query/package.json
index fc6f36c0338..10b40e05a57 100644
--- a/e2e/solid-start/basic-solid-query/package.json
+++ b/e2e/solid-start/basic-solid-query/package.json
@@ -20,7 +20,8 @@
"redaxios": "^0.5.1",
"solid-js": "^1.9.10",
"tailwind-merge": "^2.6.0",
- "vite": "^7.1.7"
+ "vite": "^7.1.7",
+ "zod": "^4.1.12"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
diff --git a/e2e/solid-start/basic-solid-query/src/routeTree.gen.ts b/e2e/solid-start/basic-solid-query/src/routeTree.gen.ts
index 5a08920bd23..c100f876d5c 100644
--- a/e2e/solid-start/basic-solid-query/src/routeTree.gen.ts
+++ b/e2e/solid-start/basic-solid-query/src/routeTree.gen.ts
@@ -21,6 +21,7 @@ import { Route as UsersUserIdRouteImport } from './routes/users.$userId'
import { Route as PostsPostIdRouteImport } from './routes/posts.$postId'
import { Route as ApiUsersRouteImport } from './routes/api.users'
import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2'
+import { Route as TransitionCountQueryRouteImport } from './routes/transition/count/query'
import { Route as PostsPostIdDeepRouteImport } from './routes/posts_.$postId.deep'
import { Route as ApiUsersIdRouteImport } from './routes/api/users.$id'
import { Route as LayoutLayout2LayoutBRouteImport } from './routes/_layout/_layout-2/layout-b'
@@ -84,6 +85,11 @@ const LayoutLayout2Route = LayoutLayout2RouteImport.update({
id: '/_layout-2',
getParentRoute: () => LayoutRoute,
} as any)
+const TransitionCountQueryRoute = TransitionCountQueryRouteImport.update({
+ id: '/transition/count/query',
+ path: '/transition/count/query',
+ getParentRoute: () => rootRouteImport,
+} as any)
const PostsPostIdDeepRoute = PostsPostIdDeepRouteImport.update({
id: '/posts_/$postId/deep',
path: '/posts/$postId/deep',
@@ -120,6 +126,7 @@ export interface FileRoutesByFullPath {
'/layout-b': typeof LayoutLayout2LayoutBRoute
'/api/users/$id': typeof ApiUsersIdRoute
'/posts/$postId/deep': typeof PostsPostIdDeepRoute
+ '/transition/count/query': typeof TransitionCountQueryRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -134,6 +141,7 @@ export interface FileRoutesByTo {
'/layout-b': typeof LayoutLayout2LayoutBRoute
'/api/users/$id': typeof ApiUsersIdRoute
'/posts/$postId/deep': typeof PostsPostIdDeepRoute
+ '/transition/count/query': typeof TransitionCountQueryRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -153,6 +161,7 @@ export interface FileRoutesById {
'/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute
'/api/users/$id': typeof ApiUsersIdRoute
'/posts_/$postId/deep': typeof PostsPostIdDeepRoute
+ '/transition/count/query': typeof TransitionCountQueryRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -171,6 +180,7 @@ export interface FileRouteTypes {
| '/layout-b'
| '/api/users/$id'
| '/posts/$postId/deep'
+ | '/transition/count/query'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -185,6 +195,7 @@ export interface FileRouteTypes {
| '/layout-b'
| '/api/users/$id'
| '/posts/$postId/deep'
+ | '/transition/count/query'
id:
| '__root__'
| '/'
@@ -203,6 +214,7 @@ export interface FileRouteTypes {
| '/_layout/_layout-2/layout-b'
| '/api/users/$id'
| '/posts_/$postId/deep'
+ | '/transition/count/query'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -214,6 +226,7 @@ export interface RootRouteChildren {
UsersRoute: typeof UsersRouteWithChildren
ApiUsersRoute: typeof ApiUsersRouteWithChildren
PostsPostIdDeepRoute: typeof PostsPostIdDeepRoute
+ TransitionCountQueryRoute: typeof TransitionCountQueryRoute
}
declare module '@tanstack/solid-router' {
@@ -302,6 +315,13 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof LayoutLayout2RouteImport
parentRoute: typeof LayoutRoute
}
+ '/transition/count/query': {
+ id: '/transition/count/query'
+ path: '/transition/count/query'
+ fullPath: '/transition/count/query'
+ preLoaderRoute: typeof TransitionCountQueryRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/posts_/$postId/deep': {
id: '/posts_/$postId/deep'
path: '/posts/$postId/deep'
@@ -403,6 +423,7 @@ const rootRouteChildren: RootRouteChildren = {
UsersRoute: UsersRouteWithChildren,
ApiUsersRoute: ApiUsersRouteWithChildren,
PostsPostIdDeepRoute: PostsPostIdDeepRoute,
+ TransitionCountQueryRoute: TransitionCountQueryRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/e2e/solid-start/basic-solid-query/src/routes/transition/count/query.tsx b/e2e/solid-start/basic-solid-query/src/routes/transition/count/query.tsx
new file mode 100644
index 00000000000..256b097dd94
--- /dev/null
+++ b/e2e/solid-start/basic-solid-query/src/routes/transition/count/query.tsx
@@ -0,0 +1,55 @@
+import { Link, createFileRoute } from '@tanstack/solid-router'
+import { Suspense } from 'solid-js'
+import { queryOptions, useQuery } from '@tanstack/solid-query'
+import { z } from 'zod'
+
+const searchSchema = z.object({
+ n: z.number().default(1),
+})
+
+const doubleQueryOptions = (n: number) =>
+ queryOptions({
+ queryKey: ['transition-double', n],
+ queryFn: async () => {
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+ return { n, double: n * 2 }
+ },
+ placeholderData: (oldData) => oldData,
+ })
+
+export const Route = createFileRoute('/transition/count/query')({
+ validateSearch: searchSchema,
+ loader: ({ context: { queryClient }, location }) => {
+ const { n } = searchSchema.parse(location.search)
+ return queryClient.ensureQueryData(doubleQueryOptions(n))
+ },
+ component: TransitionPage,
+})
+
+function TransitionPage() {
+ const search = Route.useSearch()
+
+ const doubleQuery = useQuery(() => doubleQueryOptions(search().n))
+
+ return (
+
+
+
({ n: s.n + 1 })}
+ >
+ Increase
+
+
+
+
n: {doubleQuery.data?.n}
+
+ double: {doubleQuery.data?.double}
+
+
+
+
+ )
+}
diff --git a/e2e/solid-start/basic-solid-query/tests/transition.spec.ts b/e2e/solid-start/basic-solid-query/tests/transition.spec.ts
new file mode 100644
index 00000000000..2c28fc72a28
--- /dev/null
+++ b/e2e/solid-start/basic-solid-query/tests/transition.spec.ts
@@ -0,0 +1,95 @@
+import { expect, test } from '@playwright/test'
+
+test('transitions/count/query should keep old values visible during navigation', async ({
+ page,
+}) => {
+ await page.goto('/transition/count/query')
+
+ await expect(page.getByTestId('n-value')).toContainText('n: 1')
+ await expect(page.getByTestId('double-value')).toContainText('double: 2')
+
+ const bodyTexts: Array = []
+
+ const pollInterval = setInterval(async () => {
+ const text = await page
+ .locator('body')
+ .textContent()
+ .catch(() => '')
+ if (text) bodyTexts.push(text)
+ }, 50)
+
+ // 1 click
+
+ page.getByTestId('increase-button').click()
+
+ await expect(page.getByTestId('n-value')).toContainText('n: 1', {
+ timeout: 2_000,
+ })
+ await expect(page.getByTestId('double-value')).toContainText('double: 2', {
+ timeout: 2_000,
+ })
+
+ await page.waitForTimeout(200)
+
+ await expect(page.getByTestId('n-value')).toContainText('n: 2', {
+ timeout: 2000,
+ })
+ await expect(page.getByTestId('double-value')).toContainText('double: 4', {
+ timeout: 2000,
+ })
+
+ // 2 clicks
+
+ // page.getByTestId('increase-button').click()
+ // page.getByTestId('increase-button').click()
+
+ // await expect(page.getByTestId('n-value')).toContainText('n: 2', {
+ // timeout: 2000,
+ // })
+ // await expect(page.getByTestId('double-value')).toContainText('double: 4', {
+ // timeout: 2000,
+ // })
+
+ // await page.waitForTimeout(200)
+
+ // await expect(page.getByTestId('n-value')).toContainText('n: 4', {
+ // timeout: 2000,
+ // })
+ // await expect(page.getByTestId('double-value')).toContainText('double: 8', {
+ // timeout: 2000,
+ // })
+
+ // // 3 clicks
+
+ // page.getByTestId('increase-button').click()
+ // page.getByTestId('increase-button').click()
+ // page.getByTestId('increase-button').click()
+
+ // await expect(page.getByTestId('n-value')).toContainText('n: 4', {
+ // timeout: 2000,
+ // })
+ // await expect(page.getByTestId('double-value')).toContainText('double: 8', {
+ // timeout: 2000,
+ // })
+
+ // await page.waitForTimeout(200)
+
+ // await expect(page.getByTestId('n-value')).toContainText('n: 7', {
+ // timeout: 2000,
+ // })
+ // await expect(page.getByTestId('double-value')).toContainText('double: 14', {
+ // timeout: 2000,
+ // })
+
+ clearInterval(pollInterval)
+
+ // With proper transitions, old values should remain visible until new ones arrive
+ const hasLoadingText = bodyTexts.some((text) => text.includes('Loading...'))
+
+ if (hasLoadingText) {
+ throw new Error(
+ 'FAILED: "Loading..." appeared during navigation. ' +
+ 'Solid Router should use transitions to keep old values visible.',
+ )
+ }
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cc8790f6853..81919ec9532 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1221,6 +1221,9 @@ importers:
vite:
specifier: ^7.1.7
version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+ zod:
+ specifier: ^4.1.12
+ version: 4.1.12
devDependencies:
'@playwright/test':
specifier: ^1.52.0
@@ -2829,6 +2832,9 @@ importers:
vite:
specifier: ^7.1.7
version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+ zod:
+ specifier: ^4.1.12
+ version: 4.1.12
devDependencies:
'@playwright/test':
specifier: ^1.52.0
@@ -5853,7 +5859,7 @@ importers:
version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
vitest:
specifier: ^3.2.4
- version: 3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+ version: 3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6(@types/node@22.10.2)(playwright@1.52.0)(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@3.2.4))(@vitest/ui@3.0.6(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
web-vitals:
specifier: ^5.1.0
version: 5.1.0
@@ -30878,7 +30884,7 @@ snapshots:
optionalDependencies:
vite: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
- vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1):
+ vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6(@types/node@22.10.2)(playwright@1.52.0)(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@3.2.4))(@vitest/ui@3.0.6(vitest@3.2.4))(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
@@ -30907,7 +30913,7 @@ snapshots:
'@types/node': 22.10.2
'@vitest/browser': 3.0.6(@types/node@22.10.2)(playwright@1.52.0)(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@3.2.4)
'@vitest/ui': 3.0.6(vitest@3.2.4)
- jsdom: 25.0.1
+ jsdom: 27.0.0(postcss@8.5.6)
transitivePeerDependencies:
- jiti
- less
@@ -30922,7 +30928,7 @@ snapshots:
- tsx
- yaml
- vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1):
+ vitest@3.2.4(@types/node@22.10.2)(@vitest/browser@3.0.6)(@vitest/ui@3.0.6)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.10.2)(typescript@5.9.2))(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
@@ -30951,7 +30957,7 @@ snapshots:
'@types/node': 22.10.2
'@vitest/browser': 3.0.6(@types/node@22.10.2)(playwright@1.52.0)(typescript@5.9.2)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@3.2.4)
'@vitest/ui': 3.0.6(vitest@3.2.4)
- jsdom: 27.0.0(postcss@8.5.6)
+ jsdom: 25.0.1
transitivePeerDependencies:
- jiti
- less