diff --git a/apps/web/src/app/(app)/claw/components/PersonalInstancePresenceMount.tsx b/apps/web/src/app/(app)/claw/components/PersonalInstancePresenceMount.tsx new file mode 100644 index 0000000000..d1236318ae --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/PersonalInstancePresenceMount.tsx @@ -0,0 +1,10 @@ +'use client'; + +import { useInstancePresence } from '@/hooks/useInstancePresence'; +import { useKiloClawStatus } from '@/hooks/useKiloClaw'; + +export function PersonalInstancePresenceMount() { + const { data: status } = useKiloClawStatus(); + useInstancePresence(status?.sandboxId ?? undefined); + return null; +} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx index 8ab3d6b2eb..ab8933d548 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/KiloChatLayout.tsx @@ -7,7 +7,9 @@ import { useQueryClient } from '@tanstack/react-query'; import { formatKiloChatError } from '@kilocode/kilo-chat'; import { ConversationList } from './ConversationList'; import { KiloChatContext, type KiloChatContextValue } from './kiloChatContext'; -import { useEventService, useInstanceContext } from '../hooks/useEventService'; +import { kiloclawInstanceContext } from '@kilocode/event-service'; +import { usePresenceSubscription } from '@/hooks/usePresenceSubscription'; +import { useEventServiceClient } from '@/contexts/EventServiceContext'; import { useConversations, useCreateConversation, @@ -20,7 +22,6 @@ import { // ── Layout component ──────────────────────────────────────────────── type KiloChatLayoutProps = { - getToken: () => Promise; currentUserId: string; sandboxId: string | null; basePath: string; @@ -32,7 +33,6 @@ type KiloChatLayoutProps = { }; export function KiloChatLayout({ - getToken, currentUserId, sandboxId, basePath, @@ -44,8 +44,11 @@ export function KiloChatLayout({ }: KiloChatLayoutProps) { const router = useRouter(); - const { eventService, kiloChatClient } = useEventService(getToken); - useInstanceContext(eventService, sandboxId); + const { eventService, kiloChatClient } = useEventServiceClient(); + usePresenceSubscription( + sandboxId ? kiloclawInstanceContext(sandboxId) : null, + Boolean(sandboxId) + ); const queryClient = useQueryClient(); const params = useParams<{ conversationId?: string }>(); @@ -189,7 +192,6 @@ export function KiloChatLayout({ const contextValue = useMemo( () => ({ - getToken, currentUserId, instanceStatus, leavingConversationId, @@ -202,7 +204,6 @@ export function KiloChatLayout({ kiloChatClient, }), [ - getToken, currentUserId, instanceStatus, leavingConversationId, diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx index 66beb8b8c7..e8d373a230 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/MessageArea.tsx @@ -14,7 +14,12 @@ import { useRemoveReaction, useExecuteAction, } from '../hooks/useMessages'; -import { useConversationContext } from '../hooks/useEventService'; +import { + kiloclawConversationContext, + presenceContextForConversation, +} from '@kilocode/event-service'; +import { usePresenceSubscription } from '@/hooks/usePresenceSubscription'; +import { useDocumentVisible } from '@/hooks/useDocumentVisible'; import { useTypingSender, useTypingState } from '../hooks/useTyping'; import { useConversationDetail, @@ -75,13 +80,27 @@ export function MessageArea({ conversationId }: MessageAreaProps) { const [isRenamingTitle, setIsRenamingTitle] = useState(false); const [renameText, setRenameText] = useState(''); - // Subscribe to this conversation's events via the event-service WebSocket - useConversationContext(eventService, sandboxId, conversationId); + const visible = useDocumentVisible(); + + // Subscribe to this conversation's chat-event stream while the conversation + // is open. Not gated on visibility — we want incoming messages to land in + // the cache even when the tab is hidden. + usePresenceSubscription( + sandboxId && conversationId ? kiloclawConversationContext(sandboxId, conversationId) : null, + Boolean(sandboxId && conversationId) + ); + + // Signal our own presence on this conversation. Gated on visibility so we + // only appear "viewing" while the tab is actually in the foreground. + usePresenceSubscription( + sandboxId && conversationId ? presenceContextForConversation(sandboxId, conversationId) : null, + Boolean(sandboxId && conversationId) && visible + ); // Event Service delivers subscribed contexts to every handler, so each // handler must validate the incoming `ctx` against this string before // applying changes to the active conversation's state. - const expectedContext = sandboxId ? `/kiloclaw/${sandboxId}/${conversationId}` : null; + const expectedContext = sandboxId ? kiloclawConversationContext(sandboxId, conversationId) : null; const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useMessages( kiloChatClient, diff --git a/apps/web/src/app/(app)/claw/kilo-chat/components/kiloChatContext.ts b/apps/web/src/app/(app)/claw/kilo-chat/components/kiloChatContext.ts index eb7154a26b..823296599f 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/components/kiloChatContext.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/components/kiloChatContext.ts @@ -5,7 +5,6 @@ import type { EventServiceClient } from '@kilocode/event-service'; import type { KiloChatClient } from '@kilocode/kilo-chat'; export type KiloChatContextValue = { - getToken: () => Promise; currentUserId: string; instanceStatus: string | null; leavingConversationId: string | null; diff --git a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useEventService.ts b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useEventService.ts deleted file mode 100644 index 627dc60a33..0000000000 --- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useEventService.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useEffect, useMemo } from 'react'; -import { EventServiceClient } from '@kilocode/event-service'; -import { KiloChatClient } from '@kilocode/kilo-chat'; -import { KILO_CHAT_URL, EVENT_SERVICE_URL } from '@/lib/constants'; -import { clearKiloChatToken } from '../token'; - -/** - * Creates and manages the EventServiceClient + KiloChatClient singleton. - * Connects the WebSocket on mount, disconnects on unmount. - * Returns the clients for use by child hooks. - */ -export function useEventService(getToken: () => Promise) { - const eventService = useMemo( - () => - new EventServiceClient({ - url: EVENT_SERVICE_URL, - getToken, - // Event Service rejected our token as 401/403. Drop the cached - // token so the next request refetches; the socket is permanently - // stopped by the client to avoid a reconnect storm. - onUnauthorized: () => { - clearKiloChatToken(); - }, - }), - [getToken] - ); - - const kiloChatClient = useMemo( - () => new KiloChatClient({ eventService, baseUrl: KILO_CHAT_URL, getToken }), - [eventService, getToken] - ); - - // Connect on mount, disconnect on unmount - useEffect(() => { - void eventService.connect(); - return () => eventService.disconnect(); - }, [eventService]); - - return { eventService, kiloChatClient }; -} - -/** - * Subscribes to the instance-level context (`/kiloclaw/{sandboxId}`). - * Used at the layout level for cross-conversation events (future: unread counts). - */ -export function useInstanceContext(eventService: EventServiceClient, sandboxId: string | null) { - useEffect(() => { - if (!sandboxId) return; - const context = `/kiloclaw/${sandboxId}`; - eventService.subscribe([context]); - return () => eventService.unsubscribe([context]); - }, [eventService, sandboxId]); -} - -/** - * Subscribes to the conversation-level context (`/kiloclaw/{sandboxId}/{conversationId}`). - * Used in MessageArea for message/typing/reaction events. - */ -export function useConversationContext( - eventService: EventServiceClient, - sandboxId: string | null, - conversationId: string | null -) { - useEffect(() => { - if (!sandboxId || !conversationId) return; - const context = `/kiloclaw/${sandboxId}/${conversationId}`; - eventService.subscribe([context]); - return () => eventService.unsubscribe([context]); - }, [eventService, sandboxId, conversationId]); -} diff --git a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts index f21e165e74..de70a427c5 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts +++ b/apps/web/src/app/(app)/claw/kilo-chat/hooks/useMessages.ts @@ -16,6 +16,7 @@ import type { ExecApprovalDecision, } from '@kilocode/kilo-chat'; import { useEffect } from 'react'; +import { kiloclawConversationContext } from '@kilocode/event-service'; import { toast } from 'sonner'; const PAGE_SIZE = 50; @@ -401,7 +402,7 @@ export function useMessageCacheUpdater( useEffect(() => { if (!conversationId || !sandboxId) return; const queryKey = ['kilo-chat', 'messages', conversationId]; - const expectedContext = `/kiloclaw/${sandboxId}/${conversationId}`; + const expectedContext = kiloclawConversationContext(sandboxId, conversationId); const onCreated = (ctx: string, e: MessageCreatedEvent) => { if (ctx !== expectedContext) return; diff --git a/apps/web/src/app/(app)/claw/kilo-chat/layout.tsx b/apps/web/src/app/(app)/claw/kilo-chat/layout.tsx index 3b207995d8..242a397ed6 100644 --- a/apps/web/src/app/(app)/claw/kilo-chat/layout.tsx +++ b/apps/web/src/app/(app)/claw/kilo-chat/layout.tsx @@ -1,20 +1,15 @@ 'use client'; -import { useCallback } from 'react'; import { useUser } from '@/hooks/useUser'; import { useKiloClawStatus } from '@/hooks/useKiloClaw'; -import { getKiloChatToken } from './token'; import { KiloChatLayout } from './components/KiloChatLayout'; export default function KiloChatRootLayout({ children }: { children: React.ReactNode }) { const { data: user } = useUser(); const { data: status, isLoading } = useKiloClawStatus(); - const getToken = useCallback(() => getKiloChatToken(), []); - return ( + {children} diff --git a/apps/web/src/app/(app)/components/PlatformPresenceMount.tsx b/apps/web/src/app/(app)/components/PlatformPresenceMount.tsx new file mode 100644 index 0000000000..c3719aa9a6 --- /dev/null +++ b/apps/web/src/app/(app)/components/PlatformPresenceMount.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { usePlatformPresence } from '@/hooks/usePlatformPresence'; + +export function PlatformPresenceMount() { + usePlatformPresence(); + return null; +} diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index e20158be88..afb80e8546 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -3,26 +3,31 @@ import { AppTopbar } from './components/AppTopbar'; import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'; import { RoleTestingProvider } from '@/contexts/RoleTestingContext'; import { PageTitleProvider } from '@/contexts/PageTitleContext'; +import { EventServiceProvider } from '@/contexts/EventServiceContext'; import { AdminOmnibox } from '@/components/admin-omnibox'; import { PrefetchedOrganizations } from './components/PrefetchedOrganizations'; +import { PlatformPresenceMount } from './components/PlatformPresenceMount'; import { ImpactIdentify } from '@/components/ImpactIdentify'; export default function AppLayout({ children }: { children: React.ReactNode }) { return ( - - - -
- - - -
{children}
-
-
-
-
+ + + + + +
+ + + +
{children}
+
+
+
+
+
diff --git a/apps/web/src/app/(app)/organizations/[id]/claw/components/OrgInstancePresenceMount.tsx b/apps/web/src/app/(app)/organizations/[id]/claw/components/OrgInstancePresenceMount.tsx new file mode 100644 index 0000000000..23c300b371 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/claw/components/OrgInstancePresenceMount.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { useInstancePresence } from '@/hooks/useInstancePresence'; +import { useOrgKiloClawStatus } from '@/hooks/useOrgKiloClaw'; + +export function OrgInstancePresenceMount() { + const params = useParams<{ id: string }>(); + const organizationId = params?.id; + const { data: status } = useOrgKiloClawStatus(organizationId); + useInstancePresence(status?.sandboxId ?? undefined); + return null; +} diff --git a/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/layout.tsx b/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/layout.tsx index 27733df73e..8fd6f6cedf 100644 --- a/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/layout.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/claw/kilo-chat/layout.tsx @@ -1,10 +1,8 @@ 'use client'; -import { useCallback } from 'react'; import { useParams } from 'next/navigation'; import { useUser } from '@/hooks/useUser'; import { useOrgKiloClawStatus } from '@/hooks/useOrgKiloClaw'; -import { getKiloChatToken } from '@/app/(app)/claw/kilo-chat/token'; import { KiloChatLayout } from '@/app/(app)/claw/kilo-chat/components/KiloChatLayout'; export default function OrgKiloChatRootLayout({ children }: { children: React.ReactNode }) { @@ -13,14 +11,11 @@ export default function OrgKiloChatRootLayout({ children }: { children: React.Re const { data: user } = useUser(); const { data: status, isLoading } = useOrgKiloClawStatus(organizationId); - const getToken = useCallback(() => getKiloChatToken(), []); - const basePath = `/organizations/${organizationId}/claw/kilo-chat`; const noInstanceRedirect = `/organizations/${organizationId}/claw/new`; return ( + {children} diff --git a/apps/web/src/contexts/EventServiceContext.tsx b/apps/web/src/contexts/EventServiceContext.tsx new file mode 100644 index 0000000000..03bc458faa --- /dev/null +++ b/apps/web/src/contexts/EventServiceContext.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { createContext, useContext, useEffect, useMemo, type ReactNode } from 'react'; +import { EventServiceClient } from '@kilocode/event-service'; +import { KiloChatClient } from '@kilocode/kilo-chat'; +import { EVENT_SERVICE_URL, KILO_CHAT_URL } from '@/lib/constants'; +import { getKiloChatToken, clearKiloChatToken } from '@/app/(app)/claw/kilo-chat/token'; + +export type EventServiceContextValue = { + eventService: EventServiceClient; + kiloChatClient: KiloChatClient; +}; + +const EventServiceContext = createContext(null); + +type EventServiceProviderProps = { + children: ReactNode; +}; + +/** + * Global EventService provider — owns the single `EventServiceClient` and + * `KiloChatClient` for the authenticated app. Mounted in `(app)/layout.tsx` + * so platform-, instance-, and conversation-level presence subscriptions + * (and the kilo-chat UI) all share one WebSocket. + */ +export function EventServiceProvider({ children }: EventServiceProviderProps) { + const eventService = useMemo( + () => + new EventServiceClient({ + url: EVENT_SERVICE_URL, + getToken: getKiloChatToken, + // Event Service rejected our token as 401/403. Drop the cached + // token so the next request refetches; the socket is permanently + // stopped by the client to avoid a reconnect storm. + onUnauthorized: () => { + clearKiloChatToken(); + }, + }), + [] + ); + + const kiloChatClient = useMemo( + () => + new KiloChatClient({ + eventService, + baseUrl: KILO_CHAT_URL, + getToken: getKiloChatToken, + }), + [eventService] + ); + + // Connect on mount, disconnect on unmount. + useEffect(() => { + void eventService.connect(); + return () => eventService.disconnect(); + }, [eventService]); + + const value = useMemo( + () => ({ eventService, kiloChatClient }), + [eventService, kiloChatClient] + ); + + return {children}; +} + +export function useEventServiceClient(): EventServiceContextValue { + const ctx = useContext(EventServiceContext); + if (!ctx) { + throw new Error('useEventServiceClient must be used within an EventServiceProvider'); + } + return ctx; +} diff --git a/apps/web/src/hooks/useDocumentVisible.ts b/apps/web/src/hooks/useDocumentVisible.ts new file mode 100644 index 0000000000..df55c211cc --- /dev/null +++ b/apps/web/src/hooks/useDocumentVisible.ts @@ -0,0 +1,19 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * Returns whether the current document is visible (not hidden). + * SSR-safe: returns `true` when `document` is undefined. + */ +export function useDocumentVisible(): boolean { + const [visible, setVisible] = useState(typeof document === 'undefined' ? true : !document.hidden); + + useEffect(() => { + const onChange = () => setVisible(!document.hidden); + document.addEventListener('visibilitychange', onChange); + return () => document.removeEventListener('visibilitychange', onChange); + }, []); + + return visible; +} diff --git a/apps/web/src/hooks/useInstancePresence.ts b/apps/web/src/hooks/useInstancePresence.ts new file mode 100644 index 0000000000..085e28f2e2 --- /dev/null +++ b/apps/web/src/hooks/useInstancePresence.ts @@ -0,0 +1,14 @@ +'use client'; + +import { presenceContextForInstance } from '@kilocode/event-service'; + +import { useDocumentVisible } from './useDocumentVisible'; +import { usePresenceSubscription } from './usePresenceSubscription'; + +export function useInstancePresence(sandboxId: string | undefined, enabled = true) { + const visible = useDocumentVisible(); + usePresenceSubscription( + sandboxId ? presenceContextForInstance(sandboxId) : null, + Boolean(sandboxId) && enabled && visible + ); +} diff --git a/apps/web/src/hooks/usePlatformPresence.ts b/apps/web/src/hooks/usePlatformPresence.ts new file mode 100644 index 0000000000..86cb6fa2e8 --- /dev/null +++ b/apps/web/src/hooks/usePlatformPresence.ts @@ -0,0 +1,11 @@ +'use client'; + +import { presenceContextForPlatform } from '@kilocode/event-service'; + +import { useDocumentVisible } from './useDocumentVisible'; +import { usePresenceSubscription } from './usePresenceSubscription'; + +export function usePlatformPresence() { + const visible = useDocumentVisible(); + usePresenceSubscription(presenceContextForPlatform('web'), visible); +} diff --git a/apps/web/src/hooks/usePresenceSubscription.ts b/apps/web/src/hooks/usePresenceSubscription.ts new file mode 100644 index 0000000000..56bb55a381 --- /dev/null +++ b/apps/web/src/hooks/usePresenceSubscription.ts @@ -0,0 +1,23 @@ +'use client'; + +import { useEffect } from 'react'; +import { useEventServiceClient } from '@/contexts/EventServiceContext'; + +/** + * Subscribes to a single presence/event-service context for the lifetime of + * the calling component. Bails out when `active` is false so callers can + * gate the subscription on, e.g., feature flags or page visibility. + * + * Reads the global `EventServiceClient` from `EventServiceProvider`, mounted + * in `(app)/layout.tsx` for every authenticated route. + */ +export function usePresenceSubscription(context: string | null, active: boolean) { + const { eventService } = useEventServiceClient(); + useEffect(() => { + if (!active || context === null) return; + eventService.subscribe([context]); + return () => { + eventService.unsubscribe([context]); + }; + }, [eventService, context, active]); +} diff --git a/apps/web/src/routers/kilo-chat-router.test.ts b/apps/web/src/routers/kilo-chat-router.test.ts new file mode 100644 index 0000000000..9d64775140 --- /dev/null +++ b/apps/web/src/routers/kilo-chat-router.test.ts @@ -0,0 +1,39 @@ +import jwt from 'jsonwebtoken'; +import { createCallerForUser } from '@/routers/test-utils'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { NEXTAUTH_SECRET } from '@/lib/config.server'; +import { JWT_TOKEN_VERSION } from '@/lib/tokens'; +import type { User } from '@kilocode/db/schema'; + +let testUser: User; + +describe('kiloChat router - getToken', () => { + beforeAll(async () => { + testUser = await insertTestUser({ + google_user_email: `kilo-chat-token-${crypto.randomUUID()}@example.com`, + google_user_name: 'Kilo Chat Token Test User', + }); + }); + + it('returns a verifiable kilo-chat JWT for the caller, expiring in ~1h', async () => { + const caller = await createCallerForUser(testUser.id); + const before = Date.now(); + const result = await caller.kiloChat.getToken(); + const after = Date.now(); + + const payload = jwt.verify(result.token, NEXTAUTH_SECRET, { + algorithms: ['HS256'], + }) as jwt.JwtPayload & { kiloUserId: string; tokenSource: string; version: number }; + + expect(payload.kiloUserId).toBe(testUser.id); + expect(payload.tokenSource).toBe('kilo-chat'); + expect(payload.version).toBe(JWT_TOKEN_VERSION); + + const expiresAtMs = Date.parse(result.expiresAt); + expect(Number.isNaN(expiresAtMs)).toBe(false); + // Router uses a 1h TTL; allow ±5s of clock slop around the call window. + const oneHourMs = 60 * 60 * 1000; + expect(expiresAtMs).toBeGreaterThanOrEqual(before + oneHourMs - 5_000); + expect(expiresAtMs).toBeLessThanOrEqual(after + oneHourMs + 5_000); + }); +}); diff --git a/apps/web/src/routers/kilo-chat-router.ts b/apps/web/src/routers/kilo-chat-router.ts new file mode 100644 index 0000000000..4b9b4e4cd5 --- /dev/null +++ b/apps/web/src/routers/kilo-chat-router.ts @@ -0,0 +1,20 @@ +import 'server-only'; +import * as z from 'zod'; +import { generateApiToken } from '@/lib/tokens'; +import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; + +const KILO_CHAT_TOKEN_TTL_S = 60 * 60; + +export const kiloChatRouter = createTRPCRouter({ + getToken: baseProcedure + .output(z.object({ token: z.string(), expiresAt: z.iso.datetime() })) + .query(({ ctx }) => { + const token = generateApiToken( + ctx.user, + { tokenSource: 'kilo-chat' }, + { expiresIn: KILO_CHAT_TOKEN_TTL_S } + ); + const expiresAt = new Date(Date.now() + KILO_CHAT_TOKEN_TTL_S * 1000).toISOString(); + return { token, expiresAt }; + }), +}); diff --git a/apps/web/src/routers/root-router.ts b/apps/web/src/routers/root-router.ts index 3380f4766f..90825e3d05 100644 --- a/apps/web/src/routers/root-router.ts +++ b/apps/web/src/routers/root-router.ts @@ -32,6 +32,7 @@ import { webhookTriggersRouter } from '@/routers/webhook-triggers-router'; import { userFeedbackRouter } from '@/routers/user-feedback-router'; import { appBuilderFeedbackRouter } from '@/routers/app-builder-feedback-router'; import { cloudAgentNextFeedbackRouter } from '@/routers/cloud-agent-next-feedback-router'; +import { kiloChatRouter } from '@/routers/kilo-chat-router'; import { kiloclawRouter } from '@/routers/kiloclaw-router'; import { modelsRouter } from '@/routers/models-router'; import { unifiedSessionsRouter } from '@/routers/unified-sessions-router'; @@ -69,6 +70,7 @@ export const rootRouter = createTRPCRouter({ userFeedback: userFeedbackRouter, appBuilderFeedback: appBuilderFeedbackRouter, cloudAgentNextFeedback: cloudAgentNextFeedbackRouter, + kiloChat: kiloChatRouter, kiloclaw: kiloclawRouter, models: modelsRouter, unifiedSessions: unifiedSessionsRouter, diff --git a/packages/event-service/src/client.ts b/packages/event-service/src/client.ts index 9466d50b73..1bda87f200 100644 --- a/packages/event-service/src/client.ts +++ b/packages/event-service/src/client.ts @@ -103,6 +103,10 @@ export class EventServiceClient { } const token = await this.getToken(); + // disconnect() may have run while we were awaiting the token. Bail before + // creating the socket so we don't leak a WebSocket + ping timer past + // provider unmount (e.g. sign-out, navigation, strict-mode remount). + if (this.destroyed) return; const subprotocol = `${SUBPROTOCOL_PREFIX}${encodeBase64Url(token)}`; return new Promise((resolve, reject) => {