11import { ConvexProvider , ConvexReactClient } from "convex/react" ;
2- import { ConvexHttpClient } from "convex/browser" ;
2+ import { ConnectionState , ConvexHttpClient } from "convex/browser" ;
33import {
44 createContext ,
55 ReactNode ,
6+ useCallback ,
67 useContext ,
78 useEffect ,
89 useLayoutEffect ,
@@ -24,6 +25,12 @@ export type DeploymentInfo = (
2425 }
2526 | { ok : false ; errorCode : string ; errorMessage : string }
2627) & {
28+ addBreadcrumb : ( breadcrumb : {
29+ message ?: string ;
30+ data ?: {
31+ [ key : string ] : any ;
32+ } ;
33+ } ) => void ;
2734 captureMessage : ( msg : string ) => void ;
2835 captureException : ( e : any ) => void ;
2936 reportHttpError : (
@@ -338,36 +345,61 @@ export function WaitForDeploymentApi({
338345 ) ;
339346}
340347
348+ const CONNECTION_STATE_CHECK_INTERVAL_MS = 2500 ;
349+
341350function DeploymentWithConnectionState ( {
342351 deployment,
343352 children,
344353} : {
345354 deployment : ConnectedDeployment ;
346355 children : ReactNode ;
347356} ) {
348- const { isSelfHosted, captureMessage } = useContext ( DeploymentInfoContext ) ;
357+ const { isSelfHosted, captureMessage, addBreadcrumb } = useContext (
358+ DeploymentInfoContext ,
359+ ) ;
349360 const { client, deploymentUrl, deploymentName } = deployment ;
350- const [ connectionState , setConnectionState ] = useState <
351- "Connected" | "Disconnected" | "LocalDeploymentMismatch" | null
352- > ( null ) ;
353- const [ isDisconnected , setIsDisconnected ] = useState ( false ) ;
354- useEffect ( ( ) => {
355- const checkConnection = setInterval ( async ( ) => {
356- if ( connectionState === "LocalDeploymentMismatch" ) {
357- // Connection status doesn't matter since we're connected to the wrong deployment
358- return ;
359- }
361+ const [ lastObservedConnectionState , setLastObservedConnectionState ] =
362+ useState <
363+ | {
364+ state : ConnectionState ;
365+ time : Date ;
366+ }
367+ | "LocalDeploymentMismatch"
368+ | null
369+ > ( null ) ;
370+ const [ isDisconnected , setIsDisconnected ] = useState < boolean | null > ( null ) ;
360371
361- // Check WS connection status -- if we're disconnected twice in a row, treat
362- // the deployment as disconnected.
363- const nextConnectionState = client . connectionState ( ) ;
372+ const handleConnectionStateChange = useCallback (
373+ async (
374+ state : ConnectionState ,
375+ previousState : {
376+ time : Date ;
377+ state : ConnectionState ;
378+ } | null ,
379+ ) : Promise <
380+ "Unknown" | "Disconnected" | "Connected" | "LocalDeploymentMismatch"
381+ > => {
382+ if ( previousState === null ) {
383+ return "Unknown" ;
384+ }
364385 if (
365- nextConnectionState . isWebSocketConnected === false &&
366- connectionState === "Disconnected"
386+ previousState . time . getTime ( ) <
387+ Date . now ( ) - CONNECTION_STATE_CHECK_INTERVAL_MS * 2
367388 ) {
368- setIsDisconnected ( true ) ;
389+ // If the previous state was observed a while ago, consider it stale (maybe the tab
390+ // got backgrounded).
391+ return "Unknown" ;
369392 }
370- if ( nextConnectionState . isWebSocketConnected === true ) {
393+
394+ if ( state . isWebSocketConnected === false ) {
395+ if ( previousState . state . isWebSocketConnected === false ) {
396+ // we've been in state `Disconnected` twice in a row, consider the deployment
397+ // to be disconnected.
398+ return "Disconnected" ;
399+ }
400+ return "Unknown" ;
401+ }
402+ if ( state . isWebSocketConnected === true ) {
371403 // If this is a local deployment, check that the instance name matches what we expect.
372404 if ( deploymentName . startsWith ( "local-" ) ) {
373405 let instanceNameResp : Response | null = null ;
@@ -381,30 +413,96 @@ function DeploymentWithConnectionState({
381413 if ( instanceNameResp !== null && instanceNameResp . ok ) {
382414 const instanceName = await instanceNameResp . text ( ) ;
383415 if ( instanceName !== deploymentName ) {
384- setConnectionState ( "LocalDeploymentMismatch" ) ;
385- setIsDisconnected ( true ) ;
386- return ;
416+ return "LocalDeploymentMismatch" ;
387417 }
388418 }
389419 }
390- setIsDisconnected ( false ) ;
420+ return "Connected" ;
421+ }
422+ return "Unknown" ;
423+ } ,
424+ [ deploymentName , deploymentUrl ] ,
425+ ) ;
426+
427+ useEffect ( ( ) => {
428+ // Poll `.connectionState()` every 5 seconds. If we're disconnected twice in a row,
429+ // consider the deployment to be disconnected.
430+ const checkConnection = setInterval ( async ( ) => {
431+ if ( lastObservedConnectionState === "LocalDeploymentMismatch" ) {
432+ // Connection status doesn't matter since we're connected to the wrong deployment
433+ return ;
391434 }
392- setConnectionState (
393- nextConnectionState . isWebSocketConnected ? "Connected" : "Disconnected" ,
435+ // Check WS connection status -- if we're disconnected twice in a row, treat
436+ // the deployment as disconnected.
437+ const nextConnectionState = client . connectionState ( ) ;
438+ const isLocalDeployment = deploymentName . startsWith ( "local-" ) ;
439+ const result = await handleConnectionStateChange (
440+ nextConnectionState ,
441+ lastObservedConnectionState ,
394442 ) ;
395- } , 2500 ) ;
443+ setLastObservedConnectionState ( {
444+ state : nextConnectionState ,
445+ time : new Date ( ) ,
446+ } ) ;
447+ switch ( result ) {
448+ case "Disconnected" :
449+ // If this is first time transitioning to disconnected, log to sentry that we've disconnected
450+ if ( isDisconnected !== true ) {
451+ if ( ! isLocalDeployment ) {
452+ addBreadcrumb ( {
453+ message : `Cloud deployment disconnected: ${ deploymentName } ` ,
454+ data : {
455+ hasEverConnected : nextConnectionState . hasEverConnected ,
456+ connectionCount : nextConnectionState . connectionCount ,
457+ connectionRetries : nextConnectionState . connectionRetries ,
458+ } ,
459+ } ) ;
460+ // Log to sentry including the instance name when we seem to be unable to connect to a cloud deployment
461+ captureMessage ( `Cloud deployment is disconnected` ) ;
462+ }
463+ }
464+ setIsDisconnected ( true ) ;
465+ break ;
466+ case "LocalDeploymentMismatch" :
467+ setLastObservedConnectionState ( "LocalDeploymentMismatch" ) ;
468+ break ;
469+ case "Unknown" :
470+ setIsDisconnected ( null ) ;
471+ break ;
472+ case "Connected" :
473+ // If transitioning from disconnected to connected, log to sentry that we've reconnected
474+ if ( isDisconnected === true ) {
475+ if ( ! isLocalDeployment ) {
476+ addBreadcrumb ( {
477+ message : `Cloud deployment reconnected: ${ deploymentName } ` ,
478+ } ) ;
479+ // Log to sentry including the instance name when we seem to be unable to connect to a cloud deployment
480+ captureMessage ( `Cloud deployment has reconnected` ) ;
481+ }
482+ }
483+ setIsDisconnected ( false ) ;
484+ break ;
485+ default : {
486+ const _exhaustiveCheck : never = result ;
487+ throw new Error ( `Unknown connection state: ${ result } ` ) ;
488+ }
489+ }
490+ } , CONNECTION_STATE_CHECK_INTERVAL_MS ) ;
396491 return ( ) => clearInterval ( checkConnection ) ;
397- } ) ;
398- useEffect ( ( ) => {
399- if ( isDisconnected && ! deploymentName . startsWith ( "local-" ) ) {
400- // Log to sentry including the instance name when we seem to be unable to connect to a cloud deployment
401- captureMessage ( `Cloud deployment is disconnected: ${ deploymentName } ` ) ;
402- }
403- } , [ isDisconnected , deploymentName , captureMessage ] ) ;
492+ } , [
493+ lastObservedConnectionState ,
494+ deploymentName ,
495+ deploymentUrl ,
496+ client ,
497+ addBreadcrumb ,
498+ captureMessage ,
499+ handleConnectionStateChange ,
500+ isDisconnected ,
501+ ] ) ;
404502 const value = useMemo (
405503 ( ) => ( {
406504 deployment,
407- isDisconnected,
505+ isDisconnected : isDisconnected === true ,
408506 } ) ,
409507 [ deployment , isDisconnected ] ,
410508 ) ;
0 commit comments