Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/clean-redirect-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

Render route errors when redirect target construction fails and let successor transactions own navigation and HMR presentation.
4 changes: 2 additions & 2 deletions e2e/react-start/hmr/tests/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,7 @@ test.describe('react-start hmr', () => {
await expect(page.getByTestId('child')).toHaveText('child')
})

test('rolls back a failed route refresh and accepts the next HMR update', async ({
test('publishes a failed route refresh and accepts the next HMR update', async ({
page,
}) => {
await page.goto('/child')
Expand Down Expand Up @@ -712,7 +712,7 @@ test.describe('react-start hmr', () => {
},
)

await expect(page.getByTestId('crumb-/child')).toHaveText('Child')
await expect(page.getByTestId('crumb-/child')).toHaveText('Child Failed')
await expect(page.getByTestId('root-message')).toHaveValue(
'preserved through failure',
)
Expand Down
18 changes: 2 additions & 16 deletions packages/react-router/src/Transitioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,26 +29,12 @@ export function Transitioner({
: undefined

router.startTransition = (fn, expected) =>
new Promise((resolve, reject) => {
new Promise((resolve) => {
settleOwner(acknowledgement, false)
acknowledgement.push(expected, resolve)
t(router)
React.startTransition(() => {
try {
fn()
} catch (cause) {
if (acknowledgement[1 /* settle */] === resolve) {
acknowledgement.length = 0
}
reject(cause)
}
})
React.startTransition(fn)
})
if (process.env.NODE_ENV !== 'production') {
;(
router as typeof router & { _cancelTransition?: () => void }
)._cancelTransition = () => settleOwner(acknowledgement, false)
}

// Subscribe before canonicalizing so the initial URL has exactly one load.
useLayoutEffect(() => {
Expand Down
51 changes: 51 additions & 0 deletions packages/react-router/tests/redirect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,57 @@ describe('redirect', () => {
expect(router.state.status).toBe('idle')
})

test('renders the source error boundary when building a redirect target fails', async () => {
const boom = new Error('redirect search failed')
const rootRoute = createRootRoute({ component: Outlet })
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <div>Home</div>,
})
const sourceRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/source',
beforeLoad: () => {
throw redirect({
to: '/target',
search: () => {
throw boom
},
})
},
errorComponent: ({ error }) => (
<div data-testid="source-error">{error.message}</div>
),
})
const targetRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/target',
component: () => <div>Target</div>,
})
const router = createRouter({
routeTree: rootRoute.addChildren([
indexRoute,
sourceRoute,
targetRoute,
]),
history,
})

render(<RouterProvider router={router} />)
expect(await screen.findByText('Home')).toBeInTheDocument()

await act(() => router.navigate({ to: '/source' }))

expect(await screen.findByTestId('source-error')).toHaveTextContent(
boom.message,
)
expect(screen.queryByText('Home')).not.toBeInTheDocument()
expect(screen.queryByText('Target')).not.toBeInTheDocument()
expect(window.location.pathname).toBe('/source')
expect(router.state.status).toBe('idle')
})

test('renders a root error after too many same-location redirects', async () => {
const loader = vi.fn(() => {
throw redirect({ to: '/' })
Expand Down
62 changes: 0 additions & 62 deletions packages/react-router/tests/transitioner-render-ack.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,68 +19,6 @@ afterEach(() => {
}
cleanup()
vi.useRealTimers()
vi.unstubAllEnvs()
})

test('a route lifecycle callback cannot strand a production navigation', async () => {
vi.stubEnv('NODE_ENV', 'production')
expect(process.env.NODE_ENV).toBe('production')
const rootRoute = createRootRoute({ component: Outlet })
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <div>Index</div>,
})
const error = new Error('onEnter failed')
const onEnter = vi.fn(() => {
throw error
})
const nextRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/next',
onEnter,
component: () => <div>Next</div>,
})
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, nextRoute]),
history: createMemoryHistory({ initialEntries: ['/'] }),
})

render(<RouterProvider router={router} />)
expect(await screen.findByText('Index')).toBeInTheDocument()
await waitFor(() => expect(router.state.status).toBe('idle'))

const globalReport = vi.fn()
const preventGlobalReport = (event: ErrorEvent) => {
if (event.error === error) {
globalReport()
event.preventDefault()
}
}
window.addEventListener('error', preventGlobalReport)
testCleanups.push(() => {
window.removeEventListener('error', preventGlobalReport)
})

let settled = false
const navigation = router.navigate({ to: '/next' }).then(
() => {
settled = true
},
() => {
settled = true
},
)
await waitFor(() => expect(settled).toBe(true))
await navigation

expect(onEnter).toHaveBeenCalledOnce()
expect(globalReport).not.toHaveBeenCalled()
expect(screen.getByText('Next')).toBeInTheDocument()
expect(router.state.status).toBe('idle')

await router.navigate({ to: '/' })
expect(await screen.findByText('Index')).toBeInTheDocument()
})

test('same-location invalidation resolves after its refreshed DOM commits', async () => {
Expand Down
68 changes: 46 additions & 22 deletions packages/router-core/INTERNALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ The main authorities are:
| Match flight lease | Ownership keeping that loader work alive |
| Pending session | One reveal/minimum-visible deadline and its current owner |
| React acknowledgement slot | The one requested publication whose render may settle a transition |
| Refresh transaction | Its starting presentation, handoff, and ability to roll back |
| Refresh transaction | Forced rematerialization and an optional hydration handoff |
| Request signal | Lifetime of one server request and any accepted SSR stream |
| Accepted SSR stream response | Cleanup ownership transferred from the handler to the response body |

Expand Down Expand Up @@ -400,8 +400,8 @@ lane uses that data or requires a loader generation. A discoverable same-ID
flight may satisfy that requirement, including when `shouldReload` returns
`true`.

Every eligible successful blocking loader task from an ordinary foreground
navigation or preload attempts cache admission immediately after loader
Every eligible successful blocking loader task from a foreground navigation,
development refresh, or preload attempts cache admission immediately after loader
settlement. It does not wait for component readiness, whole-lane reduction,
projection, commit, or render acknowledgement. The cache receives a
non-terminal copy with merged context removed and an additional flight lease;
Expand All @@ -413,9 +413,10 @@ normal navigation staleness and GC semantics rather than becoming synthetic
preloads.

Background loader candidates remain private until their exact transaction and
committed base authorize background publication. Development refresh foreground
generations also skip early cache admission because their candidate publication
may roll back.
committed base authorize background publication. A development refresh may
admit successful loader work while its lane is private, but refresh commit
discards every cache generation instead of preserving stale rematerialization
inputs.

It is valid for cached loader data to have been produced under context from an
older `beforeLoad` generation. Loaders are the cache boundary; guards are not.
Expand Down Expand Up @@ -601,6 +602,13 @@ reduction.
Returned and thrown redirects/not-founds normalize identically. Only an error
invokes route `onError`; if `onError` throws, its value is normalized again and
may itself become an error, not-found, or redirect.
Client and server lanes build a route redirect while its source route still owns
the outcome. Target-construction failures therefore become that route's error,
and a successful client target travels with the redirect so navigation does not
run functional updaters twice.
Client loader flights retain the raw redirect because a flight can be shared.
Each consuming lane materializes that redirect against its own location and
policy before exposing the lane-owned task outcome to settlement and reduction.
Router cancellation and request abort bypass `onError`. Aborting the
`AbortController` exposed to a loader is not by itself proof that the router
discarded the work: a still-owned loader may fulfill or reject afterward, and
Expand All @@ -610,6 +618,11 @@ proves request cancellation, while the already selected failure/control outcome
proves that an aborted descendant is obsolete. The client calls a discarded
non-result `canceled`, while the server calls it `skipped`; neither is a
publishable terminal state.
The transaction controller exposed to client `context` and `beforeLoad` is
router-owned; route code may observe its signal but must not abort it. A canceled
client lane has therefore already lost writer authority to a successor.
Canceled client lanes release only their private work; the transaction that
superseded them owns presentation and completion.

The client and server use the same settlement order and renderable-ancestor
rules.
Expand Down Expand Up @@ -828,10 +841,9 @@ session identity. Changing the selected boundary discards the old session.
- `false` means core must finish without emitting `onRendered` or starting a
pending minimum based on that publication.

A rejected acknowledgement aborts the transaction that owns the exact pending
session. The transaction remains responsible for restoring the committed lane
and releasing resources; stale acknowledgement failures cannot abort a
successor.
Framework transition wrappers are controlled adapters. Supersession settles an
older acknowledgement as `false`; core does not retain a prior presentation or
install a compensating restoration path for wrapper failure.

React cannot await `React.startTransition` directly. Its adapter keeps one
router-owned acknowledgement tuple, and `Matches` settles that tuple from a
Expand Down Expand Up @@ -973,19 +985,19 @@ rematches with committed/cache reuse disabled so obsolete params, context,
loader data, or projected assets cannot seed the refreshed lane. Selected cache
and preload resources are detached before their controllers are aborted.

Unlike an ordinary foreground navigation, a development refresh does not admit
its loader successes to the cache while its candidate lane is private. The
refresh publication checkpoint is created only after lane execution; admitting
the new generation earlier would capture it as rollback cache and allow a failed
or superseded refresh to remain reusable. Acknowledged refresh data becomes
committed normally and may be cached later when displaced.
Before publication, a failed or superseded refresh leaves the painted
presentation alone because its candidate is still private. Once published, the
refresh is the accepted semantic and presentation generation even while exact
framework acknowledgement is pending. Publication uses the ordinary commit
ownership transfer, releases the replaced generation immediately, consumes any
obsolete hydration handoff, and retains no prior cache or presentation
checkpoint.

Refresh does not immediately discard the accepted committed lane or abort the
loader signals it still owns. The previous semantic lane, presentation, and
their resources remain available to the refresh transaction until the new
publication settles or rolls back. Settlement releases the replaced generation;
rollback restores it. The ability to roll back belongs to that refresh
transaction, not to a separate router-global owner.
A rapid successor refresh rematerializes again, settles the older framework
receipt as `false`, and owns convergence and public completion through the same
`_tx` and `awaitCurrent` chain as ordinary navigation. The older transaction
releases only private background work that did not transfer. No refresh has
authority to restore a predecessor or resolve a successor's commit promise.

## Speculative preloading

Expand Down Expand Up @@ -1267,6 +1279,12 @@ identity check aborts its private work. If the published handoff becomes
incompatible, its failed identity check retires the hydration controller
and starts normal client loading from a fresh preflight.

Development refresh rematerializes instead of claiming the prefix. Its
transaction carries the existing handoff only until refreshed matches publish;
that one-way publication consumes the handoff and retires the hydration
controller before lifecycle reentrancy. A refresh superseded before publication
leaves the handoff for its rematerializing successor.

Generic framework `RouterClient` components signal streaming hydration
completion after the hydration attempt settles, including rejection. This
finally-style handoff allows bootstrap globals to be removed once the server
Expand Down Expand Up @@ -1321,6 +1339,12 @@ algorithm here.

- Do error/not-found selection, required ancestor readiness, component chunks,
and descendant redirects still reduce to one final outcome?
- Are redirects materialized while their source route owns target-construction
errors, with shared flights retaining only the raw redirect and each lane
building its own target once?
- Does cancellation require both exact signal identity and proof that the signal
is aborted, and does a canceled lane release every private background
candidate without changing presentation?
- Does pending or terminal publication retain the complete structural branch
while rendering and outputs apply their relevant cutoff, including the
selective-SSR hydration asset-prefix exception?
Expand Down
Loading
Loading