Skip to content

fix: handle notFound in Suspense with cacheComponents enabled#87041

Closed
Rani367 wants to merge 1 commit into
vercel:canaryfrom
Rani367:fix/notfound-suspense-cache-components
Closed

fix: handle notFound in Suspense with cacheComponents enabled#87041
Rani367 wants to merge 1 commit into
vercel:canaryfrom
Rani367:fix/notfound-suspense-cache-components

Conversation

@Rani367

@Rani367 Rani367 commented Dec 10, 2025

Copy link
Copy Markdown
Contributor

What?

This PR fixes an issue where notFound() (and other HTTP access fallback errors like forbidden()/unauthorized()) would cause "Connection closed" errors when cacheComponents: true is enabled and the page has a Suspense boundary with an async component.

Why?

When cacheComponents is enabled and a page is prerendered, subsequent requests use DynamicState.DATA mode which only sends RSC data without re-rendering the HTML shell. However, when notFound() is thrown during this RSC render, the prerendered HTML doesn't contain the not-found component, causing the client to receive an incomplete stream and display "Connection closed" errors.

How?

This fix buffers the RSC stream in DynamicState.DATA mode to detect HTTP access fallback errors. After consuming the stream:

  1. Check reactServerErrorsByDigest for any errors with the HTTP_ERROR_FALLBACK_ERROR_CODE prefix
  2. If no such errors exist, send the buffered RSC data as before
  3. If such errors are found, set the appropriate status code and fall through to the full dynamic render path which properly handles the not-found page

Test Plan

Added two test cases:

  • not-found-suspense: Tests notFound() thrown inside a Suspense boundary
  • not-found-with-layout-suspense: Exact reproduction of the issue - notFound() in page with async component in layout's Suspense

Fixes #86251

@nextjs-bot

nextjs-bot commented Dec 10, 2025

Copy link
Copy Markdown
Contributor

Allow CI Workflow Run

  • approve CI run for commit: 5f3bc6798896e03e668bfb620ca95f76a810a805

Note: this should only be enabled once the PR is ready to go and can only be enabled by a maintainer

@harikapadia999

Copy link
Copy Markdown

Excellent fix for a tricky edge case! 🎯

Problem Identified:
When cacheComponents: true is enabled and notFound() is thrown inside a Suspense boundary, the RSC stream was being sent directly without detecting the HTTP access fallback error, causing "Connection closed" errors on the client.

Solution Approach:
Your fix properly consumes the entire RSC stream first to detect HTTP access fallback errors (like notFound()), then:

  • If no errors → sends the buffered RSC data as before
  • If HTTP error detected → falls through to full dynamic render to properly display the error page

Strengths:
✅ Comprehensive test coverage with two test cases covering different scenarios
✅ Proper error detection by checking reactServerErrorsByDigest
✅ Maintains backward compatibility for non-error cases
✅ Sets correct HTTP status codes
✅ Clean fallback to dynamic rendering when needed

Minor Suggestions:

  1. Performance consideration: Buffering the entire RSC stream in memory (chunks array) could be memory-intensive for large responses. Consider adding a comment about this trade-off or exploring streaming detection if possible.

  2. Code comment clarity: The comment "We need to consume the RSC stream first..." is great, but could also mention why we can't detect this during streaming (because Suspense boundaries can catch errors).

  3. Test robustness: Consider adding a test case for a large page to ensure the buffering approach doesn't cause memory issues.

Overall, this is a solid fix for a complex streaming + error handling interaction! 🚀

@dsbrianwebster

dsbrianwebster commented Dec 29, 2025

Copy link
Copy Markdown

We are experiencing this and it is pretty much a deal killer for being able to use cache components as it essentially means we can have any working 404 behavior on pages. Hoping @Rani367's fix makes it into the next release soon.

import { notFound } from 'next/navigation'

export default async function ArticlePage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const article = await findArticle(slug)
  
  if (!article) {
    notFound() // results in connection closed and error page instead of 404
  }
  
  return <article />
}

@Rani367

Rani367 commented Dec 29, 2025

Copy link
Copy Markdown
Contributor Author

Hi @gnoff, could you take a look at this PR when you have a chance? There are users blocked by this issue (see #86251 and the comment above from @dsbrianwebster) who can't use cacheComponents with notFound(). Happy to address any feedback. Thanks!

@Rani367 Rani367 force-pushed the fix/notfound-suspense-cache-components branch from 5f3bc67 to ec2b374 Compare January 5, 2026 07:33
When cacheComponents is enabled and a page is prerendered, subsequent
requests use DynamicState.DATA mode which only sends RSC data without
re-rendering the HTML shell. However, when notFound() (or forbidden/
unauthorized) is thrown during this RSC render, the prerendered HTML
doesn't contain the not-found component, causing Connection closed
errors.

This fix buffers the RSC stream in DATA mode to detect HTTP access
fallback errors. If such errors are found, it falls through to the
full dynamic render path which properly handles the not-found page.

Fixes #86251
@Rani367 Rani367 force-pushed the fix/notfound-suspense-cache-components branch from ec2b374 to 8a7df5b Compare January 5, 2026 08:17
@Rani367

Rani367 commented Jan 5, 2026

Copy link
Copy Markdown
Contributor Author

Updated the fix to also store HTTP access fallback errors in reactServerErrorsByDigest (in create-error-handler.tsx), which is needed for the detection logic in app-render.tsx to work correctly.

@dsbrianwebster

Copy link
Copy Markdown

@Rani367 thanks for the updates. I've been communicating about this to Vercel through support ticket for an upcoming project. Hopefully that helps pull in some senior eyes from the NextJS team on this. Broken 404 behavior is a pretty critical concern, especially for SEO.

IMO, if this is a known limitation of cache components in their current form, this really feels like a broader, production-blocking issue, not just for us, but for other teams evaluating cache components for real-world deployments, and one deserving of some priority attention, or a return to an experimental flag disclaimer to opt in to cache components.

🤞 For this to get reviewed and merged soon.

Comment on lines +2829 to +2846
// We need to consume the RSC stream first to check for HTTP access fallback
// errors (like notFound()). If such an error is thrown inside a Suspense
// boundary, we can't just send RSC data - we need to do a full dynamic
// render to properly display the error page.
const rscStream = reactServerResult.consume()
const reader = rscStream.getReader()
const chunks: Uint8Array[] = []

// Consume the entire RSC stream to detect any errors
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (value) chunks.push(value)
}
} finally {
reader.releaseLock()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We cannot consume the entire stream here in this situation, as it prevents streaming the response to the client. I'll investigate the connection closed error, as that should not occur here, and it should instead trigger the not found boundary added by the framework which is responsible for rendering the boundary client side instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the review @wyattjoh! I understand the concern about buffering breaking streaming.

I couldn't find another way to detect these errors without consuming the stream first, but I'd be happy to explore alternative approaches. Looking forward to the results of your investigation into why the not-found boundary isn't triggering client-side in this case.

Let me know if there's anything I can help with or if you'd like me to try a different approach.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm experimenting with a few approaches to solve, I'll follow up here as soon as I got some more details for you! Been experimenting over at #88444 if you'd like to follow the experience!

@pauksztello

Copy link
Copy Markdown

Any updates on this?

@Rani367

Rani367 commented Mar 1, 2026

Copy link
Copy Markdown
Contributor Author

Any updates on this?

@wyattjoh ?

pull Bot pushed a commit to Mu-L/next.js that referenced this pull request Apr 2, 2026
…#92231)

When a `notFound()`, `forbidden()`, or `unauthorized()` error escapes
into the outer prerender recovery path, we were falling back to the
generic error shell flow.

In the `cacheComponents` case, that could leave us with:
- error HTML rendered from `ErrorApp`
- Flight data reused from the aborted prerender prelude
- references to Flight chunks that were never emitted

That is what caused the client-side `Connection closed` failure in
vercel#86251.

Instead of rerendering the full Flight tree or always using the generic
error RSC payload, this change:

- finds the deepest matching HTTP fallback boundary
- rerenders the normal app router payload with that segment-scoped
fallback
- tees the replacement Flight stream so Fizz can render from one copy
while prerender buffering consumes the other
- only takes this path for recoverable HTTP access fallbacks that have a
real boundary (ie a defined not-found/unauthorized/etc)

If no matching boundary exists, we keep the existing generic error
handling.

Fixes vercel#86251
Fixes vercel#90837
Closes vercel#87041
Closes vercel#86251

Closes NAR-711
Closes NEXT-4876
@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Apr 17, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

notFound breaks Suspense in layout with cacheComponents enabled

6 participants