Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/clean-eagles-open.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tanstack/react-router': patch
'@tanstack/solid-router': patch
'@tanstack/router-core': patch
'@tanstack/vue-router': patch
---

preserve pending UI across retained routes
2 changes: 1 addition & 1 deletion e2e/solid-start/basic/tests/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ test('client side navigating to a route with scripts', async ({ page }) => {
await page.waitForURL('/')
await page.getByRole('link', { name: 'Scripts', exact: true }).click()
await expect(page.getByTestId('scripts-test-heading')).toBeInViewport()
expect(await page.evaluate('window.SCRIPT_1')).toBe(true)
await page.waitForFunction(() => (window as any).SCRIPT_1 === true)
expect(await page.evaluate('window.SCRIPT_2')).toBe(undefined)
})

Expand Down
47 changes: 45 additions & 2 deletions packages/react-router/tests/hydration-terminal-lane.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from '@testing-library/react'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { hydrate } from '@tanstack/router-core/ssr/client'
import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id'
Expand All @@ -20,18 +20,20 @@ function bootstrap(
ssr: AnyRouteMatch['ssr']
data?: unknown
error?: unknown
notFound?: boolean
}>,
): void {
window.$_TSR = {
router: {
manifest: undefined,
matches: matches.map(({ match, status, ssr, data, error }) => ({
matches: matches.map(({ match, status, ssr, data, error, notFound }) => ({
i: dehydrateSsrMatchId(match.id),
l: data,
e: error,
s: status,
ssr,
u: Date.now(),
...(notFound ? { g: true } : {}),
})),
},
h: vi.fn(),
Expand All @@ -44,6 +46,7 @@ function bootstrap(

afterEach(() => {
cleanup()
vi.useRealTimers()
delete window.$_TSR
})

Expand Down Expand Up @@ -95,4 +98,44 @@ describe('hydration terminal lane', () => {
expect(parentLoader).not.toHaveBeenCalled()
expect(childLoader).toHaveBeenCalledTimes(1)
})

test('keeps a hydrated pending fallback through its minimum before a terminal result', async () => {
const rootRoute = createRootRoute({
pendingMs: 0,
pendingMinMs: 100,
pendingComponent: () => <div role="status">Missing page pending</div>,
notFoundComponent: () => <div>Missing page</div>,
})
const router = createRouter({
history: createMemoryHistory({ initialEntries: ['/missing'] }),
routeTree: rootRoute,
})
const matches = router.matchRoutes(router.state.location)
expect(matches[0]?._notFound).toBe(true)
bootstrap([
{
match: matches[0]!,
status: 'pending',
ssr: false,
notFound: true,
},
])

await hydrate(router)
vi.useFakeTimers()
vi.setSystemTime(0)
render(<RouterProvider router={router} />)
expect(screen.getByRole('status')).toHaveTextContent('Missing page pending')

await act(async () => {
await vi.advanceTimersByTimeAsync(99)
})
expect(screen.getByRole('status')).toHaveTextContent('Missing page pending')
expect(screen.queryByText('Missing page')).not.toBeInTheDocument()

await act(async () => {
await vi.advanceTimersByTimeAsync(5)
})
expect(screen.getByText('Missing page')).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import {
createRouter,
} from '../src'

afterEach(cleanup)
afterEach(() => {
cleanup()
vi.useRealTimers()
})

// https://github.com/TanStack/router/issues/4467
test('default pending component renders while lazy route options load', async () => {
Expand Down Expand Up @@ -142,3 +145,78 @@ test('a lazy pending component is offered while the eager loader is still pendin

expect(screen.getByRole('heading', { name: 'Page' })).toBeInTheDocument()
})

test('a lazy pending component does not restart an acknowledged minimum', async () => {
const loader = createControlledPromise<void>()
const lazyPageOptions = createLazyRoute('/page')({
pendingComponent: () => <p role="status">Loading lazy page</p>,
component: () => <h1>Page</h1>,
})
const lazyOptions = createControlledPromise<typeof lazyPageOptions>()
const rootRoute = createRootRoute({ component: Outlet })
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <h1>Index page</h1>,
})
const pageRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/page',
loader: () => loader,
}).lazy(() => lazyOptions)
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, pageRoute]),
history: createMemoryHistory({ initialEntries: ['/'] }),
defaultPendingMs: 0,
defaultPendingMinMs: 100,
defaultPendingComponent: () => <p role="status">Loading default</p>,
})

render(<RouterProvider router={router} />)
expect(
await screen.findByRole('heading', { name: 'Index page' }),
).toBeInTheDocument()
vi.useFakeTimers()
vi.setSystemTime(0)

const navigation = router.navigate({ to: '/page' })
let settled = false
void navigation.then(() => {
settled = true
})
try {
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(screen.getByRole('status')).toHaveTextContent('Loading default')

await act(async () => {
await vi.advanceTimersByTimeAsync(25)
lazyOptions.resolve(lazyPageOptions)
loader.resolve()
await vi.advanceTimersByTimeAsync(0)
})
expect(screen.getByRole('status')).toHaveTextContent('Loading lazy page')

await act(async () => {
await vi.advanceTimersByTimeAsync(74)
})
expect(screen.getByRole('status')).toHaveTextContent('Loading lazy page')

await act(async () => {
await vi.advanceTimersByTimeAsync(5)
await Promise.resolve()
})
expect(settled).toBe(true)
await navigation
expect(screen.getByRole('heading', { name: 'Page' })).toBeInTheDocument()
expect(Date.now()).toBeLessThan(125)
} finally {
lazyOptions.resolve(lazyPageOptions)
loader.resolve()
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
await navigation
})
}
})
127 changes: 126 additions & 1 deletion packages/react-router/tests/issue-7367-pending-min-redirect.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react'
import { cleanup, render, screen } from '@testing-library/react'
import { act, cleanup, render, screen } from '@testing-library/react'
import { createControlledPromise } from '@tanstack/router-core'
import { afterEach, expect, test, vi } from 'vitest'

import {
Expand All @@ -16,6 +17,7 @@ import { sleep } from './utils'
afterEach(() => {
vi.restoreAllMocks()
cleanup()
vi.useRealTimers()
})

// https://github.com/TanStack/router/issues/7367
Expand Down Expand Up @@ -76,3 +78,126 @@ test('immediate pending spinner (pendingMs: 0 + pendingMinMs) with root beforeLo
expect(router.state.location.pathname).toBe('/welcome')
expect(consoleError).not.toHaveBeenCalled()
})

test('a compatible SPA redirect preserves the acknowledged pending minimum', async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
const redirectReady = createControlledPromise<void>()
let shouldRedirect = true

const rootRoute = createRootRoute({
component: Outlet,
pendingMs: 0,
pendingMinMs: 100,
pendingComponent: () => <div data-testid="pending">loading</div>,
beforeLoad: async () => {
if (shouldRedirect) {
shouldRedirect = false
await redirectReady
throw redirect({ to: '/welcome', replace: true })
}
},
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <div>Index</div>,
})
const welcomeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/welcome',
component: () => <div data-testid="welcome-page">Welcome</div>,
})
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, welcomeRoute]),
history: createMemoryHistory({ initialEntries: ['/'] }),
})

try {
render(<RouterProvider router={router} />)
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(screen.getByTestId('pending')).toBeInTheDocument()

await act(async () => {
await vi.advanceTimersByTimeAsync(25)
redirectReady.resolve()
await vi.advanceTimersByTimeAsync(74)
})
expect(screen.getByTestId('pending')).toBeInTheDocument()
expect(screen.queryByTestId('welcome-page')).not.toBeInTheDocument()

await act(async () => {
await vi.advanceTimersByTimeAsync(5)
})
expect(screen.getByTestId('welcome-page')).toBeInTheDocument()
} finally {
redirectReady.resolve()
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
})
vi.useRealTimers()
}
})

test('an incompatible SPA redirect does not inherit the pending minimum', async () => {
const redirectReady = createControlledPromise<void>()
const rootRoute = createRootRoute({ component: Outlet })
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <div data-testid="index-page">Index</div>,
})
const sourceRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/source',
pendingMs: 0,
pendingMinMs: 100,
pendingComponent: () => <div data-testid="pending">loading</div>,
beforeLoad: async () => {
await redirectReady
throw redirect({ to: '/welcome', replace: true })
},
})
const welcomeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/welcome',
component: () => <div data-testid="welcome-page">Welcome</div>,
})
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, sourceRoute, welcomeRoute]),
history: createMemoryHistory({ initialEntries: ['/'] }),
})

render(<RouterProvider router={router} />)
expect(await screen.findByTestId('index-page')).toBeVisible()
vi.useFakeTimers()
vi.setSystemTime(0)

const navigation = router.navigate({ to: '/source' })
try {
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(screen.getByTestId('pending')).toBeVisible()

await act(async () => {
await vi.advanceTimersByTimeAsync(25)
redirectReady.resolve()
await vi.advanceTimersByTimeAsync(5)
await navigation
})

expect(Date.now()).toBeLessThan(100)
expect(screen.getByTestId('welcome-page')).toBeVisible()
expect(screen.queryByTestId('index-page')).not.toBeInTheDocument()
expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
} finally {
redirectReady.resolve()
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
await navigation
})
}
})
Loading
Loading