Skip to content

Conversation

@jere-co
Copy link

@jere-co jere-co commented Nov 28, 2025

The visitAsyncNode function used null as both an "in progress" marker and a valid cached result. When a node was revisited during its own traversal (circular reference), the function returned null as if it were a cached result, which could lead to infinite recursion.

This bug was introduced in #35005 when visited was changed from a Set to a Map for caching return values.

This change:

  • Introduces an IN_PROGRESS sentinel Symbol to distinguish nodes currently being evaluated from cached results
  • Returns null on cycle detection to indicate "no I/O found on this cyclic path" (not undefined, which would signal abort semantics and skip emitting I/O info for other non-cyclic branches)
  • Always caches the computed result after visitAsyncNodeImpl returns, including null/undefined values, so revisits get the real computed value instead of the sentinel

This fixes RangeError: Maximum call stack size exceeded that occurs in Next.js 15.5.0+ when using database clients like Gel/EdgeDB that create circular promise chains in their async sequences.

How did you test this change?

Validated manually in Next.js apps (15.5, 16.0.5, 16.1.0-canary) using the Gel/EdgeDB repro that previously hit the stack overflow. No React unit test added; the internal module system made mocking getAsyncSequenceFromPromise impractical from the test suite. Fix verified via integration runs only.

Reproduction Repository

https://github.com/jere-co/next-debug

This repository contains:

  • A minimal reproduction case triggering the bug
  • Step-by-step instructions to reproduce and verify the fix
  • Root cause analysis in /docs
  • A temporary patch script for affected projects

The visitAsyncNode function used null as both an "in progress" marker
and a valid cached result. When a node was revisited during its own
traversal (circular reference), the function returned null as if it were
a cached result, which could lead to infinite recursion.

This change:
- Introduces an IN_PROGRESS sentinel Symbol to distinguish nodes
  currently being evaluated from cached results
- Returns null on cycle detection to indicate "no I/O found on this
  cyclic path" (not undefined, which would signal abort semantics and
  skip emitting I/O info for other non-cyclic branches)
- Always caches the computed result after visitAsyncNodeImpl returns,
  including null/undefined values

This fixes RangeError: Maximum call stack size exceeded that occurs
in Next.js 15.5.0+ when using database clients like Gel/EdgeDB that
create circular promise chains in their async sequences.
@meta-cla
Copy link

meta-cla bot commented Nov 28, 2025

Hi @jere-co!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@meta-cla
Copy link

meta-cla bot commented Nov 28, 2025

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@meta-cla meta-cla bot added the CLA Signed label Nov 28, 2025
@MartinCura
Copy link

@jere-co, you savior, i hadn't seen the patch in the repo, seems to work perfectly!

This allows me to use a Next.js version without the infamous CVE, otherwise i had to stay on a vulnerable version (bug appears since v16.0.2-canary.2 exactly in my case, isn't present in the previous canary).

I'm sure Seb is mighty busy so i woudn't tag him for a few more days at least, but without your patch i was lost.

@MartinCura
Copy link

Well maybe now we could bother them with an @? 😅

@react-sizebot
Copy link

Comparing: 6a0ab4d...2224817

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.84 kB 6.84 kB +0.11% 1.88 kB 1.88 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 608.67 kB 608.67 kB = 107.63 kB 107.63 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.84 kB 6.84 kB +0.11% 1.88 kB 1.88 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 674.60 kB 674.60 kB = 118.57 kB 118.58 kB
facebook-www/ReactDOM-prod.classic.js = 694.04 kB 694.04 kB = 122.00 kB 122.01 kB
facebook-www/ReactDOM-prod.modern.js = 684.43 kB 684.43 kB = 120.40 kB 120.40 kB

Significant size changes

Includes any change greater than 0.2%:

(No significant changes)

Generated by 🚫 dangerJS against 2224817

@eps1lon
Copy link
Collaborator

eps1lon commented Jan 22, 2026

@jere-co There are some type issues that need to be resolved.

// Return null to indicate no I/O was found on this cyclic path. We don't return
// undefined here because that signals "abort" semantics which would skip emitting
// I/O info for other non-cyclic branches of this node.
return null;
Copy link
Collaborator

Choose a reason for hiding this comment

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

In the PR description you write:

Returns null on cycle detection to indicate "no I/O found on this cyclic path" (not undefined, which would signal abort semantics and skip emitting I/O info for other non-cyclic branches)

However, this doesn't actually fix the reported issue. I've synced your fix to the repro app and it still runs into a stack overflow. The fix just appears to work because it's not applied correctly here:

https://github.com/jere-co/next-debug/blob/25c992b4e021765343067a30d8b70fa20a337448/patch-visitAsyncNode.cjs#L128

That patch returns undefined instead of null. If we did that, we'd drop I/O info for valid cases, which you can see with the failing unit tests that this change would trigger.

Also, I think the IN_PROGRESS sentinel as implemented here doesn't actually change the cycle detection behavior. The original code already returns null for a cycle (via visited.get(node) returning the null that was set before recursing). The sentinel would only matter if we wanted to return something different on cycle detection.

It seems like the DB library is producing very long linear chains of async nodes via previous pointers, and the stack overflow comes from the recursive traversal of these chains, not from undetected cycles. A proper fix might require converting the previous chain traversal from recursive to iterative, though this gets complicated because the traversal also needs to handle awaited branches and return values need to propagate correctly.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Something like this maybe: #35612

unstubbable added a commit to unstubbable/react that referenced this pull request Jan 23, 2026
unstubbable added a commit to unstubbable/react that referenced this pull request Jan 23, 2026
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.

5 participants