Skip to content

fix(nuxt): force remount suspense when navigating after pending#33991

Merged
danielroe merged 5 commits intonuxt:mainfrom
OrbisK:test/28425
Jan 20, 2026
Merged

fix(nuxt): force remount suspense when navigating after pending#33991
danielroe merged 5 commits intonuxt:mainfrom
OrbisK:test/28425

Conversation

@OrbisK
Copy link
Member

@OrbisK OrbisK commented Dec 30, 2025

🔗 Linked issue

resolves #28425

📚 Description

These tests should recreate the issue as runtime test.

@OrbisK OrbisK requested a review from danielroe as a code owner December 30, 2025 13:32
@bolt-new-by-stackblitz
Copy link

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

}

afterEach(() => {
router.clearRoutes()
Copy link
Member Author

Choose a reason for hiding this comment

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

For some reason, this is not working as expected. The router is not fully reset after the test and this affects subsequent tests.

Copy link
Member Author

Choose a reason for hiding this comment

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

cc @cernymatej (copied this from you code below)

Copy link
Member

Choose a reason for hiding this comment

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

@OrbisK looking at it, it seems that while we do clear the routes, we don't reset the router fully... it stays on the /28425/p3 path for subsequent tests, for example:

image

and we also clear the initial route defined in test/runtime/app/router.options.ts

So maybe, it would be more appropriate to reset the router like this:

const initialRoutes = router.getRoutes()

afterEach(async () => {
  await router.replace('/')
  router.clearRoutes()
  for (const route of initialRoutes) {
   router.addRoute(route)
  }
})

But for some reason, this now causes expect(el.html()).toContain('<div> parent/suspense </div>') to fail 😕 and it requires waiting for page change twice to start working again 😅

await waitForPageChange()
await waitForPageChange()

expect(el.html()).toContain('<div> parent/suspense </div>')  // ✅ works

Copy link
Member

Choose a reason for hiding this comment

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

Maybe we should implement some robust utilities to work with the router (resetting, awaiting page change with timeout, etc.)

@danielroe
Copy link
Member

thank you! ❤️

maybe use it.fails instead?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 30, 2025

Walkthrough

Adds test scaffolding for navigateTo by creating a local nuxtApp and router, and a waitForPageChange helper that resolves on page:finish. Expands routing tests to register/remove a dynamic slug route, mount NuxtPage, perform multiple navigateTo calls, await page changes, and assert rendered content and final route path; makes useRoute test asynchronous and awaits an initial navigateTo('/'). Separately, updates packages/nuxt/src/pages/runtime/page.ts to track suspense state (isSuspensePending, suspenseKey), force remounts by adding a Suspense key, mark running transitions on pending, and emit page:start, page:finish, and page:loading/end while deferring route sync until suspense resolves.

Possibly related PRs

  • nuxt/nuxt PR 30807 — Modifies packages/nuxt/src/pages/runtime/page.ts, affecting NuxtPage rendering and Suspense remount behaviour.
  • nuxt/nuxt PR 32239 — Introduces/relocates transition-running logic into Suspense onPending, adding suspense tracking and page:start/page:finish triggers.
  • nuxt/nuxt PR 29009 — Adjusts NuxtPage Suspense/loading lifecycle and the timing of page lifecycle hooks (keys/remounts and page:loading/end).
🚥 Pre-merge checks | ✅ 3 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
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.
Out of Scope Changes check ❓ Inconclusive The docs file change reorders imports and adjusts formatting with no functional impact. This appears tangential but is a minor documentation update; the core changes in page.ts and composables.test.ts are directly scoped to fixing the navigation issue. Clarify whether the documentation formatting change in modules.md is intentional and related to the fix, or if it should be excluded from this PR to keep changes focused on the core issue.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: fixing suspense remount on navigation after pending, which directly addresses the linked issue #28425 about app crashes on fast navigation.
Description check ✅ Passed The description is related to the changeset, referencing the linked issue #28425 and explaining that tests are added to recreate the issue as runtime tests.
Linked Issues check ✅ Passed The PR addresses issue #28425 by adding runtime tests that reproduce the fast navigation crash scenario. The changes to page.ts implement suspense remount tracking and the test file adds test cases for navigateTo scenarios with async data.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
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.

Actionable comments posted: 0

🧹 Nitpick comments (1)
test/nuxt/composables.test.ts (1)

481-503: Verify the test correctly reproduces the issue without intermediate waits.

The test calls navigateTo multiple times in rapid succession (lines 494-497) without awaiting between calls, then waits only once at the end (line 498). Whilst this may be intentional to reproduce the race condition described in #28425, please verify this accurately reflects the issue scenario.

Additionally, consider making the test name more descriptive to explain what behaviour is being tested, e.g., 'should handle multiple rapid navigateTo calls to dynamic routes with async setup'.

Optional: More descriptive test name
- it.todo('#28425', async () => {
+ it.todo('#28425: should handle multiple rapid navigateTo calls to dynamic routes with async setup', async () => {
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a9b0ebb and 7184d92.

📒 Files selected for processing (1)
  • test/nuxt/composables.test.ts
🧰 Additional context used
📓 Path-based instructions (3)
**/*.{ts,tsx,vue}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Follow standard TypeScript conventions and best practices

Files:

  • test/nuxt/composables.test.ts
**/*.{ts,tsx,js,jsx,vue}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.{ts,tsx,js,jsx,vue}: Use clear, descriptive variable and function names
Add comments only to explain complex logic or non-obvious implementations
Keep functions focused and manageable (generally under 50 lines), and extract complex logic into separate domain-specific files
Remove code that is not used or needed
Use error handling patterns consistently

Files:

  • test/nuxt/composables.test.ts
**/*.{test,spec}.{ts,tsx,js}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Write unit tests for core functionality using vitest

Files:

  • test/nuxt/composables.test.ts
🧠 Learnings (4)
📓 Common learnings
Learnt from: CR
Repo: nuxt/nuxt PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-11-25T11:42:16.132Z
Learning: Applies to **/e2e/**/*.{ts,tsx,js} : Write end-to-end tests using Playwright and `nuxt/test-utils`
📚 Learning: 2025-11-25T11:42:16.132Z
Learnt from: CR
Repo: nuxt/nuxt PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-11-25T11:42:16.132Z
Learning: Applies to **/e2e/**/*.{ts,tsx,js} : Write end-to-end tests using Playwright and `nuxt/test-utils`

Applied to files:

  • test/nuxt/composables.test.ts
📚 Learning: 2024-12-12T12:36:34.871Z
Learnt from: huang-julien
Repo: nuxt/nuxt PR: 29366
File: packages/nuxt/src/app/components/nuxt-root.vue:16-19
Timestamp: 2024-12-12T12:36:34.871Z
Learning: In `packages/nuxt/src/app/components/nuxt-root.vue`, when optimizing bundle size by conditionally importing components based on route metadata, prefer using inline conditional imports like:

```js
const IsolatedPage = route?.meta?.isolate ? defineAsyncComponent(() => import('#build/isolated-page.mjs')) : null
```

instead of wrapping the import in a computed property or importing the component unconditionally.

Applied to files:

  • test/nuxt/composables.test.ts
📚 Learning: 2025-09-10T14:42:56.647Z
Learnt from: Tofandel
Repo: nuxt/nuxt PR: 33192
File: test/nuxt/use-async-data.test.ts:366-373
Timestamp: 2025-09-10T14:42:56.647Z
Learning: In the Nuxt useAsyncData test "should watch params deeply in a non synchronous way", the foo watcher intentionally updates both params.foo and params.locale using locale.value, simulating a scenario where one watcher consolidates multiple reactive values into a shared params object for testing debounced/non-synchronous behavior.

Applied to files:

  • test/nuxt/composables.test.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (19)
  • GitHub Check: test-fixtures (windows-latest, built, webpack, default, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (windows-latest, built, vite, default, manifest-off, json, lts/-1)
  • GitHub Check: test-fixtures (windows-latest, dev, vite-env-api, async, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (windows-latest, dev, vite, default, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (windows-latest, dev, vite, async, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, built, webpack, default, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (windows-latest, dev, vite, default, manifest-off, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, dev, vite-env-api, async, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, built, webpack, async, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, built, vite, async, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, built, vite, default, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, dev, vite, async, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, built, vite, async, manifest-off, json, lts/-1)
  • GitHub Check: release-pkg-pr-new
  • GitHub Check: typecheck (ubuntu-latest, bundler)
  • GitHub Check: typecheck (windows-latest, bundler)
  • GitHub Check: test-size
  • GitHub Check: test-benchmark
  • GitHub Check: code
🔇 Additional comments (1)
test/nuxt/composables.test.ts (1)

443-453: LGTM! Test scaffolding follows established patterns.

The setup with shared nuxtApp and router instances mirrors the existing pattern used in the useRoute tests (lines 529-539), and the waitForPageChange helper is correctly implemented using hookOnce. The afterEach cleanup ensures routes are cleared between tests.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Dec 30, 2025

Open in StackBlitz

@nuxt/kit

npm i https://pkg.pr.new/@nuxt/kit@33991

@nuxt/nitro-server

npm i https://pkg.pr.new/@nuxt/nitro-server@33991

nuxt

npm i https://pkg.pr.new/nuxt@33991

@nuxt/rspack-builder

npm i https://pkg.pr.new/@nuxt/rspack-builder@33991

@nuxt/schema

npm i https://pkg.pr.new/@nuxt/schema@33991

@nuxt/vite-builder

npm i https://pkg.pr.new/@nuxt/vite-builder@33991

@nuxt/webpack-builder

npm i https://pkg.pr.new/@nuxt/webpack-builder@33991

commit: 3883fbe

nuxtApp._processingMiddleware = false
})

it.todo('#28425', async () => {
Copy link
Member Author

Choose a reason for hiding this comment

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

We cant use the .fails here because the test results in an infinite loop that doesnt time out.

@OrbisK
Copy link
Member Author

OrbisK commented Dec 30, 2025

thank you! ❤️

maybe use it.fails instead?

#33991 (review) 😅

@codspeed-hq
Copy link

codspeed-hq bot commented Dec 30, 2025

Merging this PR will improve performance by 11.62%

⚡ 1 improved benchmark
✅ 9 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
writeTypes in the basic-types fixture 94 ms 84.2 ms +11.62%

Comparing OrbisK:test/28425 (3883fbe) with main (c02c83b)1

Open in CodSpeed

Footnotes

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

@danielroe danielroe changed the title test: add reproduction for #28425 fix(nuxt): force remount suspense when navigating after pending Jan 4, 2026
Copy link
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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/nuxt/src/pages/runtime/page.ts (1)

180-199: Add missing onError callback to reset state on Suspense failure.

The onPending and onResolve callbacks properly manage the isSuspensePending flag and hook sequencing, with try/finally ensuring cleanup occurs. However, there is no onError callback to handle Suspense resolution failures, which would leave isSuspensePending = true if an error occurs. This violates the error handling consistency guideline and could break subsequent navigations.

Add an onError callback:

Example addition
onError: () => {
  isSuspensePending = false
  delete nuxtApp._runningTransition
}

Additionally, verify that the await nextTick() addition at line 188 does not introduce timing regressions with route synchronisation during concurrent navigations.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7184d92 and d23dd87.

📒 Files selected for processing (2)
  • packages/nuxt/src/pages/runtime/page.ts
  • test/nuxt/composables.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/nuxt/composables.test.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx,vue}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Follow standard TypeScript conventions and best practices

Files:

  • packages/nuxt/src/pages/runtime/page.ts
**/*.{ts,tsx,js,jsx,vue}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.{ts,tsx,js,jsx,vue}: Use clear, descriptive variable and function names
Add comments only to explain complex logic or non-obvious implementations
Keep functions focused and manageable (generally under 50 lines), and extract complex logic into separate domain-specific files
Remove code that is not used or needed
Use error handling patterns consistently

Files:

  • packages/nuxt/src/pages/runtime/page.ts
🧠 Learnings (1)
📓 Common learnings
Learnt from: CR
Repo: nuxt/nuxt PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2025-11-25T11:42:16.132Z
Learning: Applies to **/e2e/**/*.{ts,tsx,js} : Write end-to-end tests using Playwright and `nuxt/test-utils`
Learnt from: huang-julien
Repo: nuxt/nuxt PR: 29366
File: packages/nuxt/src/app/components/nuxt-root.vue:16-19
Timestamp: 2024-12-12T12:36:34.871Z
Learning: In `packages/nuxt/src/app/components/nuxt-root.vue`, when optimizing bundle size by conditionally importing components based on route metadata, prefer using inline conditional imports like:

```js
const IsolatedPage = route?.meta?.isolate ? defineAsyncComponent(() => import('#build/isolated-page.mjs')) : null
```

instead of wrapping the import in a computed property or importing the component unconditionally.
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (19)
  • GitHub Check: test-fixtures (ubuntu-latest, built, rspack, default, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (windows-latest, built, vite-env-api, default, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, built, vite, default, manifest-off, json, lts/-1)
  • GitHub Check: test-fixtures (windows-latest, dev, vite, async, manifest-off, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, built, vite, async, manifest-off, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, dev, vite-env-api, default, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, built, vite-env-api, default, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, built, vite, async, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, built, rspack, async, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, built, vite, async, manifest-on, js, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, dev, vite, default, manifest-off, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, built, vite-env-api, async, manifest-on, json, lts/-1)
  • GitHub Check: test-fixtures (ubuntu-latest, dev, vite, async, manifest-on, js, lts/-1)
  • GitHub Check: typecheck (windows-latest, bundler)
  • GitHub Check: release-pkg-pr-new
  • GitHub Check: typecheck (ubuntu-latest, bundler)
  • GitHub Check: test-benchmark
  • GitHub Check: test-size
  • GitHub Check: code
🔇 Additional comments (2)
packages/nuxt/src/pages/runtime/page.ts (2)

71-72: LGTM! Clear variable declarations for suspense state tracking.

The closure-scoped variables isSuspensePending and suspenseKey are well-named and appropriately initialised to track internal suspense state and enable force remounts.


178-178: Correct use of Suspense key prop.

Adding the reactive suspenseKey as the Suspense key prop is the standard Vue pattern for forcing remounts when the key changes.

Comment on lines +154 to +158
// force suspense remount and restart async tracking
// if suspense is already pending and page key changed
if (isSuspensePending && previousPageKey !== key) {
suspenseKey++
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "page.ts" -path "*/pages/runtime/*" | head -5

Repository: nuxt/nuxt

Length of output: 94


🏁 Script executed:

wc -l packages/nuxt/src/pages/runtime/page.ts

Repository: nuxt/nuxt

Length of output: 96


🏁 Script executed:

sed -n '140,170p' packages/nuxt/src/pages/runtime/page.ts

Repository: nuxt/nuxt

Length of output: 1420


🏁 Script executed:

grep -n "isSuspensePending" packages/nuxt/src/pages/runtime/page.ts

Repository: nuxt/nuxt

Length of output: 258


🏁 Script executed:

sed -n '1,100p' packages/nuxt/src/pages/runtime/page.ts | head -80

Repository: nuxt/nuxt

Length of output: 3077


🏁 Script executed:

grep -n "Suspense\|onError\|error" packages/nuxt/src/pages/runtime/page.ts

Repository: nuxt/nuxt

Length of output: 608


🏁 Script executed:

sed -n '170,195p' packages/nuxt/src/pages/runtime/page.ts

Repository: nuxt/nuxt

Length of output: 1374


🏁 Script executed:

sed -n '100,120p' packages/nuxt/src/pages/runtime/page.ts

Repository: nuxt/nuxt

Length of output: 1163


🌐 Web query:

Vue 3 Suspense component error handling onError callback

💡 Result:

Short answer: Suspense has no built‑in onError callback. Use errorCaptured (Options API) or onErrorCaptured (Composition API) in a parent/wrapper component to catch errors from suspended children, set an error state, show an error UI (or retry), and optionally return false to stop propagation. See Vue docs. [1][2]

Example (script setup):

<script setup> import { ref, onErrorCaptured } from 'vue' const error = ref(null) onErrorCaptured((err, instance, info) => { error.value = err // return false to stop propagation to higher handlers/app.config.errorHandler return false }) </script>

Error: {{ error.message }}

Retry

Loading…

Notes:

  • The third arg (info) indicates source (e.g. "setup", "render", "async component loader"). In production it's a short code. [2]
  • Don’t rely on Suspense for error boundaries; handle errors with errorCaptured/onErrorCaptured or app.config.errorHandler. [1][2]

Sources:

  1. Vue Suspense docs — error handling note. [1]
  2. Vue API docs — onErrorCaptured / errorCaptured details (args, return false behaviour, production codes). [2]

Add error handling to reset isSuspensePending flag on suspense failure.

The logic correctly forces remount when navigating during pending suspense, but the flag is never reset if the suspended component throws an error. Since Vue 3 Suspense provides only onPending and onResolve callbacks (no onError), if an async component fails, isSuspensePending remains true indefinitely. On subsequent navigations where the page key changes, this causes the condition to remain true and incorrectly increments suspenseKey even after the error has been resolved elsewhere.

Add error handling using onErrorCaptured in a wrapper component or use the app error hook to reset the flag when suspense-related errors occur, ensuring the flag reflects the actual suspense state.

@danielroe danielroe merged commit d19f1a8 into nuxt:main Jan 20, 2026
54 of 56 checks passed
@github-actions github-actions bot mentioned this pull request Jan 20, 2026
@github-actions github-actions bot mentioned this pull request Jan 21, 2026
@Kolobok12309
Copy link
Contributor

@OrbisK Can you please tell, how you debug this and why key on Suspense fix it?
This pr broke another good feature (when you have chain of redirects, first page component unmounted on first navigateTo call, without Suspense key it unmounted only when last page is ready), but memory leak is more important

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.

App crash on fast navigation

4 participants