@@ -4,6 +4,7 @@ import getPort from "get-port";
44import { SimpleServer } from "../src/server/simple-server" ;
55import { LoroWebsocketClient } from "../src/client" ;
66import { ClientStatus } from "../src/client" ;
7+ import type { LoroWebsocketClientRoom } from "../src/client" ;
78import { createLoroAdaptor } from "loro-adaptors" ;
89
910// Make WebSocket available globally for the client
@@ -308,6 +309,12 @@ describe("E2E: Client-Server Sync", () => {
308309 unsubscribe2 = client2 . onStatusChange ( s => statuses2 . push ( s ) ) ;
309310
310311 await Promise . all ( [ client1 . waitConnected ( ) , client2 . waitConnected ( ) ] ) ;
312+ const initialConnectedCount1 = statuses1 . filter (
313+ s => s === ClientStatus . Connected
314+ ) . length ;
315+ const initialConnectedCount2 = statuses2 . filter (
316+ s => s === ClientStatus . Connected
317+ ) . length ;
311318
312319 const adaptor1 = createLoroAdaptor ( { peerId : 31 } ) ;
313320 const adaptor2 = createLoroAdaptor ( { peerId : 32 } ) ;
@@ -350,6 +357,16 @@ describe("E2E: Client-Server Sync", () => {
350357 50
351358 ) ;
352359
360+ await waitUntil (
361+ ( ) =>
362+ statuses1 . filter ( s => s === ClientStatus . Connected ) . length >
363+ initialConnectedCount1 &&
364+ statuses2 . filter ( s => s === ClientStatus . Connected ) . length >
365+ initialConnectedCount2 ,
366+ 5000 ,
367+ 25
368+ ) ;
369+
353370 await waitUntil ( ( ) => text2 . toString ( ) === expected , 5000 , 50 ) ;
354371
355372 await Promise . all ( [ room1 . destroy ( ) , room2 . destroy ( ) ] ) ;
@@ -362,6 +379,96 @@ describe("E2E: Client-Server Sync", () => {
362379 }
363380 } , 20000 ) ;
364381
382+ it ( "reconnects even when the online event never fires" , async ( ) => {
383+ const env = installMockWindow ( ) ;
384+ let client1 : LoroWebsocketClient | undefined ;
385+ let client2 : LoroWebsocketClient | undefined ;
386+ let unsubscribe1 : ( ( ) => void ) | undefined ;
387+ let unsubscribe2 : ( ( ) => void ) | undefined ;
388+ let room1 : LoroWebsocketClientRoom | undefined ;
389+ let room2 : LoroWebsocketClientRoom | undefined ;
390+ try {
391+ client1 = new LoroWebsocketClient ( { url : `ws://localhost:${ port } ` } ) ;
392+ client2 = new LoroWebsocketClient ( { url : `ws://localhost:${ port } ` } ) ;
393+
394+ const statuses1 : string [ ] = [ ] ;
395+ const statuses2 : string [ ] = [ ] ;
396+ unsubscribe1 = client1 . onStatusChange ( s => statuses1 . push ( s ) ) ;
397+ unsubscribe2 = client2 . onStatusChange ( s => statuses2 . push ( s ) ) ;
398+
399+ await Promise . all ( [ client1 . waitConnected ( ) , client2 . waitConnected ( ) ] ) ;
400+
401+ const initialConnectedCount1 = statuses1 . filter (
402+ s => s === ClientStatus . Connected
403+ ) . length ;
404+ const initialConnectedCount2 = statuses2 . filter (
405+ s => s === ClientStatus . Connected
406+ ) . length ;
407+
408+ const adaptor1 = createLoroAdaptor ( { peerId : 41 } ) ;
409+ const adaptor2 = createLoroAdaptor ( { peerId : 42 } ) ;
410+
411+ [ room1 , room2 ] = await Promise . all ( [
412+ client1 . join ( { roomId : "offline-no-online" , crdtAdaptor : adaptor1 } ) ,
413+ client2 . join ( { roomId : "offline-no-online" , crdtAdaptor : adaptor2 } ) ,
414+ ] ) ;
415+
416+ const text1 = adaptor1 . getDoc ( ) . getText ( "shared" ) ;
417+ const text2 = adaptor2 . getDoc ( ) . getText ( "shared" ) ;
418+
419+ text1 . insert ( 0 , "seed" ) ;
420+ adaptor1 . getDoc ( ) . commit ( ) ;
421+ await waitUntil ( ( ) => text2 . toString ( ) === "seed" , 3000 , 50 ) ;
422+
423+ env . goOffline ( ) ;
424+ await waitUntil (
425+ ( ) =>
426+ client1 ! . getStatus ( ) === ClientStatus . Disconnected &&
427+ client2 ! . getStatus ( ) === ClientStatus . Disconnected ,
428+ 5000 ,
429+ 25
430+ ) ;
431+ await server . stop ( ) ;
432+
433+ expect ( ( navigator as { onLine ?: boolean } ) . onLine ) . toBe ( false ) ;
434+
435+ // No env.goOnline() here – navigator stays offline
436+ await new Promise ( resolve => setTimeout ( resolve , 200 ) ) ;
437+ await server . start ( ) ;
438+
439+ await waitUntil (
440+ ( ) =>
441+ client1 ! . getStatus ( ) === ClientStatus . Connected &&
442+ client2 ! . getStatus ( ) === ClientStatus . Connected ,
443+ 10000 ,
444+ 50
445+ ) ;
446+
447+ expect ( ( navigator as { onLine ?: boolean } ) . onLine ) . toBe ( false ) ;
448+
449+ await waitUntil (
450+ ( ) =>
451+ statuses1 . filter ( s => s === ClientStatus . Connected ) . length >
452+ initialConnectedCount1 &&
453+ statuses2 . filter ( s => s === ClientStatus . Connected ) . length >
454+ initialConnectedCount2 ,
455+ 5000 ,
456+ 25
457+ ) ;
458+
459+ text1 . insert ( text1 . length , " rebound" ) ;
460+ adaptor1 . getDoc ( ) . commit ( ) ;
461+ await waitUntil ( ( ) => text2 . toString ( ) === "seed rebound" , 5000 , 50 ) ;
462+ } finally {
463+ unsubscribe1 ?.( ) ;
464+ unsubscribe2 ?.( ) ;
465+ await Promise . all ( [ room1 ?. destroy ( ) , room2 ?. destroy ( ) ] ) ;
466+ client1 ?. destroy ( ) ;
467+ client2 ?. destroy ( ) ;
468+ env . restore ( ) ;
469+ }
470+ } , 20000 ) ;
471+
365472 it ( "destroy rejects pending ping waiters" , async ( ) => {
366473 const client = new LoroWebsocketClient ( { url : `ws://localhost:${ port } ` } ) ;
367474 await client . waitConnected ( ) ;
0 commit comments