Skip to content

useRouteContext() renders with beforeLoad-provided context stripped during pending presentation #8115

Description

@antur84

Which project does this relate to?

Router

Describe the bug

Since the lane loader rewrite (1.170.29+), a client-side navigation can render an already-mounted route with a context that's missing every key its beforeLoad returns.

contextualize (router-core dist/esm/load-client.js) updates the in-flight lane match in two steps: it assigns match.context = { ...parentContext, ...routeContext } before awaiting the route's beforeLoad, and merges the result back in only after the await. Meanwhile offerPending fires at pendingMs and presents shallow clones of those same lane matches. If the timer lands inside the await, the presented root match still has status: "success" (it sits above the pending boundary), so the root component re-renders from it — and useRouteContext() is suddenly missing everything beforeLoad provides.

It only needs three ordinary ingredients: a pending component on the navigation target (or a defaultPendingComponent), a pendingMs shorter than an async root beforeLoad (we use defaultPendingMs: 150; on mobile devices a 150ms+ beforeLoad is routine), and a component that reads that context during render. With router.invalidate() involved it's worse — invalidated matches present with zero delay (match.invalid ? 0 : pendingMs), so even a fast beforeLoad window gets rendered.

Your Example Website or App

https://github.com/antur84/tanstack-router-pending-context-loss-repro

pnpm install && pnpm build && pnpm start, open the app with the console showing, click either button. One attempt per page load (a retained destination never goes pending), and note the repro disables defaultPreload: 'intent' — hover preloading moves the navigation outside the window, which is also why this skews hard to touch devices in production. The old-versions-clean branch is the identical app pinned to 1.170.15 / 1.168.25: same clicks, no context loss.

Expected behavior

A match being reloaded keeps its previous (complete) context visible until the new beforeLoad result is in — the rendered UI should never observe the half-built context.

Actual behavior

The root component re-renders with the stripped context, crashes into the root errorComponent for a moment, and the navigation then completes. Consumers that cache the value are broken past the flash — in our app the locale from root beforeLoad seeds the i18n runtime, so this crashed ~500 users/hour to the error screen after we upgraded (reverting stopped it).

Suggested fix

Don't overwrite match.context before beforeLoad settles — keep the fresh merge local and assign once, after the result is in (assign directly only on the no-beforeLoad path). Retained matches then keep their previous complete context while reloading, and fresh matches below the pending boundary are presented as pending, so nothing reads their empty context. Happy to open a PR with this plus a test if you want it.

Platform

  • OS: any (reproduced on macOS; production impact on Android/iOS)
  • Browser: any
  • Version: @tanstack/react-router 1.170.29 / @tanstack/react-start 1.168.46 (the quoted code is also present in latest router-core 1.171.24)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions