fix(react): deduplicate concurrent fetchAccessToken calls#267
fix(react): deduplicate concurrent fetchAccessToken calls#267erquhart merged 1 commit intoget-convex:mainfrom
Conversation
|
@ramonclaudio is attempting to deploy a commit to the Convex Team on Vercel. A member of the Team first needs to authorize it. |
📝 WalkthroughWalkthroughModified Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/react/index.tsx`:
- Around line 133-135: pendingTokenRef can hold an in-flight promise across
session changes causing a stale token to be returned; inside
useAuthFromBetterAuth (where sessionId is in scope) add a useEffect that runs
when sessionId changes and clears pendingTokenRef.current (set to
undefined/null) so that the recreated fetchAccessToken does not return a promise
from the previous session; ensure you reference pendingTokenRef and do not alter
fetchAccessToken signature, and keep setCachedToken behavior unchanged except
that the cleared ref prevents old promises from re-setting a revoked token.
- Around line 147-149: The finally handler unconditionally clears
pendingTokenRef.current, which breaks deduplication when a later force-refresh
overwrites the ref; fix by capturing the promise into a local variable (e.g.,
const myPromise = <the promise assigned to pendingTokenRef.current>) and in the
.finally() only set pendingTokenRef.current = null if pendingTokenRef.current
=== myPromise so you don’t clear a newer in-flight promise (apply this change
where pendingTokenRef.current is assigned and the .finally() is attached, and
keep behavior for forceRefreshToken unchanged).
| if (!forceRefreshToken && pendingTokenRef.current) { | ||
| return pendingTokenRef.current; | ||
| } |
There was a problem hiding this comment.
pendingTokenRef is not reset when sessionId changes, allowing a stale session's in-flight promise to be returned post-sign-out.
fetchAccessToken is recreated when sessionId changes (via useCallback([sessionId])), but pendingTokenRef persists uncleared. If a fetch is in-flight at the moment the user signs out and sessionId becomes undefined/null, the new fetchAccessToken will return the old session's pending promise to the post-sign-out caller. That promise resolves with the old session's token and also calls setCachedToken(oldToken), momentarily re-setting a revoked token in state.
Add a useEffect inside useAuthFromBetterAuth (where sessionId is already available) to clear the ref on session change:
🛡️ Proposed fix — clear pending ref on session change
const sessionId = session?.session?.id;
+useEffect(() => {
+ pendingTokenRef.current = null;
+}, [sessionId]);
useEffect(() => {
if (!session && !isSessionPending && cachedToken) {
setCachedToken(null);
}
}, [session, isSessionPending]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/react/index.tsx` around lines 133 - 135, pendingTokenRef can hold an
in-flight promise across session changes causing a stale token to be returned;
inside useAuthFromBetterAuth (where sessionId is in scope) add a useEffect that
runs when sessionId changes and clears pendingTokenRef.current (set to
undefined/null) so that the recreated fetchAccessToken does not return a promise
from the previous session; ensure you reference pendingTokenRef and do not alter
fetchAccessToken signature, and keep setCachedToken behavior unchanged except
that the cleared ref prevents old promises from re-setting a revoked token.
There was a problem hiding this comment.
Convex's AuthenticationManager handles this. When sessionId changes, a new fetchAccessToken is created (via useCallback([sessionId])), which triggers ConvexAuthStateFirstEffect to call client.setAuth() again. That increments configVersion inside fetchTokenAndGuardAgainstRace, so even if the new fetchAccessToken returns the old in-flight promise, the result is discarded as isFromOutdatedConfig: true. The stale token never reaches the WebSocket.
Adding a useEffect to clear the ref would be redundant with Convex's existing race guard.
There was a problem hiding this comment.
@ramonclaudio, thank you for the detailed explanation! You're right—if Convex's AuthenticationManager already discards results from outdated configs via the configVersion and isFromOutdatedConfig guard, then clearing pendingTokenRef on session change would indeed be redundant. The race condition is already handled at the framework level.
I appreciate you taking the time to clarify the architecture. I've noted this for future reference.
✏️ Learnings added
Learnt from: ramonclaudio
Repo: get-convex/better-auth PR: 267
File: src/react/index.tsx:133-135
Timestamp: 2026-02-19T18:30:07.688Z
Learning: In `src/react/index.tsx` with Convex and Better Auth integration, `pendingTokenRef` does not need to be cleared when `sessionId` changes because Convex's AuthenticationManager handles stale token race conditions through a configVersion mechanism. When sessionId changes, a new fetchAccessToken is created (via useCallback([sessionId])), triggering ConvexAuthStateFirstEffect to call client.setAuth() and increment configVersion inside fetchTokenAndGuardAgainstRace. Even if an old in-flight promise is returned, the result is discarded as isFromOutdatedConfig: true, preventing stale tokens from reaching the WebSocket.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
| .finally(() => { | ||
| pendingTokenRef.current = null; | ||
| }); |
There was a problem hiding this comment.
.finally() unconditionally clears the ref — breaks deduplication when a force-refresh overwrites it.
If forceRefreshToken: true fires while a regular fetch is already in-flight, pendingTokenRef.current is overwritten with the new force-refresh promise. When the original regular fetch settles, its .finally() blindly sets pendingTokenRef.current = null, clearing a ref that now belongs to the force-refresh promise. Any caller arriving after that .finally() but before the force-refresh promise resolves will see an empty ref and fire a redundant third request.
Fix: capture the promise in a local variable and only clear the ref if it still points to this promise:
🐛 Proposed fix — identity-guarded `.finally()`
- pendingTokenRef.current = authClient.convex
- .token({ fetchOptions: { throw: false } })
- .then(({ data }) => {
- const token = data?.token || null;
- setCachedToken(token);
- return token;
- })
- .catch(() => {
- setCachedToken(null);
- return null;
- })
- .finally(() => {
- pendingTokenRef.current = null;
- });
- return pendingTokenRef.current;
+ const pending = authClient.convex
+ .token({ fetchOptions: { throw: false } })
+ .then(({ data }) => {
+ const token = data?.token || null;
+ setCachedToken(token);
+ return token;
+ })
+ .catch(() => {
+ setCachedToken(null);
+ return null;
+ })
+ .finally(() => {
+ if (pendingTokenRef.current === pending) {
+ pendingTokenRef.current = null;
+ }
+ });
+ pendingTokenRef.current = pending;
+ return pending;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/react/index.tsx` around lines 147 - 149, The finally handler
unconditionally clears pendingTokenRef.current, which breaks deduplication when
a later force-refresh overwrites the ref; fix by capturing the promise into a
local variable (e.g., const myPromise = <the promise assigned to
pendingTokenRef.current>) and in the .finally() only set pendingTokenRef.current
= null if pendingTokenRef.current === myPromise so you don’t clear a newer
in-flight promise (apply this change where pendingTokenRef.current is assigned
and the .finally() is attached, and keep behavior for forceRefreshToken
unchanged).
There was a problem hiding this comment.
The .finally() race requires forceRefreshToken: true to fire while a non-force fetch is still in-flight. Looking at AuthenticationManager, forceRefreshToken: true fires via refetchToken() after the server confirms the initial token (onTransition in the waitingForServerConfirmationOfCachedToken state), or via tryToReauthenticate on auth errors, or via the scheduled refresh (~15 min). In all three paths, the initial forceRefreshToken: false fetch has already resolved before the force-refresh fires -- they're sequential, not concurrent.
Even in the theoretical worst case, the .finally() clears the ref and one extra request fires on the next call. Adding a local binding + equality check to guard against it would complicate the diff for no practical gain.
There was a problem hiding this comment.
@ramonclaudio, that's a fair analysis. If the AuthenticationManager's call patterns ensure forceRefreshToken: true fires sequentially after initial fetches resolve, the race window doesn't exist in practice. And even if it did theoretically occur, the cost of one extra request is negligible compared to the added complexity.
Your engineering judgment on this tradeoff makes sense — thanks for walking through the code paths!
ramonclaudio
left a comment
There was a problem hiding this comment.
Addressed both points inline. The configVersion guard in AuthenticationManager already handles the stale session case, and the .finally() race requires concurrent force/non-force calls that can't happen in practice (force-refresh only fires after the initial fetch resolves). Happy to add the guards if you disagree, but I figured I'd keep the diff minimal.
| if (!forceRefreshToken && pendingTokenRef.current) { | ||
| return pendingTokenRef.current; | ||
| } |
There was a problem hiding this comment.
Convex's AuthenticationManager handles this. When sessionId changes, a new fetchAccessToken is created (via useCallback([sessionId])), which triggers ConvexAuthStateFirstEffect to call client.setAuth() again. That increments configVersion inside fetchTokenAndGuardAgainstRace, so even if the new fetchAccessToken returns the old in-flight promise, the result is discarded as isFromOutdatedConfig: true. The stale token never reaches the WebSocket.
Adding a useEffect to clear the ref would be redundant with Convex's existing race guard.
| .finally(() => { | ||
| pendingTokenRef.current = null; | ||
| }); |
There was a problem hiding this comment.
The .finally() race requires forceRefreshToken: true to fire while a non-force fetch is still in-flight. Looking at AuthenticationManager, forceRefreshToken: true fires via refetchToken() after the server confirms the initial token (onTransition in the waitingForServerConfirmationOfCachedToken state), or via tryToReauthenticate on auth errors, or via the scheduled refresh (~15 min). In all three paths, the initial forceRefreshToken: false fetch has already resolved before the force-refresh fires -- they're sequential, not concurrent.
Even in the theoretical worst case, the .finally() clears the ref and one extra request fires on the next call. Adding a local binding + equality check to guard against it would complicate the diff for no practical gain.
erquhart
left a comment
There was a problem hiding this comment.
Good improvement, thanks for this
commit: |
|
NP! Thanks @erquhart |
When
cachedTokenis null (initial load, post-signout), concurrent calls tofetchAccessTokeneach fire a separateauthClient.convex.token()HTTP request before the first resolves. On page load,sessionIdtransitions fromundefinedto a value, creating a newfetchAccessTokenreference that triggersConvexProviderWithAuthto callsetAuth()again while the first request is still in-flight. React 18 StrictMode doubles this in dev.Each
/convex/tokencall runs session middleware (DB hit) + JWT signing. N concurrent calls = N redundant round-trips, only one result used.A
useRefstores the in-flight promise so concurrent callers share it instead of firing duplicates:forceRefreshToken: truebypasses the guard and overwrites with a fresh request..finally()clears the ref after resolution so the next call starts fresh.Changes
Single file,
src/react/index.tsx:useRefto React imports (+1 token)pendingTokenRefdeclaration aftercachedTokenuseStateTest plan
rm -rf dist/ && npm run build,npm run typecheck,npm run testall pass. 55 tests passed, 0 type errors. Rebased on upstream/main.Closes #219. Likely reduces the excessive action count reported in #186.
Summary by CodeRabbit