Skip to content

Fix race condition in bearer-rejection retry that caused "link chain completed without emitting a value"#11754

Merged
nbudin merged 1 commit into
mainfrom
fix-bearer-rejection-complete-race
Jun 21, 2026
Merged

Fix race condition in bearer-rejection retry that caused "link chain completed without emitting a value"#11754
nbudin merged 1 commit into
mainfrom
fix-bearer-rejection-complete-race

Conversation

@nbudin

@nbudin nbudin commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Purpose

Apollo was throwing "The link chain completed without emitting a value" when loading pages in background tabs (or whenever the server sends X-Bearer-Token-Rejected: true on a response).

The root cause was a race condition in buildRefreshOnRejectedBearerLink. BatchHttpLink calls observer.next(result) and then immediately calls observer.complete() synchronously in the same Promise .then() callback. When the next handler detected a bearer rejection, it kicked off an async token refresh and returned — without ever calling observer.next() on the outer observable. But the synchronous complete: () => observer.complete() handler fired before the retry had a chance to emit anything. Apollo Client 4's validateDidEmitValue guard sees this and throws.

The intended defense (calling activeSubscription.unsubscribe() at the top of runOnce()) only ran after ensureFreshAccessToken() resolved asynchronously — too late.

Changes

💻 Engineer-facing

  • In the bearer-rejection next handler, call activeSubscription.unsubscribe() synchronously the moment rejection is detected. This marks the rxjs Subscriber as isStopped before BatchHttpLink's .complete() runs, making that call a no-op. The retry proceeds normally and emits a value through a fresh subscription.

Background tabs were the most common trigger because bearer rejection is more likely when a token is near expiry — tabs that have been sitting open for a while naturally hit this more often.

Release plan and notes

🚢

🤖 Generated with Claude Code

…k chain completed without emitting a value"

When the server sends X-Bearer-Token-Rejected, BatchHttpLink calls
observer.next(result) and then observer.complete() synchronously in the
same Promise .then(). The next handler detected the rejection and kicked
off an async token refresh without forwarding the value — but the
synchronous complete: () => observer.complete() fired before the retry
could emit, triggering Apollo Client 4's validateDidEmitValue guard.

Fix: call activeSubscription.unsubscribe() synchronously inside the next
handler the moment bearer rejection is detected. This marks the rxjs
Subscriber as isStopped before BatchHttpLink's .complete() runs, so
that call becomes a no-op. The retry then proceeds normally and emits
a value through a fresh subscription.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Code Coverage Report: Only Changed Files listed

Package Base Coverage New Coverage Difference
app/javascript/useIntercodeApolloClient.ts 🔴 14.29% 🔴 13.89% 🔴 -0.4%
Overall Coverage 🟢 54.21% 🟢 54.21% ⚪ 0%

Minimum allowed coverage is 0%, this run produced 54.21%

@nbudin nbudin added bug frontend patch Bumps the patch version number on release labels Jun 21, 2026
@nbudin nbudin marked this pull request as ready for review June 21, 2026 19:53
@nbudin nbudin merged commit 5763f27 into main Jun 21, 2026
24 checks passed
@nbudin nbudin deleted the fix-bearer-rejection-complete-race branch June 21, 2026 19:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug frontend patch Bumps the patch version number on release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant