Skip to content

fix: preserve suspense promises for pending route matches#7051

Open
Sheraff wants to merge 5 commits intomainfrom
fix/pending-load-promise-suspense
Open

fix: preserve suspense promises for pending route matches#7051
Sheraff wants to merge 5 commits intomainfrom
fix/pending-load-promise-suspense

Conversation

@Sheraff
Copy link
Copy Markdown
Contributor

@Sheraff Sheraff commented Mar 26, 2026

Summary

  • keep a route's suspense promise alive while the match still renders as pending
  • add a regular navigation regression test where aborting a pending route must keep rendering the pending UI
  • add an invalidate({ forcePending: true }) regression test that keeps rendering the suspense fallback instead of throwing undefined

Testing

  • CI=1 NX_DAEMON=false pnpm nx run @tanstack/react-router:test:unit --outputStyle=stream --skipRemoteCache
  • CI=1 NX_DAEMON=false pnpm nx run @tanstack/react-router:test:types --outputStyle=stream --skipRemoteCache
  • CI=1 NX_DAEMON=false pnpm nx run @tanstack/react-router:test:eslint --outputStyle=stream --skipRemoteCache

Summary by CodeRabbit

  • Bug Fixes

    • Prevented spurious error rendering during route loads and navigations; pending UI is now preserved when in-flight loaders are aborted or when reloads are forced into a pending state.
  • Tests

    • Added regression tests covering aborted-loader navigation and forced-pending reloads to ensure pending UI remains visible and error components do not render incorrectly.

Keep the current suspense promise attached while a route still renders as pending. This prevents aborted or invalidated reloads from throwing undefined instead of suspending.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 26, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ae19c04d-88ae-42e0-b944-8e533fea4916

📥 Commits

Reviewing files that changed from the base of the PR and between c07c593 and 8e16dff.

📒 Files selected for processing (4)
  • packages/react-router/src/Match.tsx
  • packages/router-core/src/Matches.ts
  • packages/router-core/src/load-matches.ts
  • packages/router-core/src/router.ts
💤 Files with no reviewable changes (1)
  • packages/router-core/src/load-matches.ts

📝 Walkthrough

Walkthrough

Preserves per-match pending render promises and avoids clearing loadPromise on resolved loaders for pending matches; adds tests covering loader aborts during navigation and forced-pending invalidation; introduces changeset.

Changes

Cohort / File(s) Summary
Tests
packages/react-router/tests/loaders.test.tsx, packages/react-router/tests/router.test.tsx
Added tests: one verifying aborting an in-flight loader keeps the previous route's pending UI and doesn't render its error component; another ensuring router.invalidate({ forcePending: true }) keeps the pending UI during reload.
Router Core: load lifecycle
packages/router-core/src/load-matches.ts
Stopped unconditionally nulling match._nonReactive.loadPromise when loader resolution completes, preserving loadPromise for pending matches.
Matches typing
packages/router-core/src/Matches.ts
Added _nonReactive.pendingRenderPromise?: ControlledPromise<void> to RouteMatch internal state.
Router core: pending render management
packages/router-core/src/router.ts
Added clearPendingRenderPromise(match?) and logic to clear or preserve per-match pendingRenderPromise during mount, update, and invalidate flows.
React runtime: Match rendering
packages/react-router/src/Match.tsx
When a match is pending, throw a managed match._nonReactive.pendingRenderPromise (creating it if missing/not pending) instead of throwing the match's loadPromise.
Changeset
.changeset/fancy-camels-rhyme.md
New changeset declaring patch bumps for @tanstack/react-router and @tanstack/router-core with a note about fixing undefined throws on immediate redirects.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant RouterCore
  participant Match
  participant Loader

  Client->>RouterCore: navigate to /first
  RouterCore->>Match: start loader (creates loaderPromise)
  Match->>Loader: run loader (holds until resolved/aborted)
  Note right of Match: create pendingRenderPromise\nand throw it to suspend render
  Client->>RouterCore: navigate to /second
  RouterCore->>Match: abort /first loader (reject loaderPromise)
  RouterCore->>Match: do NOT clear loadPromise for pending match
  RouterCore->>Loader: resolve /second loader
  RouterCore->>Client: commit /second render with loader data
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I burrowed through promises, soft and neat,

Held pending petals where loaders meet,
No sudden clearing, no error's dread—
A gentle hop forward, carrots ahead. 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: preserve suspense promises for pending route matches' directly and clearly describes the main change—introducing a new pendingRenderPromise mechanism to keep suspense promises alive during pending renders, which addresses the core issue of preventing undefined throws or fallback rendering interruptions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pending-load-promise-suspense

Comment @coderabbitai help to get the list of available commands and usage tips.

@nx-cloud
Copy link
Copy Markdown

nx-cloud bot commented Mar 26, 2026

View your CI Pipeline Execution ↗ for commit 8e16dff

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ✅ Succeeded 15m 41s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 1m 45s View ↗

☁️ Nx Cloud last updated this comment at 2026-03-27 11:01:39 UTC

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 26, 2026

🚀 Changeset Version Preview

2 package(s) bumped directly, 20 bumped as dependents.

🟩 Patch bumps

Package Version Reason
@tanstack/react-router 1.168.7 → 1.168.8 Changeset
@tanstack/router-core 1.168.6 → 1.168.7 Changeset
@tanstack/react-start 1.167.12 → 1.167.13 Dependent
@tanstack/react-start-client 1.166.22 → 1.166.23 Dependent
@tanstack/react-start-server 1.166.22 → 1.166.23 Dependent
@tanstack/router-cli 1.166.22 → 1.166.23 Dependent
@tanstack/router-generator 1.166.21 → 1.166.22 Dependent
@tanstack/router-plugin 1.167.8 → 1.167.9 Dependent
@tanstack/router-vite-plugin 1.166.23 → 1.166.24 Dependent
@tanstack/solid-router 1.168.6 → 1.168.7 Dependent
@tanstack/solid-start 1.167.11 → 1.167.12 Dependent
@tanstack/solid-start-client 1.166.20 → 1.166.21 Dependent
@tanstack/solid-start-server 1.166.20 → 1.166.21 Dependent
@tanstack/start-client-core 1.167.6 → 1.167.7 Dependent
@tanstack/start-plugin-core 1.167.13 → 1.167.14 Dependent
@tanstack/start-server-core 1.167.6 → 1.167.7 Dependent
@tanstack/start-static-server-functions 1.166.22 → 1.166.23 Dependent
@tanstack/start-storage-context 1.166.20 → 1.166.21 Dependent
@tanstack/vue-router 1.168.6 → 1.168.7 Dependent
@tanstack/vue-start 1.167.11 → 1.167.12 Dependent
@tanstack/vue-start-client 1.166.20 → 1.166.21 Dependent
@tanstack/vue-start-server 1.166.20 → 1.166.21 Dependent

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 26, 2026

Bundle Size Benchmarks

  • Commit: 21bd99242026
  • Measured at: 2026-03-27T10:46:51.526Z
  • Baseline source: history:42c3f3b3a3a4
  • Dashboard: bundle-size history
Scenario Current (gzip) Delta vs baseline Raw Brotli Trend
react-router.minimal 87.57 KiB +106 B (+0.12%) 276.01 KiB 76.09 KiB ██▇▇▃▃▂▂▂▂▁▂
react-router.full 90.83 KiB +76 B (+0.08%) 287.20 KiB 79.02 KiB ██▇▇▃▃▂▂▂▂▁▂
solid-router.minimal 35.58 KiB +72 B (+0.20%) 107.29 KiB 31.98 KiB ██▅▅▅▅▂▂▂▂▁▃
solid-router.full 40.05 KiB +70 B (+0.17%) 120.82 KiB 36.02 KiB ██▄▄▄▄▃▃▃▃▁▃
vue-router.minimal 53.44 KiB +78 B (+0.14%) 153.22 KiB 47.99 KiB ████▂▂▂▂▂▂▁▂
vue-router.full 58.30 KiB +84 B (+0.14%) 168.68 KiB 52.30 KiB ████▃▃▂▂▂▂▁▂
react-start.minimal 102.09 KiB +106 B (+0.10%) 324.19 KiB 88.32 KiB ██▆▆▂▂▂▂▂▂▁▂
react-start.full 105.46 KiB +103 B (+0.10%) 334.55 KiB 91.11 KiB ██▇▇▂▂▂▂▂▂▁▂
solid-start.minimal 49.68 KiB +80 B (+0.16%) 153.47 KiB 43.75 KiB ██▅▅▅▅▂▂▂▂▁▃
solid-start.full 55.20 KiB +103 B (+0.18%) 169.71 KiB 48.53 KiB ██▄▄▄▄▃▃▃▃▁▄

Trend sparkline is historical gzip bytes ending with this PR measurement; lower is better.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 26, 2026

More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/@tanstack/arktype-adapter@7051

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/@tanstack/eslint-plugin-router@7051

@tanstack/history

npm i https://pkg.pr.new/@tanstack/history@7051

@tanstack/nitro-v2-vite-plugin

npm i https://pkg.pr.new/@tanstack/nitro-v2-vite-plugin@7051

@tanstack/react-router

npm i https://pkg.pr.new/@tanstack/react-router@7051

@tanstack/react-router-devtools

npm i https://pkg.pr.new/@tanstack/react-router-devtools@7051

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/@tanstack/react-router-ssr-query@7051

@tanstack/react-start

npm i https://pkg.pr.new/@tanstack/react-start@7051

@tanstack/react-start-client

npm i https://pkg.pr.new/@tanstack/react-start-client@7051

@tanstack/react-start-server

npm i https://pkg.pr.new/@tanstack/react-start-server@7051

@tanstack/router-cli

npm i https://pkg.pr.new/@tanstack/router-cli@7051

@tanstack/router-core

npm i https://pkg.pr.new/@tanstack/router-core@7051

@tanstack/router-devtools

npm i https://pkg.pr.new/@tanstack/router-devtools@7051

@tanstack/router-devtools-core

npm i https://pkg.pr.new/@tanstack/router-devtools-core@7051

@tanstack/router-generator

npm i https://pkg.pr.new/@tanstack/router-generator@7051

@tanstack/router-plugin

npm i https://pkg.pr.new/@tanstack/router-plugin@7051

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/@tanstack/router-ssr-query-core@7051

@tanstack/router-utils

npm i https://pkg.pr.new/@tanstack/router-utils@7051

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/@tanstack/router-vite-plugin@7051

@tanstack/solid-router

npm i https://pkg.pr.new/@tanstack/solid-router@7051

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/@tanstack/solid-router-devtools@7051

@tanstack/solid-router-ssr-query

npm i https://pkg.pr.new/@tanstack/solid-router-ssr-query@7051

@tanstack/solid-start

npm i https://pkg.pr.new/@tanstack/solid-start@7051

@tanstack/solid-start-client

npm i https://pkg.pr.new/@tanstack/solid-start-client@7051

@tanstack/solid-start-server

npm i https://pkg.pr.new/@tanstack/solid-start-server@7051

@tanstack/start-client-core

npm i https://pkg.pr.new/@tanstack/start-client-core@7051

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/@tanstack/start-fn-stubs@7051

@tanstack/start-plugin-core

npm i https://pkg.pr.new/@tanstack/start-plugin-core@7051

@tanstack/start-server-core

npm i https://pkg.pr.new/@tanstack/start-server-core@7051

@tanstack/start-static-server-functions

npm i https://pkg.pr.new/@tanstack/start-static-server-functions@7051

@tanstack/start-storage-context

npm i https://pkg.pr.new/@tanstack/start-storage-context@7051

@tanstack/valibot-adapter

npm i https://pkg.pr.new/@tanstack/valibot-adapter@7051

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/@tanstack/virtual-file-routes@7051

@tanstack/vue-router

npm i https://pkg.pr.new/@tanstack/vue-router@7051

@tanstack/vue-router-devtools

npm i https://pkg.pr.new/@tanstack/vue-router-devtools@7051

@tanstack/vue-router-ssr-query

npm i https://pkg.pr.new/@tanstack/vue-router-ssr-query@7051

@tanstack/vue-start

npm i https://pkg.pr.new/@tanstack/vue-start@7051

@tanstack/vue-start-client

npm i https://pkg.pr.new/@tanstack/vue-start-client@7051

@tanstack/vue-start-server

npm i https://pkg.pr.new/@tanstack/vue-start-server@7051

@tanstack/zod-adapter

npm i https://pkg.pr.new/@tanstack/zod-adapter@7051

commit: 8e16dff

@Sheraff Sheraff requested a review from schiller-manuel March 26, 2026 21:49
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Mar 26, 2026

Merging this PR will not alter performance

✅ 6 untouched benchmarks


Comparing fix/pending-load-promise-suspense (8e16dff) with main (42c3f3b)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (21bd992) during the generation of this report, so 42c3f3b was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Sheraff added 2 commits March 27, 2026 07:18
Keep resolving shared load promises in core so Solid suspense and selective SSR can complete, but let React create a temporary local suspense promise when a pending match no longer has a pending load promise to throw.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/react-router/src/Match.tsx (1)

488-511: Unreachable server-side code in client-only component.

Since MatchInnerClient is only called when !(isServer ?? router.isServer) (see line 263), the isServer check at line 494 will always be false. The server-side error rendering code (lines 495-507) is unreachable.

♻️ Optional: Remove dead code
   if (match.status === 'error') {
-    // If we're on the server, we need to use React's new and super
-    // wonky api for throwing errors from a server side render inside
-    // of a suspense boundary. This is the only way to get
-    // renderToPipeableStream to not hang indefinitely.
-    // We'll serialize the error and rethrow it on the client.
-    if (isServer ?? router.isServer) {
-      const RouteErrorComponent =
-        (route.options.errorComponent ??
-          router.options.defaultErrorComponent) ||
-        ErrorComponent
-      return (
-        <RouteErrorComponent
-          error={match.error as any}
-          reset={undefined as any}
-          info={{
-            componentStack: '',
-          }}
-        />
-      )
-    }
-
     throw match.error
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router/src/Match.tsx` around lines 488 - 511, The server-side
rendering branch inside the match.status === 'error' block is unreachable in
MatchInnerClient because MatchInnerClient is only invoked when !(isServer ??
router.isServer); remove the dead server-specific code (the isServer ??
router.isServer check and the RouteErrorComponent JSX return) and leave the
client behavior (throw match.error) or replace it with the appropriate
client-only error handling; update the match.status === 'error' block in
MatchInnerClient (referencing match, isServer, router.isServer, and
RouteErrorComponent) to eliminate the unreachable server render path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/react-router/src/Match.tsx`:
- Around line 488-511: The server-side rendering branch inside the match.status
=== 'error' block is unreachable in MatchInnerClient because MatchInnerClient is
only invoked when !(isServer ?? router.isServer); remove the dead
server-specific code (the isServer ?? router.isServer check and the
RouteErrorComponent JSX return) and leave the client behavior (throw
match.error) or replace it with the appropriate client-only error handling;
update the match.status === 'error' block in MatchInnerClient (referencing
match, isServer, router.isServer, and RouteErrorComponent) to eliminate the
unreachable server render path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 62dea7b3-5421-4bb4-adc3-2d8f241654e7

📥 Commits

Reviewing files that changed from the base of the PR and between e1dbc02 and 00ca45c.

📒 Files selected for processing (2)
  • packages/react-router/src/Match.tsx
  • packages/router-core/src/load-matches.ts
💤 Files with no reviewable changes (1)
  • packages/router-core/src/load-matches.ts

@Sheraff Sheraff requested a review from schiller-manuel March 27, 2026 12:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants