From b56cd7033758be214f23a1b13093eda90a6c36a3 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 19 May 2026 23:48:35 +0200 Subject: [PATCH 1/3] fix(channels): clear stale OAuth Connecting badges across auth modes (#2128) OAuth connection badges could stay pinned at `Connecting` indefinitely: - DiscordConfig had a per-component `oauth:success` effect but no error listener; TelegramConfig had neither, so completed and failed OAuth flows never transitioned its badge out of `connecting`. - Starting a second OAuth method on the same channel left the first method's pending state in place, so two methods could appear active at once (the reproducer from the issue). This change: - Adds a shared `useOAuthConnectionListener` hook that bridges the global `oauth:success` / `oauth:error` deep-link CustomEvents from desktopDeepLinkListener.ts into the channelConnections slice, filtered case-insensitively by channel. DiscordConfig and TelegramConfig now both subscribe via this hook; future channels inherit correct pending-state transitions for free. - Adds a `clearOtherPendingForChannel` reducer that cancels any sibling auth mode still mid-`connecting` (transitions it to `disconnected`, not `error`, so the UI doesn't surface a misleading failure) when a new connect flow starts in either panel. - Covers the new reducer (4 cases) and hook (8 cases) with Vitest. Co-Authored-By: Claude --- app/src/components/channels/DiscordConfig.tsx | 31 ++-- .../components/channels/TelegramConfig.tsx | 11 ++ .../useOAuthConnectionListener.test.tsx | 146 ++++++++++++++++++ app/src/hooks/useOAuthConnectionListener.ts | 112 ++++++++++++++ .../__tests__/channelConnectionsSlice.test.ts | 107 +++++++++++++ app/src/store/channelConnectionsSlice.ts | 29 ++++ 6 files changed, 415 insertions(+), 21 deletions(-) create mode 100644 app/src/hooks/__tests__/useOAuthConnectionListener.test.tsx create mode 100644 app/src/hooks/useOAuthConnectionListener.ts diff --git a/app/src/components/channels/DiscordConfig.tsx b/app/src/components/channels/DiscordConfig.tsx index 5c3459fd64..aac42c5724 100644 --- a/app/src/components/channels/DiscordConfig.tsx +++ b/app/src/components/channels/DiscordConfig.tsx @@ -1,11 +1,13 @@ import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { useOAuthConnectionListener } from '../../hooks/useOAuthConnectionListener'; import { AUTH_MODE_LABELS } from '../../lib/channels/definitions'; import { useT } from '../../lib/i18n/I18nContext'; import { channelConnectionsApi } from '../../services/api/channelConnectionsApi'; import { callCoreRpc } from '../../services/coreRpcClient'; import { + clearOtherPendingForChannel, disconnectChannelConnection, setChannelConnectionStatus, upsertChannelConnection, @@ -70,27 +72,11 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => { }; }, []); - useEffect(() => { - const handleOauthSuccess = (event: Event) => { - const customEvent = event as CustomEvent<{ toolkit?: string }>; - const toolkit = customEvent.detail?.toolkit?.toLowerCase(); - if (toolkit !== 'discord') return; - - log('discord oauth success deep link received'); - dispatch( - upsertChannelConnection({ - channel: 'discord', - authMode: 'oauth', - patch: { status: 'connected', lastError: undefined, capabilities: ['read', 'write'] }, - }) - ); - }; - - window.addEventListener('oauth:success', handleOauthSuccess); - return () => { - window.removeEventListener('oauth:success', handleOauthSuccess); - }; - }, [dispatch]); + // Centralised OAuth deep-link bridge — also handles `oauth:error` so failed + // sign-ins transition out of `connecting` instead of pinning the badge. See + // useOAuthConnectionListener.ts for the per-channel matching contract. Fixes + // the Discord half of #2128. + useOAuthConnectionListener({ channel: 'discord', authMode: 'oauth' }); const startLinkPolling = useCallback( (token: string) => { @@ -153,6 +139,9 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => { (spec: AuthModeSpec) => { const key = `discord:${spec.mode}`; void runBusy(key, async () => { + // Drop any sibling auth mode that's still mid-`connecting` so the + // panel doesn't show two methods pinned simultaneously (#2128). + dispatch(clearOtherPendingForChannel({ channel: 'discord', exceptAuthMode: spec.mode })); dispatch( setChannelConnectionStatus({ channel: 'discord', diff --git a/app/src/components/channels/TelegramConfig.tsx b/app/src/components/channels/TelegramConfig.tsx index 451c3b6318..ae6ade3113 100644 --- a/app/src/components/channels/TelegramConfig.tsx +++ b/app/src/components/channels/TelegramConfig.tsx @@ -1,11 +1,13 @@ import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { useOAuthConnectionListener } from '../../hooks/useOAuthConnectionListener'; import { AUTH_MODE_LABELS } from '../../lib/channels/definitions'; import { useT } from '../../lib/i18n/I18nContext'; import { channelConnectionsApi } from '../../services/api/channelConnectionsApi'; import { callCoreRpc } from '../../services/coreRpcClient'; import { + clearOtherPendingForChannel, disconnectChannelConnection, setChannelConnectionStatus, upsertChannelConnection, @@ -75,6 +77,12 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => { }; }, []); + // Bridge OAuth deep-link completions into Redux. Previously absent on the + // Telegram panel, so OAuth attempts that succeeded in the browser would + // never clear the `connecting` badge here. Fixes the Telegram half of + // #2128 and inherits the shared error-transition behavior. + useOAuthConnectionListener({ channel: 'telegram', authMode: 'oauth' }); + const startManagedDmPolling = useCallback( (key: string, linkToken: string) => { stopManagedDmPolling(key); @@ -156,6 +164,9 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => { (spec: AuthModeSpec) => { const key = `telegram:${spec.mode}`; void runBusy(key, async () => { + // Cancel any sibling auth mode still mid-`connecting` so the panel + // doesn't pin multiple methods simultaneously (#2128). + dispatch(clearOtherPendingForChannel({ channel: 'telegram', exceptAuthMode: spec.mode })); dispatch( setChannelConnectionStatus({ channel: 'telegram', diff --git a/app/src/hooks/__tests__/useOAuthConnectionListener.test.tsx b/app/src/hooks/__tests__/useOAuthConnectionListener.test.tsx new file mode 100644 index 0000000000..558819d274 --- /dev/null +++ b/app/src/hooks/__tests__/useOAuthConnectionListener.test.tsx @@ -0,0 +1,146 @@ +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { Provider } from 'react-redux'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { store } from '../../store'; +import { + resetChannelConnectionsState, + setChannelConnectionStatus, +} from '../../store/channelConnectionsSlice'; +import { useOAuthConnectionListener } from '../useOAuthConnectionListener'; + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +const dispatchOAuthSuccess = (toolkit: string, integrationId = 'integration-123') => { + window.dispatchEvent(new CustomEvent('oauth:success', { detail: { integrationId, toolkit } })); +}; + +const dispatchOAuthError = (provider: string, errorCode = 'access_denied', message?: string) => { + window.dispatchEvent( + new CustomEvent('oauth:error', { detail: { provider, errorCode, message } }) + ); +}; + +describe('useOAuthConnectionListener (#2128)', () => { + beforeEach(() => { + store.dispatch(resetChannelConnectionsState()); + }); + + afterEach(() => { + store.dispatch(resetChannelConnectionsState()); + }); + + it('transitions matching channel to connected on oauth:success', () => { + store.dispatch( + setChannelConnectionStatus({ channel: 'discord', authMode: 'oauth', status: 'connecting' }) + ); + + renderHook(() => useOAuthConnectionListener({ channel: 'discord', authMode: 'oauth' }), { + wrapper, + }); + dispatchOAuthSuccess('discord'); + + const connection = store.getState().channelConnections.connections.discord.oauth; + expect(connection?.status).toBe('connected'); + expect(connection?.lastError).toBeUndefined(); + expect(connection?.capabilities).toEqual(['read', 'write']); + }); + + it('ignores oauth:success for a different channel', () => { + store.dispatch( + setChannelConnectionStatus({ channel: 'discord', authMode: 'oauth', status: 'connecting' }) + ); + + renderHook(() => useOAuthConnectionListener({ channel: 'discord', authMode: 'oauth' }), { + wrapper, + }); + dispatchOAuthSuccess('telegram'); + + expect(store.getState().channelConnections.connections.discord.oauth?.status).toBe( + 'connecting' + ); + }); + + it('matches toolkit case-insensitively', () => { + renderHook(() => useOAuthConnectionListener({ channel: 'discord', authMode: 'oauth' }), { + wrapper, + }); + dispatchOAuthSuccess('Discord'); + + expect(store.getState().channelConnections.connections.discord.oauth?.status).toBe('connected'); + }); + + it('transitions to error on oauth:error and surfaces the message', () => { + store.dispatch( + setChannelConnectionStatus({ channel: 'telegram', authMode: 'oauth', status: 'connecting' }) + ); + + renderHook(() => useOAuthConnectionListener({ channel: 'telegram', authMode: 'oauth' }), { + wrapper, + }); + dispatchOAuthError('telegram', 'access_denied', 'User cancelled'); + + const connection = store.getState().channelConnections.connections.telegram.oauth; + expect(connection?.status).toBe('error'); + expect(connection?.lastError).toBe('User cancelled'); + }); + + it('falls back to a generic error message when none is provided', () => { + renderHook(() => useOAuthConnectionListener({ channel: 'discord', authMode: 'oauth' }), { + wrapper, + }); + dispatchOAuthError('discord', 'unknown_error'); + + const connection = store.getState().channelConnections.connections.discord.oauth; + expect(connection?.status).toBe('error'); + expect(connection?.lastError).toMatch(/OAuth sign-in did not complete/); + }); + + it('ignores oauth:error for a different channel', () => { + store.dispatch( + setChannelConnectionStatus({ channel: 'discord', authMode: 'oauth', status: 'connecting' }) + ); + + renderHook(() => useOAuthConnectionListener({ channel: 'discord', authMode: 'oauth' }), { + wrapper, + }); + dispatchOAuthError('telegram', 'access_denied'); + + expect(store.getState().channelConnections.connections.discord.oauth?.status).toBe( + 'connecting' + ); + }); + + it('records custom capabilities on success when provided', () => { + renderHook( + () => + useOAuthConnectionListener({ + channel: 'discord', + authMode: 'oauth', + capabilitiesOnSuccess: ['dm'], + }), + { wrapper } + ); + dispatchOAuthSuccess('discord'); + + expect(store.getState().channelConnections.connections.discord.oauth?.capabilities).toEqual([ + 'dm', + ]); + }); + + it('unsubscribes on unmount so further events do not mutate state', () => { + const { unmount } = renderHook( + () => useOAuthConnectionListener({ channel: 'discord', authMode: 'oauth' }), + { wrapper } + ); + unmount(); + dispatchOAuthSuccess('discord'); + + // No listener mounted any more — the slice stays at its initial state for + // discord.oauth (undefined, not connected). + expect(store.getState().channelConnections.connections.discord.oauth).toBeUndefined(); + }); +}); diff --git a/app/src/hooks/useOAuthConnectionListener.ts b/app/src/hooks/useOAuthConnectionListener.ts new file mode 100644 index 0000000000..789a9d2168 --- /dev/null +++ b/app/src/hooks/useOAuthConnectionListener.ts @@ -0,0 +1,112 @@ +/** + * OAuth Connection Listener Hook + * + * Bridges the global `oauth:success` / `oauth:error` deep-link CustomEvents + * (dispatched from `utils/desktopDeepLinkListener.ts`) into the + * `channelConnections` Redux slice so that the right channel/authMode badge + * transitions out of `connecting` when the OAuth flow finishes in the system + * browser. + * + * Per-channel config panels (`DiscordConfig`, `TelegramConfig`, …) call this + * hook with their channel + the auth mode that owns the OAuth path. Each panel + * used to roll its own effect, which is how #2128 happened: `DiscordConfig` + * had a success listener, `TelegramConfig` had none, neither handled errors, + * so failed or completed OAuth flows could leave the badge pinned at + * `Connecting` forever. + * + * Centralising this means new channels with OAuth auth modes inherit correct + * pending-state transitions for free. + */ +import debug from 'debug'; +import { useEffect } from 'react'; + +import { + setChannelConnectionStatus, + upsertChannelConnection, +} from '../store/channelConnectionsSlice'; +import { useAppDispatch } from '../store/hooks'; +import type { ChannelAuthMode, ChannelType } from '../types/channels'; + +const log = debug('channels:oauth-listener'); + +interface OAuthSuccessDetail { + integrationId?: string; + toolkit?: string; +} + +interface OAuthErrorDetail { + provider?: string; + errorCode?: string; + message?: string; +} + +export interface UseOAuthConnectionListenerOptions { + /** Channel that owns the OAuth flow (e.g. 'discord', 'telegram'). */ + channel: ChannelType; + /** Auth mode that the OAuth deep-link should resolve to. */ + authMode: ChannelAuthMode; + /** + * Capabilities to record on the connection when OAuth succeeds. Mirrors the + * existing per-channel defaults; kept explicit so each call site stays + * self-documenting. + */ + capabilitiesOnSuccess?: readonly string[]; +} + +/** + * Subscribe to OAuth completion / failure deep-link events for one channel. + * + * Match key: the event's `toolkit` (success) or `provider` (error) field is + * compared case-insensitively to `channel`. Events for other channels are + * ignored so multiple panels can mount the hook simultaneously without + * stepping on each other. + */ +export function useOAuthConnectionListener({ + channel, + authMode, + capabilitiesOnSuccess = ['read', 'write'], +}: UseOAuthConnectionListenerOptions): void { + const dispatch = useAppDispatch(); + + useEffect(() => { + const channelKey = channel.toLowerCase(); + + const handleSuccess = (event: Event) => { + const detail = (event as CustomEvent).detail; + const toolkit = detail?.toolkit?.toLowerCase(); + if (!toolkit || toolkit !== channelKey) return; + + log('oauth success for channel=%s authMode=%s', channel, authMode); + dispatch( + upsertChannelConnection({ + channel, + authMode, + patch: { + status: 'connected', + lastError: undefined, + capabilities: [...capabilitiesOnSuccess], + }, + }) + ); + }; + + const handleError = (event: Event) => { + const detail = (event as CustomEvent).detail; + const provider = detail?.provider?.toLowerCase(); + if (!provider || provider !== channelKey) return; + + const lastError = + detail?.message || + 'OAuth sign-in did not complete. Try again and approve access to continue.'; + log('oauth error for channel=%s authMode=%s code=%s', channel, authMode, detail?.errorCode); + dispatch(setChannelConnectionStatus({ channel, authMode, status: 'error', lastError })); + }; + + window.addEventListener('oauth:success', handleSuccess); + window.addEventListener('oauth:error', handleError); + return () => { + window.removeEventListener('oauth:success', handleSuccess); + window.removeEventListener('oauth:error', handleError); + }; + }, [dispatch, channel, authMode, capabilitiesOnSuccess]); +} diff --git a/app/src/store/__tests__/channelConnectionsSlice.test.ts b/app/src/store/__tests__/channelConnectionsSlice.test.ts index 8efd668abf..3474bb43de 100644 --- a/app/src/store/__tests__/channelConnectionsSlice.test.ts +++ b/app/src/store/__tests__/channelConnectionsSlice.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import reducer, { + clearOtherPendingForChannel, completeBreakingMigration, + setChannelConnectionStatus, setDefaultMessagingChannel, upsertChannelConnection, } from '../channelConnectionsSlice'; @@ -70,6 +72,111 @@ describe('channelConnectionsSlice', () => { expect(state.connections.telegram.managed_dm?.capabilities).toEqual(['dm']); }); + describe('clearOtherPendingForChannel (#2128)', () => { + it('cancels sibling auth modes stuck in connecting', () => { + const migrated = reducer(undefined, completeBreakingMigration()); + const withTwoPending = [ + upsertChannelConnection({ + channel: 'discord', + authMode: 'oauth', + patch: { status: 'connecting' }, + }), + upsertChannelConnection({ + channel: 'discord', + authMode: 'managed_dm', + patch: { status: 'connecting' }, + }), + ].reduce(reducer, migrated); + + const cleared = reducer( + withTwoPending, + clearOtherPendingForChannel({ channel: 'discord', exceptAuthMode: 'managed_dm' }) + ); + + expect(cleared.connections.discord.managed_dm?.status).toBe('connecting'); + expect(cleared.connections.discord.oauth?.status).toBe('disconnected'); + expect(cleared.connections.discord.oauth?.lastError).toBeUndefined(); + }); + + it('leaves connected and error sibling rows untouched', () => { + const migrated = reducer(undefined, completeBreakingMigration()); + const mixed = [ + upsertChannelConnection({ + channel: 'discord', + authMode: 'oauth', + patch: { status: 'connected', capabilities: ['read', 'write'] }, + }), + setChannelConnectionStatus({ + channel: 'discord', + authMode: 'bot_token', + status: 'error', + lastError: 'bad token', + }), + upsertChannelConnection({ + channel: 'discord', + authMode: 'managed_dm', + patch: { status: 'connecting' }, + }), + ].reduce(reducer, migrated); + + const cleared = reducer( + mixed, + clearOtherPendingForChannel({ channel: 'discord', exceptAuthMode: 'managed_dm' }) + ); + + // Sibling row that was `connecting` would have flipped, but there's + // none here — the others are connected/error and must be preserved. + expect(cleared.connections.discord.oauth?.status).toBe('connected'); + expect(cleared.connections.discord.bot_token?.status).toBe('error'); + expect(cleared.connections.discord.bot_token?.lastError).toBe('bad token'); + expect(cleared.connections.discord.managed_dm?.status).toBe('connecting'); + }); + + it('is a no-op when no sibling is pending', () => { + const migrated = reducer(undefined, completeBreakingMigration()); + const justOne = reducer( + migrated, + upsertChannelConnection({ + channel: 'telegram', + authMode: 'oauth', + patch: { status: 'connecting' }, + }) + ); + const after = reducer( + justOne, + clearOtherPendingForChannel({ channel: 'telegram', exceptAuthMode: 'oauth' }) + ); + + expect(after.connections.telegram.oauth?.status).toBe('connecting'); + }); + + it('does not affect other channels', () => { + const migrated = reducer(undefined, completeBreakingMigration()); + const crossChannel = [ + upsertChannelConnection({ + channel: 'discord', + authMode: 'oauth', + patch: { status: 'connecting' }, + }), + upsertChannelConnection({ + channel: 'telegram', + authMode: 'oauth', + patch: { status: 'connecting' }, + }), + ].reduce(reducer, migrated); + + const after = reducer( + crossChannel, + clearOtherPendingForChannel({ channel: 'discord', exceptAuthMode: 'bot_token' }) + ); + + // discord.oauth was pending and is not the exception → cleared. + expect(after.connections.discord.oauth?.status).toBe('disconnected'); + // telegram.oauth is a different channel → untouched. + expect(after.connections.telegram.oauth?.status).toBe('connecting'); + }); + }); + it('clears stale lastError when patch explicitly sets undefined', () => { const withError = reducer( undefined, diff --git a/app/src/store/channelConnectionsSlice.ts b/app/src/store/channelConnectionsSlice.ts index f984887298..064d7ea978 100644 --- a/app/src/store/channelConnectionsSlice.ts +++ b/app/src/store/channelConnectionsSlice.ts @@ -125,6 +125,34 @@ const channelConnectionsSlice = createSlice({ }); }, + /** + * Cancel any sibling auth modes on the same channel that are still in + * the `connecting` state, except the one explicitly started. Fixes #2128 + * where starting a second OAuth method on a channel left the previous + * method's badge pinned at `Connecting` forever. Cancelled rows transition + * to `disconnected` (not `error`) so the UI doesn't surface a misleading + * failure message — the user explicitly switched methods. + */ + clearOtherPendingForChannel( + state, + action: PayloadAction<{ channel: ChannelType; exceptAuthMode: ChannelAuthMode }> + ) { + const { channel, exceptAuthMode } = action.payload; + const modes = state.connections[channel]; + if (!modes) return; + for (const mode of Object.keys(modes) as ChannelAuthMode[]) { + if (mode === exceptAuthMode) continue; + const existing = modes[mode]; + if (existing?.status !== 'connecting') continue; + modes[mode] = touchConnection(existing, { + channel, + authMode: mode, + status: 'disconnected', + lastError: undefined, + }); + } + }, + resetChannelConnectionsState() { return initialState; }, @@ -140,6 +168,7 @@ export const { upsertChannelConnection, setChannelConnectionStatus, disconnectChannelConnection, + clearOtherPendingForChannel, resetChannelConnectionsState, } = channelConnectionsSlice.actions; From a559bd87c69afb180925283f8b7a40e28efbe2fd Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Wed, 20 May 2026 00:17:09 +0200 Subject: [PATCH 2/3] fix(channels): abort in-flight managed-DM polls before clearing siblings (#2128) CodeRabbit on PR #2256 flagged a real race in the new clearOtherPendingForChannel flow: when the user switches auth modes, the slice row for the prior method is reset to `disconnected`, but the underlying managed-DM polling loop is still alive and can later dispatch the cleared row back to `connected` or `error`, leaking the old attempt into state. Fix in both panels: cancel the in-flight poll *before* dispatching the clear. - DiscordConfig: unconditionally `pollAbort.current?.abort()` + `setLinkToken(null)` at the top of handleConnect. Safe in the same- mode case because startLinkPolling spawns a fresh controller. - TelegramConfig: when starting a non-managed-dm mode, call `stopManagedDmPolling('telegram:managed_dm')` before the slice clear. (Same-mode case is already handled inside startManagedDmPolling.) Added `stopManagedDmPolling` to handleConnect's dep array per react-hooks/exhaustive-deps. Co-Authored-By: Claude --- app/src/components/channels/DiscordConfig.tsx | 7 +++++++ app/src/components/channels/TelegramConfig.tsx | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/src/components/channels/DiscordConfig.tsx b/app/src/components/channels/DiscordConfig.tsx index aac42c5724..41f9dd36a7 100644 --- a/app/src/components/channels/DiscordConfig.tsx +++ b/app/src/components/channels/DiscordConfig.tsx @@ -139,6 +139,13 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => { (spec: AuthModeSpec) => { const key = `discord:${spec.mode}`; void runBusy(key, async () => { + // Cancel any in-flight managed-link poll before clearing sibling + // state. Without this, a stale poll completion could later dispatch + // `managed_dm` back to connected/error, reviving a flow the user + // just switched away from. (CodeRabbit on PR #2256.) + pollAbort.current?.abort(); + setLinkToken(null); + // Drop any sibling auth mode that's still mid-`connecting` so the // panel doesn't show two methods pinned simultaneously (#2128). dispatch(clearOtherPendingForChannel({ channel: 'discord', exceptAuthMode: spec.mode })); diff --git a/app/src/components/channels/TelegramConfig.tsx b/app/src/components/channels/TelegramConfig.tsx index ae6ade3113..e28d362059 100644 --- a/app/src/components/channels/TelegramConfig.tsx +++ b/app/src/components/channels/TelegramConfig.tsx @@ -164,6 +164,14 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => { (spec: AuthModeSpec) => { const key = `telegram:${spec.mode}`; void runBusy(key, async () => { + // Abort sibling managed-dm polls before clearing their slice rows; + // a still-running poll could otherwise complete after the clear and + // dispatch the sibling back to connected/error, leaking the prior + // attempt into state. (CodeRabbit on PR #2256.) Only managed_dm + // polls today, so stop that one explicitly. + const managedDmKey = 'telegram:managed_dm'; + if (key !== managedDmKey) stopManagedDmPolling(managedDmKey); + // Cancel any sibling auth mode still mid-`connecting` so the panel // doesn't pin multiple methods simultaneously (#2128). dispatch(clearOtherPendingForChannel({ channel: 'telegram', exceptAuthMode: spec.mode })); @@ -289,7 +297,15 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => { } }); }, - [dispatch, fieldValues, runBusy, startManagedDmPolling, MANAGED_DM_CONNECTING_MESSAGE, t] + [ + dispatch, + fieldValues, + runBusy, + startManagedDmPolling, + stopManagedDmPolling, + MANAGED_DM_CONNECTING_MESSAGE, + t, + ] ); const handleDisconnect = useCallback( From 8a6f6beeb581077bf79b9a5d51a476acefe460c7 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Wed, 20 May 2026 08:27:14 +0200 Subject: [PATCH 3/3] fix(channels): stabilize default OAuth capabilities reference (#2128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit on PR #2256 flagged that the inline default array literal `['read', 'write']` for `capabilitiesOnSuccess` creates a fresh reference on every parent render. Since that prop is in the effect's dep array, the global `oauth:success` / `oauth:error` listeners would tear down and re-subscribe on every render of any panel using the default — same class of bug as #2177. Hoist to a module-level `DEFAULT_OAUTH_CAPABILITIES` constant so the identity is stable. No behaviour change at the call sites. Co-Authored-By: Claude --- app/src/hooks/useOAuthConnectionListener.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/hooks/useOAuthConnectionListener.ts b/app/src/hooks/useOAuthConnectionListener.ts index 789a9d2168..96f78bf1e3 100644 --- a/app/src/hooks/useOAuthConnectionListener.ts +++ b/app/src/hooks/useOAuthConnectionListener.ts @@ -29,6 +29,12 @@ import type { ChannelAuthMode, ChannelType } from '../types/channels'; const log = debug('channels:oauth-listener'); +// Module-level constant so the default identity is stable across renders. +// Without this, an inline default array literal would land in the effect's +// dep array and re-subscribe the global oauth:* listeners on every parent +// render. (CodeRabbit on PR #2256.) +const DEFAULT_OAUTH_CAPABILITIES = ['read', 'write'] as const; + interface OAuthSuccessDetail { integrationId?: string; toolkit?: string; @@ -64,7 +70,7 @@ export interface UseOAuthConnectionListenerOptions { export function useOAuthConnectionListener({ channel, authMode, - capabilitiesOnSuccess = ['read', 'write'], + capabilitiesOnSuccess = DEFAULT_OAUTH_CAPABILITIES, }: UseOAuthConnectionListenerOptions): void { const dispatch = useAppDispatch();