/* eslint-disable react-hooks/refs, react-hooks/set-state-in-effect -- pre-existing pattern flagged by the React Compiler rules added in eslint-plugin-react-hooks v7; refactoring it is out of scope for the lint toolchain upgrade. */ import { StreamChatService } from '@linktr.ee/messaging-core' import React, { createContext, useContext, useEffect, useState, useRef, useCallback, } from 'react' import { StreamChat } from 'stream-chat' import { Chat } from 'stream-chat-react' import { MessagingLoggerProvider, resolveLogger } from '../logging' import type { MessagingProviderProps, MessagingCapabilities } from '../types' /** * Context value for messaging state and service */ export interface MessagingContextValue { service: StreamChatService | null client: StreamChat | null // Stream Chat client isConnected: boolean isLoading: boolean error: string | null capabilities: MessagingCapabilities refreshConnection: () => Promise debug: boolean } const MessagingContext = createContext({ service: null, client: null, isConnected: false, isLoading: false, error: null, capabilities: {}, refreshConnection: async () => {}, debug: false, }) /** * Hook to access messaging context */ export const useMessagingContext = () => useContext(MessagingContext) /** * Provider component that wraps messaging-core with React state management */ export const MessagingProvider: React.FC = ({ children, user, serviceConfig, apiKey, capabilities = {}, debug = false, client: injectedClient, logger, }) => { // Create debug logger that respects the debug prop const debugLog = useCallback( (message: string, ...args: unknown[]) => { if (debug) { console.log(`🔥 [MessagingProvider] ${message}`, ...args) } }, [debug] ) debugLog('🔄 RENDER START', { userId: user?.id, apiKey: apiKey?.substring(0, 8) + '...', serviceConfig: !!serviceConfig, capabilities: Object.keys(capabilities), }) const [service, setService] = useState(null) const [client, setClient] = useState(null) const [isConnected, setIsConnected] = useState(false) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) // Prevent multiple concurrent connection attempts const connectingRef = useRef(false) // The single service instance, for teardown in the client-cleanup effect. const serviceRef = useRef(null) // serviceConfig / debug are read at service-creation time via refs so that // changing them does not recreate the service (and re-adopt the live client). // The service is created once per client lifetime (apiKey); the connection is // driven by `user`. Real consumers pass a stable, memoized serviceConfig. const serviceConfigRef = useRef(serviceConfig) serviceConfigRef.current = serviceConfig const debugRef = useRef(debug) debugRef.current = debug const loggerRef = useRef(logger) loggerRef.current = logger // Track renders and prop changes const prevPropsRef = useRef({ userId: user?.id, apiKey, serviceConfig, capabilities, }) const renderCountRef = useRef(0) renderCountRef.current++ debugLog('📊 RENDER INFO', { renderCount: renderCountRef.current, currentProps: { userId: user?.id, apiKey: apiKey?.substring(0, 8) + '...' }, propChanges: { userChanged: prevPropsRef.current.userId !== user?.id, apiKeyChanged: prevPropsRef.current.apiKey !== apiKey, serviceConfigChanged: prevPropsRef.current.serviceConfig !== serviceConfig, capabilitiesChanged: prevPropsRef.current.capabilities !== capabilities, }, }) prevPropsRef.current = { userId: user?.id, apiKey, serviceConfig, capabilities, } // Construct the Stream client synchronously so can render against a // stable instance on the very first render. Without this the subtree mounted // as a bare `children` while connecting, then remounted inside once // connectUser resolved (~0.8s later) because the children's parent element // changed — re-running every mount effect (refetches, Stream listeners, // page-view events) and resetting transient state. This client is injected // into the service below, so connectUser connects this exact instance. // // Stored in a ref (not useMemo) so its identity is *guaranteed* stable for the // apiKey's lifetime. useMemo is unsafe here: React may discard a memo and // recompute it, which would mint a new client, re-run the service-init effect, // and tear down + rebuild the live connection — the very remount this fix // exists to prevent. Both effects below key on `chatClient`, so it must not // depend on memo retention. One client per provider mount (keyed on apiKey) — // not the process-wide StreamChat.getInstance, which is shared across keys and // provider instances. const clientRef = useRef<{ apiKey: string; client: StreamChat } | null>(null) if (!injectedClient && apiKey && clientRef.current?.apiKey !== apiKey) { clientRef.current = { apiKey, client: new StreamChat(apiKey) } } // An injected client takes precedence and short-circuits the apiKey-derived // client, the service, and the connect/disconnect lifecycle below — the // caller owns that instance (offline/dev via `createMockMessagingClient`, or // a test). Otherwise fall back to the apiKey-derived client as before. const chatClient = injectedClient ? injectedClient : apiKey ? (clientRef.current?.client ?? null) : null // Create the service once per client lifetime (apiKey). Keyed on `chatClient` // — NOT on serviceConfig/debug — so those changing does not spin up a second // service that would re-adopt the live client and race the in-flight connect. // serviceConfig/debug are read from refs at creation. There is therefore a // single service per client, which owns the connection lifecycle and tracks // the active user; `user` changes reconnect through it. useEffect(() => { const currentRender = renderCountRef.current const currentConfig = serviceConfigRef.current // An injected client owns its own connection — never create a service or // connect for it. Reset all apiKey-path connection state (service, client, // isConnected, error, isLoading): while injected these are masked by the // context, but on a later return to apiKey mode they would leak back — // before the fresh service reconnects — feeding the user-connection effect // a stale service and MessagingShell (which gates its load on // `client && isConnected`) a disconnected client to queryChannels on. if (injectedClient) { serviceRef.current = null setService(null) setClient(null) setIsConnected(false) setError(null) setIsLoading(false) return } if (!apiKey || !chatClient || !currentConfig) { debugLog('⚠️ SERVICE INIT SKIPPED', { renderCount: currentRender, reason: 'Missing apiKey, client, or serviceConfig', }) return } debugLog('🚀 CREATING SERVICE', { renderCount: currentRender, apiKey: apiKey?.substring(0, 8) + '...', }) const newService = new StreamChatService({ ...currentConfig, apiKey, debug: debugRef.current, client: chatClient, // An explicit serviceConfig.logger wins — existing injectors keep working. // Resolved, because the service swaps its console defaults out wholesale. ...(currentConfig.logger || !loggerRef.current ? {} : { logger: resolveLogger(loggerRef.current) }), }) serviceRef.current = newService setService(newService) debugLog('✅ SERVICE SET', { renderCount: currentRender, serviceInstance: !!newService, }) // No disconnect on cleanup here — disconnection is owned by the // client-cleanup effect below, keyed on `chatClient` (apiKey) and unmount. // eslint-disable-next-line react-hooks/exhaustive-deps -- serviceConfig/debug/debugLog are read via refs by design (see above); recreating on them would re-adopt the live client }, [apiKey, chatClient, injectedClient]) // Track if we've already connected this user with this service to prevent duplicate connections const connectedUserRef = useRef<{ serviceId: StreamChatService userId: string } | null>(null) // Connect user when service and user are available useEffect(() => { debugLog('🔗 USER CONNECTION EFFECT TRIGGERED', { hasService: !!service, hasUser: !!user, userId: user?.id, isConnecting: connectingRef.current, isConnected: isConnected, dependencies: { service: !!service, userId: user?.id }, }) // The injected client owns its own connection — never connect a service for // it. Without this guard a `user` change in injected-client mode would run // service.connectUser on a stale apiKey-path service (the toggle case). if (injectedClient) { return } if (!service || !user) { debugLog('⚠️ USER CONNECTION SKIPPED', 'Missing service or user') return } if (connectingRef.current) { debugLog('⚠️ USER CONNECTION SKIPPED', 'Already connecting') return } // Check if we've already connected this exact user with this exact service instance if ( connectedUserRef.current?.serviceId === service && connectedUserRef.current?.userId === user.id ) { debugLog( '⚠️ USER CONNECTION SKIPPED', 'Already connected this user with this service' ) return } const connectUser = async () => { debugLog('🚀 STARTING USER CONNECTION', { userId: user.id }) connectingRef.current = true setIsLoading(true) setError(null) try { debugLog('📞 CALLING SERVICE.CONNECTUSER', { userId: user.id }) const streamClient = await service.connectUser(user) setClient(streamClient) setIsConnected(true) connectedUserRef.current = { serviceId: service, userId: user.id } // Mark as connected debugLog('✅ USER CONNECTION SUCCESS', { userId: user.id, clientId: streamClient.userID, }) } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Connection failed' setError(errorMessage) debugLog('❌ USER CONNECTION ERROR', { userId: user.id, error: errorMessage, }) } finally { setIsLoading(false) connectingRef.current = false debugLog('🔄 USER CONNECTION FINISHED', { userId: user.id, isConnected, }) } } connectUser() }, [service, user, debugLog, isConnected, injectedClient]) // Disconnect tied to the client's lifetime: only when `chatClient` changes // (i.e. `apiKey` changes) or the provider unmounts — never on a mere // serviceConfig/debug change, which reuses the same client. Uses `serviceRef` // so the disconnect goes through the latest service instance. Deliberately // keyed on `chatClient` only: depending on `debugLog` (which changes identity // when the `debug` prop toggles) would tear down the live client just for a // logging change. useEffect(() => { const client = chatClient return () => { if (!client) return connectedUserRef.current = null // Reset connection tracking serviceRef.current?.disconnectUser().catch(console.error) } }, [chatClient]) const refreshConnection = useCallback(async () => { debugLog('🔄 REFRESH CONNECTION CALLED', { hasService: !!service, hasUser: !!user, }) // The injected client owns its own connection lifecycle — refresh is a // no-op. Guarding here (not just the context fields) stops a stale // apiKey-path `service` from being disconnected/reconnected if the same // provider previously connected via apiKey and then received a client. if (injectedClient) { return } if (!service || !user) { debugLog('⚠️ REFRESH CONNECTION SKIPPED', 'Missing service or user') return } debugLog('🚀 STARTING CONNECTION REFRESH', { userId: user.id }) setIsLoading(true) try { debugLog('🔌 DISCONNECTING FOR REFRESH') await service.disconnectUser() debugLog('📞 RECONNECTING FOR REFRESH') const streamClient = await service.connectUser(user) setClient(streamClient) setIsConnected(true) setError(null) debugLog('✅ CONNECTION REFRESH SUCCESS', { userId: user.id }) } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Refresh failed' setError(errorMessage) debugLog('❌ CONNECTION REFRESH ERROR', { userId: user.id, error: errorMessage, }) } finally { setIsLoading(false) debugLog('🔄 CONNECTION REFRESH FINISHED', { userId: user.id }) } }, [service, user, debugLog, injectedClient]) // Memoize context value to prevent unnecessary re-renders const contextValue: MessagingContextValue = React.useMemo(() => { debugLog('💫 CONTEXT VALUE MEMOIZATION', { hasService: !!service, hasClient: !!client, isConnected, isLoading, hasError: !!error, capabilitiesKeys: Object.keys(capabilities), }) return { // With an injected client, expose no service — the injected client owns // its lifecycle and there is no backend to reach. Suppressing it stops // service-dependent paths (channel creation, block/unblock) from calling // a stale apiKey-path service if the same provider previously connected. service: injectedClient ? null : service, // An injected client is already connected and owns its lifecycle, so // surface it directly and report a clean connected state — the apiKey // path's `client`/`isLoading`/`error` (from connectUser) must not leak // through if the same provider previously attempted an apiKey connect. client: injectedClient ?? client, isConnected: injectedClient ? true : isConnected, isLoading: injectedClient ? false : isLoading, error: injectedClient ? null : error, capabilities, refreshConnection, debug, } }, [ service, client, injectedClient, isConnected, isLoading, error, capabilities, refreshConnection, debug, debugLog, ]) debugLog('🔄 RENDER END', { renderCount: renderCountRef.current, willRenderChat: !!chatClient, contextValueReady: !!contextValue, }) return ( {chatClient ? ( {children} ) : ( children )} ) }