From 9da1aac3ad4cc5f1ffe3d36c25a9faa06ece101c Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 8 Jun 2026 11:33:43 -0700 Subject: [PATCH] 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. --- packages/shared/src/sessions.ts | 23 +++++++++++++++++++ .../apps/implementations/client-app-impl.ts | 12 ++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/sessions.ts b/packages/shared/src/sessions.ts index e89e08776..788fc1756 100644 --- a/packages/shared/src/sessions.ts +++ b/packages/shared/src/sessions.ts @@ -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. * diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts index 49fd65643..90e7011db 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts @@ -1547,10 +1547,14 @@ export class _HexclaveClientAppImplIncomplete {}); + // 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 {