Skip to content
Open
21 changes: 21 additions & 0 deletions e2e/solid-router/basic-file-based/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route'
import { Route as NonNestedRouteRouteImport } from './routes/non-nested/route'
import { Route as IndexRouteImport } from './routes/index'
import { Route as TransitionIndexRouteImport } from './routes/transition/index'
import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index'
import { Route as RelativeIndexRouteImport } from './routes/relative/index'
import { Route as RedirectIndexRouteImport } from './routes/redirect/index'
Expand Down Expand Up @@ -158,6 +159,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const TransitionIndexRoute = TransitionIndexRouteImport.update({
id: '/transition/',
path: '/transition/',
getParentRoute: () => rootRouteImport,
} as any)
const SearchParamsIndexRoute = SearchParamsIndexRouteImport.update({
id: '/',
path: '/',
Expand Down Expand Up @@ -600,6 +606,7 @@ export interface FileRoutesByFullPath {
'/redirect': typeof RedirectIndexRoute
'/relative': typeof RelativeIndexRoute
'/search-params/': typeof SearchParamsIndexRoute
'/transition': typeof TransitionIndexRoute
'/non-nested/named/$baz': typeof NonNestedNamedBazRouteRouteWithChildren
'/non-nested/path/baz': typeof NonNestedPathBazRouteRouteWithChildren
'/non-nested/prefix/prefix{$baz}': typeof NonNestedPrefixPrefixChar123bazChar125RouteRouteWithChildren
Expand Down Expand Up @@ -684,6 +691,7 @@ export interface FileRoutesByTo {
'/redirect': typeof RedirectIndexRoute
'/relative': typeof RelativeIndexRoute
'/search-params': typeof SearchParamsIndexRoute
'/transition': typeof TransitionIndexRoute
'/params-ps/named/$foo': typeof ParamsPsNamedFooRouteRouteWithChildren
'/params-ps/non-nested/$foo': typeof ParamsPsNonNestedFooRouteRouteWithChildren
'/insidelayout': typeof groupLayoutInsidelayoutRoute
Expand Down Expand Up @@ -771,6 +779,7 @@ export interface FileRoutesById {
'/redirect/': typeof RedirectIndexRoute
'/relative/': typeof RelativeIndexRoute
'/search-params/': typeof SearchParamsIndexRoute
'/transition/': typeof TransitionIndexRoute
'/non-nested/named/$baz': typeof NonNestedNamedBazRouteRouteWithChildren
'/non-nested/path/baz': typeof NonNestedPathBazRouteRouteWithChildren
'/non-nested/prefix/prefix{$baz}': typeof NonNestedPrefixPrefixChar123bazChar125RouteRouteWithChildren
Expand Down Expand Up @@ -860,6 +869,7 @@ export interface FileRouteTypes {
| '/redirect'
| '/relative'
| '/search-params/'
| '/transition'
| '/non-nested/named/$baz'
| '/non-nested/path/baz'
| '/non-nested/prefix/prefix{$baz}'
Expand Down Expand Up @@ -944,6 +954,7 @@ export interface FileRouteTypes {
| '/redirect'
| '/relative'
| '/search-params'
| '/transition'
| '/params-ps/named/$foo'
| '/params-ps/non-nested/$foo'
| '/insidelayout'
Expand Down Expand Up @@ -1030,6 +1041,7 @@ export interface FileRouteTypes {
| '/redirect/'
| '/relative/'
| '/search-params/'
| '/transition/'
| '/non-nested/named/$baz'
| '/non-nested/path/baz'
| '/non-nested/prefix/prefix{$baz}'
Expand Down Expand Up @@ -1112,6 +1124,7 @@ export interface RootRouteChildren {
ParamsPsIndexRoute: typeof ParamsPsIndexRoute
RedirectIndexRoute: typeof RedirectIndexRoute
RelativeIndexRoute: typeof RelativeIndexRoute
TransitionIndexRoute: typeof TransitionIndexRoute
ParamsPsNamedFooRouteRoute: typeof ParamsPsNamedFooRouteRouteWithChildren
groupSubfolderInsideRoute: typeof groupSubfolderInsideRoute
ParamsPsNamedPrefixChar123fooChar125Route: typeof ParamsPsNamedPrefixChar123fooChar125Route
Expand Down Expand Up @@ -1216,6 +1229,13 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/transition/': {
id: '/transition/'
path: '/transition'
fullPath: '/transition'
preLoaderRoute: typeof TransitionIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/search-params/': {
id: '/search-params/'
path: '/'
Expand Down Expand Up @@ -2104,6 +2124,7 @@ const rootRouteChildren: RootRouteChildren = {
ParamsPsIndexRoute: ParamsPsIndexRoute,
RedirectIndexRoute: RedirectIndexRoute,
RelativeIndexRoute: RelativeIndexRoute,
TransitionIndexRoute: TransitionIndexRoute,
ParamsPsNamedFooRouteRoute: ParamsPsNamedFooRouteRouteWithChildren,
groupSubfolderInsideRoute: groupSubfolderInsideRoute,
ParamsPsNamedPrefixChar123fooChar125Route:
Expand Down
49 changes: 49 additions & 0 deletions e2e/solid-router/basic-file-based/src/routes/transition/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Link, createFileRoute } from '@tanstack/solid-router'
import { Suspense, createResource } from 'solid-js'
import { z } from 'zod'

export const Route = createFileRoute('/transition/')({
validateSearch: z.object({
n: z.number().default(1),
}),
component: Home,
})

function Home() {
return (
<div class="p-2">
<Link
data-testid="increase-button"
class="border bg-gray-50 px-3 py-1"
from="/transition"
search={(s) => ({ n: s.n + 1 })}
startTransition
>
Increase
</Link>

<Result />
</div>
)
}

function Result() {
const searchQuery = Route.useSearch()

const [doubleQuery] = createResource(
() => searchQuery().n,
async (n) => {
await new Promise((r) => setTimeout(r, 1000))
return n * 2
},
)

return (
<div class="mt-2">
<Suspense fallback="Loading...">
<div data-testid="n-value">n: {searchQuery().n}</div>
<div data-testid="double-value">double: {doubleQuery()}</div>
</Suspense>
</div>
)
}
51 changes: 51 additions & 0 deletions e2e/solid-router/basic-file-based/tests/transition.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { expect, test } from '@playwright/test'

test('transitions should keep old values visible during navigation', async ({
page,
}) => {
// Navigate to the transition test route
await page.goto('/transition')

// Wait for initial values to load
await expect(page.getByTestId('n-value')).toContainText('n: 1')
await expect(page.getByTestId('double-value')).toContainText('double: 2')

// Set up a listener to capture all text content changes
const bodyTexts: Array<string> = []

// Poll the body text during the transition
const pollInterval = setInterval(async () => {
const text = await page
.locator('body')
.textContent()
.catch(() => '')
if (text) bodyTexts.push(text)
}, 50)

// Click the increase button to trigger navigation with new search params
await page.getByTestId('increase-button').click()

// Wait a bit to capture text during the transition
await page.waitForTimeout(200)

clearInterval(pollInterval)

// Eventually, new values should appear
await expect(page.getByTestId('n-value')).toContainText('n: 2', {
timeout: 2000,
})
await expect(page.getByTestId('double-value')).toContainText('double: 4', {
timeout: 2000,
})

// CRITICAL TEST: Verify "Loading..." was never shown during the transition
// 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.',
)
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
expect(updates).toBe(5)
expect(updates).toBe(4)
})

test('sync beforeLoad', async () => {
Expand Down
101 changes: 53 additions & 48 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2090,56 +2090,61 @@ export class RouterCore<
// eslint-disable-next-line @typescript-eslint/require-await
onReady: async () => {
// eslint-disable-next-line @typescript-eslint/require-await
this.startViewTransition(async () => {
// this.viewTransitionPromise = createControlledPromise<true>()

// Commit the pending matches. If a previous match was
// removed, place it in the cachedMatches
let exitingMatches!: Array<AnyRouteMatch>
let enteringMatches!: Array<AnyRouteMatch>
let stayingMatches!: Array<AnyRouteMatch>

batch(() => {
this.__store.setState((s) => {
const previousMatches = s.matches
const newMatches = s.pendingMatches || s.matches

exitingMatches = previousMatches.filter(
(match) => !newMatches.some((d) => d.id === match.id),
)
enteringMatches = newMatches.filter(
(match) =>
!previousMatches.some((d) => d.id === match.id),
)
stayingMatches = newMatches.filter((match) =>
previousMatches.some((d) => d.id === match.id),
)

return {
...s,
isLoading: false,
loadedAt: Date.now(),
matches: newMatches,
pendingMatches: undefined,
cachedMatches: [
...s.cachedMatches,
...exitingMatches.filter((d) => d.status !== 'error'),
],
}
// Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition)
this.startTransition(() => {
this.startViewTransition(async () => {
// this.viewTransitionPromise = createControlledPromise<true>()

// Commit the pending matches. If a previous match was
// removed, place it in the cachedMatches
let exitingMatches: Array<AnyRouteMatch> = []
let enteringMatches: Array<AnyRouteMatch> = []
let stayingMatches: Array<AnyRouteMatch> = []

batch(() => {
this.__store.setState((s) => {
const previousMatches = s.matches
const newMatches = s.pendingMatches || s.matches

exitingMatches = previousMatches.filter(
(match) => !newMatches.some((d) => d.id === match.id),
)
enteringMatches = newMatches.filter(
(match) =>
!previousMatches.some((d) => d.id === match.id),
)
stayingMatches = newMatches.filter((match) =>
previousMatches.some((d) => d.id === match.id),
)

return {
...s,
isLoading: false,
loadedAt: Date.now(),
matches: newMatches,
pendingMatches: undefined,
cachedMatches: [
...s.cachedMatches,
...exitingMatches.filter((d) => d.status !== 'error'),
],
}
})
this.clearExpiredCache()
})
this.clearExpiredCache()
})

//
;(
[
[exitingMatches, 'onLeave'],
[enteringMatches, 'onEnter'],
[stayingMatches, 'onStay'],
] as const
).forEach(([matches, hook]) => {
matches.forEach((match) => {
this.looseRoutesById[match.routeId]!.options[hook]?.(match)
//
;(
[
[exitingMatches, 'onLeave'],
[enteringMatches, 'onEnter'],
[stayingMatches, 'onStay'],
] as const
).forEach(([matches, hook]) => {
matches.forEach((match) => {
this.looseRoutesById[match.routeId]!.options[hook]?.(
match,
)
})
})
})
})
Expand Down
Loading
Loading