fix(rde): keep session identity stable across access-token refreshes

Access-token-only client sessions (no refresh token) were keyed by the
access-token string. The remote development environment dashboard re-mints
its short-lived access token every ~30-60s, so every refresh produced a
brand-new InternalSession object. Session-scoped caches (useUser/useConfig/
useTeams/...) are keyed by the session object, so each refresh cold-
invalidated them, suspended the tree, and blanked the dashboard.

Key access-only sessions by the token's stable refresh_token_id instead of
the raw token string, and add InternalSession.updateAccessToken so a freshly
minted token is pushed into the existing session in place rather than
constructing a new one.
This commit is contained in:
Bilal Godil 2026-06-08 11:33:43 -07:00
parent 96273a9d65
commit 9da1aac3ad
2 changed files with 31 additions and 4 deletions

View File

@ -124,6 +124,14 @@ export class InternalSession {
if (ofTokens.refreshToken) {
return `refresh-${ofTokens.refreshToken}`;
} else if (ofTokens.accessToken) {
// Access-only sessions (no refresh token) are keyed by the underlying session's `refresh_token_id`, not the
// access token string: access tokens get re-minted frequently, and keying by the raw token would spawn a new
// session (and cold-invalidate every session-scoped cache) on each refresh. Falls back to the raw token if
// the JWT can't be decoded.
const refreshTokenId = decodeAccessTokenIfValid(ofTokens.accessToken)?.refresh_token_id;
if (refreshTokenId) {
return `access-session-${refreshTokenId}`;
}
return `access-${ofTokens.accessToken}`;
} else {
return "not-logged-in";
@ -210,6 +218,21 @@ export class InternalSession {
return accessToken ? { accessToken, refreshToken: this._refreshToken } : null;
}
/**
* Installs a fresh access token into this session in place, keeping the session object (and therefore every
* session-scoped cache) stable instead of constructing a new InternalSession. Caller must pass a token that
* belongs to the same session. No-op if the session is invalid, the token can't be decoded, or it's unchanged;
* never clears an existing token.
*/
updateAccessToken(accessToken: string | null) {
if (this._knownToBeInvalid.get()) return;
if (!accessToken) return;
const newAccessToken = AccessToken.createIfValid(accessToken);
if (!newAccessToken) return;
if (this._accessToken.get()?.token === newAccessToken.token) return;
this._accessToken.set(newAccessToken);
}
/**
* Manually mark the access token as expired, even if the date on its payload may still be valid.
*

View File

@ -1547,10 +1547,14 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
const tokenStore = this._getOrCreateTokenStore(await this._createCookieHelper());
tokenStore.set(tokens);
// Pre-fetch the current user for the new session so the cache is already
// populated when useUser() re-renders, avoiding a stale-cache render cycle.
const newSession = this._getSessionFromTokenStore(tokenStore);
this._currentUserCache.getOrWait([newSession], "write-only").catch(() => {});
// If these tokens resolve to a session we already have (eg. the RDE dashboard re-installing a freshly minted
// access token for the same access-only session), push the new token into it in place; constructing a new
// session here would cold-invalidate every session-scoped cache and suspend the UI on each refresh.
const session = this._getSessionFromTokenStore(tokenStore);
session.updateAccessToken(tokens.accessToken);
// Pre-fetch the current user so the cache is warm when useUser() re-renders (write-only, so it never suspends).
this._currentUserCache.getOrWait([session], "write-only").catch(() => {});
}
protected _getTokenStoreInitForFreshTokens(tokens: { accessToken: string | null, refreshToken: string }): TokenStoreInit | undefined {