Skip to content

Authenticated App Router browser SSR calls legacy react-dom/server API (E394 InvariantError) — full-HTML render resolves streaming export to the poison stub #6710

Closed

Description

@proggeramlug

Summary

A logged-in user loading an authenticated App Router page in a real browser (full HTML SSR) crashes under Perry with Next.js E394 — Internal Error: do not use legacy react-dom/server APIs — repeated as unhandledRejection: InvariantError, and the response stream never cleanly closes (browser spins "loading forever"; curl reports 500). Node renders the identical request 200.

E394 is a Next.js poison-pill stub: function a(){ throw Error("Internal Error: do not use legacy react-dom/server APIs...") } assigned to the legacy react-dom/server exports (renderToString, renderToStaticMarkup, renderToNodeStream, …). App Router must only call the streaming APIs. So under Perry, a react-dom/server module export is resolving to the throwing legacy stub instead of the real streaming function during the browser SSR of an authenticated page.

Reproduction (gscmaster, Next 16.1.1 standalone)

request Perry Node
GET /de/dashboard with Accept: text/html + browser UA, authenticated 500 + E394/InvariantError flood 200
GET /de/dashboard plain curl (no Accept/UA), authenticated 200 (byte-identical to node) 200
GET /de/dashboard with RSC: 1 (client nav), authenticated 200 200
public pages with browser UA 200 200

So it is specific to the full-HTML SSR path of an authenticated, dynamic = "force-dynamic" page — exactly what a browser does on the post-login redirect. Plain curl and the RSC-only path take a different render route and succeed, which is why a 220-request curl soak passed while a real browser login hangs.

Likely in the react-dom/server (Fizz) HTML-shell render, possibly interacting with the PPR/postpone-resume path (the bundle also carries Invariant: isDynamicPostpone misidentified a postpone reason), cf. the module-export "value form ≠ static form" resolution family (#6667/#6668) and PPR resume #5437.

Next step

Wrap the bundle's E394 stub a() to log a stack when called → identify exactly which react-dom/server export is mis-resolved to the stub, then fix Perry's resolution of that export (probable: the streaming API being read as a value/namespace member and getting the legacy alias).

Observed on main @ 3fd379f, macOS arm64. Workaround unknown; the RSC/plain-fetch path is unaffected.

Activity

  1. proggeramlug commented on Jul 20, 2026

    @proggeramlug
    ContributorAuthor

    Confirmed: opt-miscompile family (#6546/#6682); -O0 renders correctly

    Same source/runtime, only clang opt level differs:

    build authenticated /de/dashboard, browser HTML render (Accept: text/html + UA)
    default opt 500 + E394 (InvariantError, legacy react-dom/server stub called); stream never closes → browser hangs
    PERRY_LL_O0_THRESHOLD_BYTES=1 (all modules -Os/-O0) 200, stable ×3, zero E394, correct 32 KB page

    Also a heisenbug: instrumenting the react-dom-server alias (wrapping the E394 stub + logging t.renderToReadableStream===a) makes the failure disappear (dashboard 200) — the added module strings shift codegen/interning, so the "exports look correct" reading can't be trusted for the failing build. Same signature as #6682 (React Flight symbol serialization), which was in this same family and got fixed forward on main by unrelated symbol/codegen fixes.

    So E394 is not a react-dom/server export-resolution bug per se — it's a downstream default-opt (-O3) miscompile in the authenticated full-HTML SSR path that causes the legacy react-dom/server stub to be reached (or a value to mis-resolve to it), layout-dependent. -O0 is the reliable workaround.

    Bracketing which sub-6MB -O3 module miscompiles would follow the same threshold-bisect plan as #6682 — but given #6682 self-resolved on main within days via forward fixes, worth re-baselining default-opt on latest main before an expensive bisect.

    Observed on main @ 3fd379f, macOS arm64. Workaround: PERRY_LL_O0_THRESHOLD_BYTES=1.

  2. proggeramlug commented on Jul 20, 2026

    @proggeramlug
    ContributorAuthor

    Severity upgrade: E394 poisons global render state → permanent server wedge (reproduces at -O0)

    Correcting the earlier "-O0 fixes it" — that was based on sequential single-request curls. Under a realistic concurrent browser session, E394 fires even at -O0, and once it fires it wedges the entire server permanently.

    Repro (O0 build, PERRY_LL_O0_THRESHOLD_BYTES=1, authenticated)

    3 rounds of, per round, concurrent:

    • 3× full-HTML renders (Accept: text/html + UA) of /de/dashboard, /de/settings, /de/dashboard/settings
    • 2× RSC prefetches (RSC: 1) of /de/dashboard, /de/settings
    • 2× client API calls /api/dashboard/{summary,insights}

    → E394 fires (×4), then:

    • GET /de (a public page) → 500, and stays 500 after 5s (does not recover)
    • The failing public /de emits the same InvariantError / E394
    • Only a process restart clears it (fresh server → /de 200)

    Mechanism

    Once E394 fires during one authenticated streaming render, a global/module-singleton render binding is left poisoned — every subsequent render (including public pages that never touch the dashboard) re-throws E394. The prime suspect is the shared react-dom/server alias export t.renderToReadableStream flipping to the poison stub a process-wide (the comma-sequence member-assignment t.renderToReadableStream=n.renderToReadableStream,t.renderToString=a,t.renderToStaticMarkup=a mis-resolving under the opt-miscompile / a re-entrant module init), after which all renders call the legacy stub.

    This turns a per-request opt-miscompile into a full-server-outage resilience bug: one bad render takes down the whole process until restart. That is the user-visible "one tab spins forever, all other tabs → Internal Server Error."

    Impact

    The app is not usable for authenticated browsing under Perry at any opt level — login succeeds, but the first real browser load of the authenticated dashboard wedges the server. -O0 is NOT a sufficient workaround.

    Observed on main @ 3fd379f, macOS arm64.

  3. proggeramlug commented on Jul 20, 2026

    @proggeramlug
    ContributorAuthor

    Key narrowing: the wedge is a CONCURRENCY (async-interleaving) bug, not a plain opt-miscompile

    Controlled test on the -O0 build (PERRY_LL_O0_THRESHOLD_BYTES=1), authenticated:

    • Strictly SEQUENTIAL — 50 back-to-back full-HTML /de/dashboard renders (one at a time): 50/50 → 200, zero E394, server stays healthy.
    • CONCURRENT / interleaved — parallel mixed authed requests (HTML renders + RSC prefetches + /api/dashboard/*): E394 fires, and intermittently the whole server wedges (persistent 500 on every route incl. public /de, until process restart).

    Since Perry is single-threaded, "concurrent" = the requests' async renders interleave through await/microtasks. So E394 is triggered by async interleaving, not by opt level per se (-O0 doesn't prevent it). The signature is a per-invocation value shared across interleaved async renders: one render's react-dom/server streaming binding (or a value feeding it) gets clobbered by another interleaved render → the legacy poison stub a is reached → E394; when the clobbered value lands in shared module state, every subsequent render throws → full-server wedge.

    This is the async-locals-shared-cell family (#6328/#6345): an async function's locals boxed into one shared cell so two concurrent activations corrupt each other. The prior fixes may be incomplete for the async shape the Next SSR/Flight render uses, or a different container (render context / a captured binding) has the same aliasing.

    Repro (reliable-ish; intermittent wedge)

    Log in, then 3 rounds of: concurrently GET (browser headers) /de/dashboard, /de/settings, /de/dashboard/settings; RSC:1 GET /de/dashboard, /de/settings; GET /api/dashboard/{summary,insights}. E394 fires within a round or two; the wedge appears within a few concurrent bursts.

    Next: debug-symbol -O0 build + lldb on the E394 throw under the concurrent repro to identify the exact shared slot/cell being clobbered across the interleaved renders.

    Observed on main @ 3fd379f, macOS arm64.

  4. proggeramlug commented on Jul 20, 2026

    @proggeramlug
    ContributorAuthor

    Verification: ca27b6fae (the async-step CURRENT_MICROTASK_PROMISE save/restore) does NOT fix the wedge

    Built the fix onto current main (3fd379fae + the 62-line microtasks.rs patch, -O0, auto-opt runtime rebuilt from the patched source — confirmed the fix is linked), and ran a head-to-head against the unfixed build with a now-reliable trigger.

    Reliable repro (authenticated mixed-session): log in, then per round concurrently: browser-header GET /de/dashboard /de/settings /de/dashboard/settings; RSC:1 GET /de/dashboard /de/settings; GET /api/dashboard/{summary,insights}. Wedge check = /de 500 persisting across 3× 30s retries (distinguishes a true wedge from transient overload-000).

    Result:

    • Unfixed build: wedged at round 10 (persistent 500, InvariantError/E394).
    • Fixed (ca27b6fae) build: wedged at round 7 — same persistent 500, same InvariantError/E394.

    So the fix is insufficient. This matches the subagent's own late doubt: in the re-entrant async cases, the InvariantError throw is caught by the async function's own try/catch, not the run_microtasks setjmp trap the patch guards — so the mechanism that poisons global render state is elsewhere (candidate: async-step next-promise crossing under re-entrancy, or a different shared-state corruption).

    ca27b6fae may still be a valid latent-correctness improvement (it mirrors the Task::Microtask arm's save/restore in the async arms), but it should NOT be treated as the #6710 fix. Reopening the root-cause hunt. Repro remains: authenticated mixed-session wedges within ~10 rounds; public-page-only concurrency is less reliable at moderate load.

    Observed on main @ 3fd379f, macOS arm64.

  5. added
    bugConfirmed defect or regression
    parityCompatibility gap with Node.js, ECMAScript, or the supported ecosystem
    triagedMaintainer reviewed; type, scope, and next step are clear
    on Jul 26, 2026
  6. proggeramlug commented on Jul 30, 2026

    @proggeramlug
    ContributorAuthor

    Fixed by merged PR #6735. The root cause was recycled HTTP handle IDs retaining per-request string/symbol/descriptor side-table state, which let an HTML request inherit NextInternalRequestMeta from an earlier RSC request. The fix clears request, response, HTTP/2 stream, and upgrade-handle side tables before JS observes them; the PR validated the mixed RSC/HTML concurrency repro without a wedge. Revalidated the six handle-expando cleanup tests on current main; all pass. Closing as fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regressionparityCompatibility gap with Node.js, ECMAScript, or the supported ecosystemtriagedMaintainer reviewed; type, scope, and next step are clear

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions