Skip to content

fix: defer initial token refresh until first subscriber - #467

Merged
gjtorikian merged 2 commits into
mainfrom
fix/defer-initial-token-refresh
Aug 3, 2026
Merged

fix: defer initial token refresh until first subscriber#467
gjtorikian merged 2 commits into
mainfrom
fix/defer-initial-token-refresh

Conversation

@gjtorikian

@gjtorikian gjtorikian commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • The client TokenStore singleton 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.
  • When that happens, the Server Action dispatch throws Internal Next.js error: Router action dispatched before initialization (E668) inside React 19's startTransition, which routes the error to reportGlobalError without settling the action promise. _refreshToken's catch/finally never run, so refreshPromise stays pending for the rest of the page lifetime — every subsequent getAccessToken/getAccessTokenSilently/refresh call returns the same hung promise and token refresh on that tab is permanently dead until reload.
  • Fix: the constructor now records the initial token but defers scheduleRefresh to the first subscribe() call. Subscribers attach via useSyncExternalStore from effects, which run after hydration, so the router action queue is guaranteed to be initialized before any timer can dispatch a Server Action.
  • Nothing is lost for the expired-token case: useAccessToken's mount effect already calls getAccessTokenSilently() 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

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
Comment thread src/components/tokenStore.ts Outdated
@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown

Greptile Summary

This PR defers initial token-refresh scheduling until the store gains its first subscriber.

  • Schedules refresh whenever the subscriber count transitions from zero to one.
  • Restores the refresh timer after all consumers unsubscribe and a later consumer mounts.
  • Adds constructor and subscription lifecycle tests for expiring and opaque tokens.

Confidence Score: 5/5

The 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

Filename Overview
src/components/tokenStore.ts Defers initial refresh scheduling to the first subscriber and correctly restores scheduling after a zero-to-one listener transition.
src/components/tokenStore.spec.ts Adds coverage for deferred scheduling, multiple subscribers, remount restoration, and opaque initial tokens.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[TokenStore constructed] --> B[Read initial token from cookie]
    B --> C[Do not schedule refresh]
    C --> D[First subscriber attaches after hydration]
    D --> E{Token is a parseable JWT?}
    E -- Yes --> F[Schedule refresh from expiry]
    E -- No --> G[No refresh timer]
    F --> H[Last subscriber detaches]
    H --> I[Clear refresh timer]
    I --> D
Loading

Reviews (2): Last reviewed commit: "fix: restore refresh timer when a subscr..." | Re-trigger Greptile

…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>
@gjtorikian
gjtorikian merged commit 587c5d7 into main Aug 3, 2026
5 checks passed
@gjtorikian
gjtorikian deleted the fix/defer-initial-token-refresh branch August 3, 2026 18:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Dev-only React warning: initial-mount silent token refresh (server action) races AppRouter mount in Next 16 / React 19

2 participants