Skip to content

Bug: resolveLazy catching promises causes race condition with short-lived Suspense-wrapped componentsΒ #35399

@TomLaVachette

Description

@TomLaVachette

Steps To Reproduce

  1. Create a React Server Component with a short async delay (~5ms) wrapped in Suspense
  2. Trigger a revalidation (e.g., via revalidatePath() in Next.js)
  3. The server sends correct updated data, but the client UI does not update

Link to code example: vercel/next.js#87529

The current behavior

After calling revalidatePath(), the browser receives the correct Flight data with the updated counter value, but the UI remains stuck showing the old value. This only happens:

  • In production mode
  • With short async delays (~5ms)
  • When the component is wrapped in Suspense
next-issue.mov

The issue was introduced in commit that upgraded React from eaee5308-20250728 to 9be531cd-20250729.

Root cause

The new resolveLazy function in ReactChildFiber.js catches promises and throws SuspenseException:

export function resolveLazy<T>(lazyType: LazyComponentType<T, any>): T {
  try {
    if (__DEV__) {
      return callLazyInitInDEV(lazyType);
    }
    const payload = lazyType._payload;
    const init = lazyType._init;
    return init(payload);
  } catch (x) {
    if (x !== null && typeof x === 'object' && typeof x.then === 'function') {
      // This lazy Suspended. Treat this as if we called use() to unwrap it.
      suspendedThenable = x;
      if (__DEV__) {
        needsToResetSuspendedThenableDEV = true;
      }
      throw SuspenseException;
    }
    throw x;
  }
}

When a lazy component's init() throws a short-lived promise (~5ms), this creates a race condition:

  1. resolveLazy catches the promise and stores it in suspendedThenable
  2. It throws SuspenseException to signal suspension
  3. The promise resolves before React has finished setting up its subscription
  4. React remains suspended, waiting for a signal that already passed
  5. The UI never updates

Previous working version

function resolveLazy(lazyType: any) {
  if (__DEV__) {
    return callLazyInitInDEV(lazyType);
  }
  const payload = lazyType._payload;
  const init = lazyType._init;
  return init(payload);
}

Without the try-catch, promises propagate naturally through React's existing Suspense machinery, which handles them correctly.

The expected behavior

After revalidatePath() is called:

  1. The server sends updated Flight data
  2. React reconciles the new data
  3. The UI updates to show the new counter value

Metadata

Metadata

Assignees

No one assigned

    Labels

    Status: UnconfirmedA potential issue that we haven't yet confirmed as a bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions