From f25187da8f8d1b3c3f87e212c8b1e5cf571799ad Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Pinho Date: Tue, 4 Aug 2026 15:26:23 +1200 Subject: [PATCH] fix(realtime): ensure setAuth doesn't disable token refresh --- .../core/realtime-js/src/RealtimeClient.ts | 22 +++++++++++-------- .../test/unit/SupabaseClient.test.ts | 16 ++++++++++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/core/realtime-js/src/RealtimeClient.ts b/packages/core/realtime-js/src/RealtimeClient.ts index 3bc638d985..28ac5fbbc4 100755 --- a/packages/core/realtime-js/src/RealtimeClient.ts +++ b/packages/core/realtime-js/src/RealtimeClient.ts @@ -482,14 +482,18 @@ export default class RealtimeClient { * * On callback used, it will set the value of the token internal to the client. * - * When a token is explicitly provided, it will be preserved across channel operations - * (including removeChannel and resubscribe). The `accessToken` callback will not be - * invoked until `setAuth()` is called without arguments. + * When a token is explicitly provided AND no `accessToken` callback is configured, + * it will be preserved across channel operations (including removeChannel and + * resubscribe) and the client stays in manual-token mode. + * + * When an `accessToken` callback IS configured, the callback is the source of truth: + * the client remains in callback mode and continues to refresh from it on heartbeat, + * even after a bootstrap/override `setAuth(token)` call. * * @param token A JWT string to override the token set on the client. * * @example Setting the authorization header - * // Use a manual token (preserved across resubscribes, ignores accessToken callback) + * // Use a manual token (preserved across resubscribes when no accessToken callback is set) * client.realtime.setAuth('my-custom-jwt') * * // Switch back to using the accessToken callback @@ -625,12 +629,12 @@ export default class RealtimeClient { tokenToSend = this.accessTokenValue } - // Track whether this token was manually set or fetched via callback - if (isManualToken) { - this._manuallySetToken = true - } else if (this.accessToken) { - // If we used the callback, clear the manual flag + // Track whether this token was manually set or fetched via callback. + // The callback is the source of truth for token refresh + if (this.accessToken) { this._manuallySetToken = false + } else if (isManualToken) { + this._manuallySetToken = true } if (this.accessTokenValue != tokenToSend) { diff --git a/packages/core/supabase-js/test/unit/SupabaseClient.test.ts b/packages/core/supabase-js/test/unit/SupabaseClient.test.ts index 6be800cdd7..02e35ccf56 100644 --- a/packages/core/supabase-js/test/unit/SupabaseClient.test.ts +++ b/packages/core/supabase-js/test/unit/SupabaseClient.test.ts @@ -317,6 +317,22 @@ describe('SupabaseClient', () => { client.realtime.disconnect() }) + test('keeps Realtime in callback mode (auto-refresh enabled) after the accessToken bootstrap', async () => { + const customToken = 'custom-jwt-token' + const customAccessTokenFn = jest.fn().mockResolvedValue(customToken) + + const client = createClient(URL, KEY, { accessToken: customAccessTokenFn }) + + // Wait for the constructor's async setAuth bootstrap to complete. + await new Promise((resolve) => setTimeout(resolve, 0)) + + // Still in callback mode -> the accessToken callback will keep being + // invoked on heartbeat to refresh the token. + expect((client.realtime as any)._isManualToken()).toBe(false) + + client.realtime.disconnect() + }) + test('should automatically populate token in channels when using custom JWT', async () => { const customToken = 'custom-channel-token' const customAccessTokenFn = jest.fn().mockResolvedValue(customToken)