Skip to content

fix(app-router): don't clear RSC context before lazy stream is consumed#352

Merged
james-elicx merged 2 commits intomainfrom
j-branch-8
Mar 8, 2026
Merged

fix(app-router): don't clear RSC context before lazy stream is consumed#352
james-elicx merged 2 commits intomainfrom
j-branch-8

Conversation

@james-elicx
Copy link
Copy Markdown
Collaborator

@james-elicx james-elicx commented Mar 8, 2026

When handling RSC requests (client-side navigation), the handler was calling setNavigationContext(null) and setHeadersContext(null) immediately after renderToReadableStream() returned — before the stream was actually consumed.

The problem: renderToReadableStream() returns as soon as the shell is ready, but the stream is consumed lazily by the client. Async server components that run during stream consumption (e.g. NextIntlClientProviderServer, which is rendered as a child of NextIntlClientProvider in a layout) would execute after the handler had already cleared context, causing them to see null headers/navigation context and throw. The symptom for users was useTranslations() failing with "context from NextIntlClientProvider was not found" during client-side navigation.

Fix: Remove the early setNavigationContext(null) / setHeadersContext(null) calls on the RSC response branches in renderHTTPAccessFallbackPage and renderErrorBoundaryPage. Context is already cleaned up correctly when the AsyncLocalStorage scope from runWithHeadersContext unwinds — no manual clear is needed.

The HTML response branches are unaffected: those await the full SSR pipeline (handleSsr) before responding, so context is fully consumed by the time the clears run.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 8, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@352

commit: b436f7a

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 8, 2026

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original

@james-elicx james-elicx force-pushed the j-branch-8 branch 2 times, most recently from 987d785 to d93b7bb Compare March 8, 2026 15:50
@james-elicx
Copy link
Copy Markdown
Collaborator Author

/bigbonk review this pr please

@james-elicx james-elicx marked this pull request as ready for review March 8, 2026 15:57
Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Review

The fix is correct and the test is well-constructed. Removing the early setNavigationContext(null) / setHeadersContext(null) calls from the RSC branches of renderHTTPAccessFallbackPage and renderErrorBoundaryPage is the right approach — the AsyncLocalStorage scope from runWithHeadersContext() (line 1409) naturally handles cleanup when all async continuations complete, including lazy stream consumption.

The explanation in the PR body and the inline comments are excellent — they clearly describe the mechanism (ALS store mutation vs. ALS scope unwinding), the symptom, and why the HTML branches are unaffected.

Test quality

The test fixture is well-designed:

  • layout.tsx — async layout that calls headers() during stream consumption (reproduces the NextIntlClientProviderServer pattern)
  • page.tsx — calls notFound() to trigger renderHTTPAccessFallbackPage (the buggy code path)
  • not-found.tsx — simple component to verify the correct code path was taken
  • Tests cover both the HTML baseline (confirms the fixture works) and the RSC regression path with explicit positive and negative assertions

Same bug exists elsewhere in this file

Per AGENTS.md: "When fixing a bug in any of these files, check whether the same bug exists in the others. Do not leave known bugs as 'follow-ups' — fix them in the same PR."

I checked prod-server.ts and app-router-entry.ts — they delegate to the built RSC entry and don't manage context directly, so they're clean. However, there are two more instances of the same bug pattern in app-dev-server.ts itself that this PR doesn't fix:

1. Intercepting route RSC branch (lines 2154-2159):

const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError });
setHeadersContext(null);       // ← same bug
setNavigationContext(null);    // ← same bug
return new Response(interceptStream, {
  headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
});

This is the same pattern: renderToReadableStream() → immediate context clear → return unconsumed stream. If an intercepting route has an async layout that calls headers(), it will hit the exact same bug.

2. Server action RSC re-render branch (lines 1828-1837):

const rscStream = renderToReadableStream(
  { root: element, returnValue },
  { temporaryReferences, onError: onRenderError },
);
const actionPendingCookies = getAndClearPendingCookies();
const actionDraftCookie = getDraftModeCookieHeader();
setHeadersContext(null);       // ← same bug
setNavigationContext(null);    // ← same bug

After a server action, the page tree is re-rendered to an RSC stream that's returned to the client. Context is cleared before the stream is consumed. If any async component in the re-render calls headers(), it will see null context.

Both of these should be fixed in this PR to avoid leaving known bugs as follow-ups. The fix is the same: remove the setHeadersContext(null) / setNavigationContext(null) calls from these RSC response branches.

Minor: the getAndClearPendingCookies() + getDraftModeCookieHeader() calls in the server action branch

Note that getAndClearPendingCookies() and getDraftModeCookieHeader() on lines 1834-1835 read from the headers context synchronously and are called before the stream is consumed. This is fine — they extract data that was set during action.apply(), not during stream rendering. So those calls should stay; only the two context-clear calls below them need to be removed.

Summary

The core fix is sound and the test coverage for the renderHTTPAccessFallbackPage path is good. The two additional instances of the same bug (intercepting routes and server action re-renders) should be fixed in this PR.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 8, 2026

I've posted a review on PR #352 requesting changes. Here's the summary:

The core fix is correct — removing early setNavigationContext(null) / setHeadersContext(null) from the RSC branches of renderHTTPAccessFallbackPage and renderErrorBoundaryPage is the right approach. The AsyncLocalStorage scope from runWithHeadersContext() handles cleanup naturally. The test is well-designed and covers both the HTML baseline and RSC regression path.

However, there are two more instances of the same bug in app-dev-server.ts that should be fixed in this PR (per AGENTS.md's rule about not leaving known bugs as follow-ups):

  1. Intercepting route RSC branch (lines 2154-2159) — clears context immediately after renderToReadableStream() before the intercepting route's stream is consumed.

  2. Server action RSC re-render branch (lines 1836-1837) — clears context after renderToReadableStream() for the post-action page re-render before the stream is consumed.

I also noted that prod-server.ts and app-router-entry.ts are clean — they delegate all context management to the built RSC entry.

github run

…nd intercepting route RSC branches

Same bug as fixed for renderHTTPAccessFallbackPage/renderErrorBoundaryPage: context
was cleared immediately after renderToReadableStream() returned, before the stream
was lazily consumed by the client. Async server components running during stream
consumption would see null context and fail.

Two additional instances fixed:
- Server action RSC re-render branch (after action.apply())
- Intercepting route RSC response branch

The getAndClearPendingCookies() / getDraftModeCookieHeader() calls in the server action
branch are intentionally preserved — they read synchronously from context set during
action execution (not during stream rendering) and must run before the response is sent.

Context cleanup is handled naturally by the AsyncLocalStorage scope from
runWithHeadersContext() unwinding after all async continuations complete.
@james-elicx
Copy link
Copy Markdown
Collaborator Author

/bigbonk re-review this pr please

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Re-review

All feedback from the first review has been addressed. The second commit (af053d6) fixes the two additional instances that were flagged:

  1. Server action RSC re-render branch (line ~1833) — setHeadersContext(null) / setNavigationContext(null) removed. The getAndClearPendingCookies() / getDraftModeCookieHeader() calls correctly remain since they read synchronously-set data from the action execution, not from stream consumption.

  2. Intercepting route RSC branch (line ~2157) — context clears removed.

Both fixes include the same clear explanatory comment as the original two sites.

Remaining setHeadersContext(null) calls are safe

I audited all ~50 remaining setHeadersContext(null) / setNavigationContext(null) calls in app-dev-server.ts. They all fall into safe categories:

  • HTML branches (lines 596, 716) — await handleSsr(rscStream, ...) fully consumes the stream before clearing
  • Redirect responses (lines 2185, 2216, 2264) — no unconsumed stream exists
  • Fallback text responses (lines 2193, 2224) — renderHTTPAccessFallbackPage returned null, so a plain text response is sent with no stream

No additional instances of the bug remain.

CI

All 26 checks pass — Lint, Typecheck, Vitest, all 7 Playwright E2E projects, all 7 deploy previews, smoke tests, CodeQL.

LGTM.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 8, 2026

PR #352 re-reviewed and approved. The author addressed both issues from the first review:

  • Server action RSC re-render branch — premature context clears removed, cookie collection calls correctly kept
  • Intercepting route RSC branch — premature context clears removed

I audited all ~50 remaining setHeadersContext(null) / setNavigationContext(null) calls in app-dev-server.ts and confirmed they're all on safe paths (HTML branches that await full SSR, redirect responses, or fallback text responses with no unconsumed stream). All 26 CI checks pass.

github run

@james-elicx james-elicx merged commit f1e98b9 into main Mar 8, 2026
25 checks passed
@james-elicx james-elicx deleted the j-branch-8 branch March 8, 2026 16:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant