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
2 changes: 1 addition & 1 deletion packages/router-core/src/load-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2159,7 +2159,7 @@ export async function loadClientRoute(
// matches must wait for lazy routes to place the final boundary.
if (
resolvedPrefix ||
(!resolvedLocation && !matches.some((match) => match._notFound))
(!router._committed.length && !matches.some((match) => match._notFound))
) {
offerPending(router, tx)
}
Expand Down
70 changes: 70 additions & 0 deletions packages/router-core/tests/public-client-loading-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,76 @@ describe('public client loading contracts', () => {
})
})

test('a successor retains committed UI until its pending context is ready', async () => {
const initialPublished = createControlledPromise<void>()
const initialRenderAck = createControlledPromise<boolean>()
const successorStarted = createControlledPromise<void>()
const successorLoader = createControlledPromise<string>()
const rootRoute = new BaseRootRoute({})
const initialRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/',
})
const successorRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/successor',
context: () => ({ destinationContext: 'ready' }),
pendingMs: 0,
pendingMinMs: 0,
pendingComponent: () => null,
loader: () => {
successorStarted.resolve()
return successorLoader
},
})
const router = createTestRouter({
routeTree: rootRoute.addChildren([initialRoute, successorRoute]),
history: createMemoryHistory({ initialEntries: ['/'] }),
})
const startTransition = router.startTransition
let waitForInitialRender = true
router.startTransition = (fn, expected) => {
fn()
if (
waitForInitialRender &&
expected?.at(-1)?.routeId === initialRoute.id &&
expected.at(-1)?.status === 'success'
) {
waitForInitialRender = false
initialPublished.resolve()
return initialRenderAck
}
return Promise.resolve(true)
}

const initialLoad = router.load()
let navigation: Promise<void> | undefined
try {
await initialPublished
expect(router._committed.at(-1)?.routeId).toBe(initialRoute.id)
expect(router.state.resolvedLocation).toBeUndefined()
expect(initialRenderAck.status).toBe('pending')

navigation = router.navigate({ to: '/successor' })
await successorStarted

expect(router.state.matches.at(-1)).toMatchObject({
routeId: successorRoute.id,
status: 'pending',
context: { destinationContext: 'ready' },
})

successorLoader.resolve('successor data')
initialRenderAck.resolve(true)
await Promise.all([initialLoad, navigation])
} finally {
successorLoader.resolve('successor data')
initialRenderAck.resolve(true)
await Promise.allSettled([initialLoad, navigation])
router.startTransition = startTransition
}
})

test('a rejected pending publication restores the committed lane', async () => {
const loaderStarted = createControlledPromise<void>()
const loaderGate = createControlledPromise<string>()
Expand Down
42 changes: 36 additions & 6 deletions packages/solid-router/src/Transitioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import * as Solid from 'solid-js'
import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core'
import { isServer } from '@tanstack/router-core/isServer'
import { useRouter } from './useRouter'
import type { AnyRouteMatch } from '@tanstack/router-core'

function getResolvedLocation(router: ReturnType<typeof useRouter>) {
const resolvedLocation = router.stores.resolvedLocation.get()
Expand All @@ -22,13 +21,44 @@ export function Transitioner() {
return null
}

let transitionOwner: Array<AnyRouteMatch> | undefined
router.startTransition = async (fn, expected) => {
transitionOwner = expected
await Solid.startTransition(fn)
return transitionOwner === expected
let settleCurrent: ((rendered: boolean) => void) | undefined
router.startTransition = (fn) => {
settleCurrent?.(false)

return new Promise((resolve, reject) => {
const settle = (rendered: boolean) => {
if (settleCurrent !== settle) {
return
}
settleCurrent = undefined
resolve(rendered)
}
const fail = (cause: unknown) => {
if (settleCurrent !== settle) {
return
}
settleCurrent = undefined
reject(cause)
}
settleCurrent = settle

void Solid.startTransition(() => {
// A newer publication may supersede this deferred callback.
if (settleCurrent === settle) {
try {
fn()
} catch (cause) {
fail(cause)
}
}
}).then(() => settle(true), fail)
})
}

Solid.onCleanup(() => {
settleCurrent?.(false)
})

// Subscribe to location changes
// and try to load the new location
Solid.onMount(() => {
Expand Down
87 changes: 87 additions & 0 deletions packages/solid-router/tests/hydration-terminal-lane.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { cleanup, render, screen } from '@solidjs/testing-library'
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'
import {
RouterProvider,
createMemoryHistory,
createRootRoute,
createRouter,
} from '../src'
import type { AnyRouteMatch } from '@tanstack/router-core'
import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client'

function bootstrap(
matches: Array<{
match: AnyRouteMatch
status: AnyRouteMatch['status']
ssr: AnyRouteMatch['ssr']
data?: unknown
error?: unknown
notFound?: boolean
}>,
): void {
window.$_TSR = {
router: {
manifest: undefined,
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(),
e: vi.fn(),
c: vi.fn(),
p: vi.fn(),
buffer: [],
} as TsrSsrGlobal
}

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

describe('hydration terminal lane', () => {
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 vi.advanceTimersByTimeAsync(99)
expect(screen.getByRole('status')).toHaveTextContent('Missing page pending')
expect(screen.queryByText('Missing page')).not.toBeInTheDocument()

await vi.advanceTimersByTimeAsync(5)
expect(screen.getByText('Missing page')).toBeInTheDocument()
})
})
82 changes: 82 additions & 0 deletions packages/solid-router/tests/issue-4467-lazy-route-pending.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { cleanup, render, screen } from '@solidjs/testing-library'
import { afterEach, expect, test, vi } from 'vitest'
import { createControlledPromise } from '@tanstack/router-core'
import {
Outlet,
RouterProvider,
createLazyRoute,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
} from '../src'

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

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 vi.advanceTimersByTimeAsync(0)
expect(screen.getByRole('status')).toHaveTextContent('Loading default')

await vi.advanceTimersByTimeAsync(25)
lazyOptions.resolve(lazyPageOptions)
loader.resolve()
await vi.advanceTimersByTimeAsync(0)
expect(screen.getByRole('status')).toHaveTextContent('Loading lazy page')

await vi.advanceTimersByTimeAsync(74)
expect(screen.getByRole('status')).toHaveTextContent('Loading lazy page')

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 vi.advanceTimersByTimeAsync(1_000)
await navigation
}
})
Loading
Loading