fix: defer initial token refresh until first subscriber - #467
Merged
Conversation
The token store is constructed at module-evaluation time, before hydration. When the fast cookie held an already-expiring token, the constructor scheduled a 0ms refresh timer that could fire before Next.js initialized its router action queue. The Server Action then threw inside React 19's startTransition, which swallows the error without settling the action promise — wedging refreshPromise for the rest of the page lifetime and permanently disabling token refresh on that tab. Subscribers attach from effects, which run after hydration, so scheduling on first subscribe guarantees the router can accept the dispatch. Nothing is lost for expired tokens: useAccessToken's mount effect independently refreshes them on load. Refs #463
Greptile SummaryThis PR defers initial token-refresh scheduling until the store gains its first subscriber.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; the zero-to-one subscriber transition restores the timer after remount and resolves the previously reported lifecycle issue. Important Files Changed
|
…bscribe The one-time initialRefreshPending flag was consumed by the first subscriber, so once the last unsubscribe cleared the timer, a later subscriber could never restore background refresh for the cached token. Schedule instead on every zero-to-one subscriber transition; subscribers attach from effects, so this still never fires before hydration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nicknisi
approved these changes
Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
TokenStoresingleton is constructed at module-evaluation time, before hydration. When the middleware-set fast cookie held an already-expiring token, the constructor scheduled a 0ms refresh timer that could fire before Next.js initialized its router action queue.Internal Next.js error: Router action dispatched before initialization(E668) inside React 19'sstartTransition, which routes the error toreportGlobalErrorwithout settling the action promise._refreshToken'scatch/finallynever run, sorefreshPromisestays pending for the rest of the page lifetime — every subsequentgetAccessToken/getAccessTokenSilently/refreshcall returns the same hung promise and token refresh on that tab is permanently dead until reload.scheduleRefreshto the firstsubscribe()call. Subscribers attach viauseSyncExternalStorefrom effects, which run after hydration, so the router action queue is guaranteed to be initialized before any timer can dispatch a Server Action.useAccessToken's mount effect already callsgetAccessTokenSilently()post-hydration, which refreshes an expiring token immediately. Background refresh with zero subscribers was already unsupported (unsubscribing the last listener clears the timer), so deferring to first-subscribe matches the store's existing lifecycle.Closes #463