@@ -169,7 +169,7 @@ describe('EditorCacheProvider', () => {
169169 // THE CRITICAL TEST: Logout cleanup
170170 it ( 'should destroy TipTap provider when user logs out' , async ( ) => {
171171 let cacheRef : any = null
172-
172+
173173 // Start with logged in user
174174 render (
175175 < EditorCacheProvider sessionId = "test-session" >
@@ -198,4 +198,350 @@ describe('EditorCacheProvider', () => {
198198 // After reset, the cache should be in loading state (not ready)
199199 expect ( screen . getByTestId ( 'is-ready' ) ) . toHaveTextContent ( 'no' )
200200 } )
201+
202+ describe ( 'Provider Lifecycle - Preventing Unnecessary Disconnections' , ( ) => {
203+ it ( 'should NOT disconnect provider when re-rendering with same sessionId' , async ( ) => {
204+ const { TiptapCollabProvider } = await import ( '@hocuspocus/provider' )
205+
206+ const { rerender } = render (
207+ < EditorCacheProvider sessionId = "test-session" >
208+ < TestConsumer />
209+ </ EditorCacheProvider >
210+ )
211+
212+ await waitFor ( ( ) => {
213+ expect ( screen . getByTestId ( 'is-ready' ) ) . toHaveTextContent ( 'yes' )
214+ } )
215+
216+ // Get the mock instance and clear previous calls
217+ const mockProvider = vi . mocked ( TiptapCollabProvider ) . mock . results [ 0 ] ?. value
218+ vi . clearAllMocks ( )
219+
220+ // Re-render the same component (simulates user clicking in editor)
221+ rerender (
222+ < EditorCacheProvider sessionId = "test-session" >
223+ < TestConsumer />
224+ </ EditorCacheProvider >
225+ )
226+
227+ // Wait a bit to ensure no cleanup happens
228+ await new Promise ( resolve => setTimeout ( resolve , 50 ) )
229+
230+ // CRITICAL: Provider should NOT be disconnected
231+ expect ( mockProvider ?. disconnect ) . not . toHaveBeenCalled ( )
232+
233+ // CRITICAL: Provider should NOT be recreated
234+ expect ( TiptapCollabProvider ) . not . toHaveBeenCalled ( )
235+ } )
236+
237+ it ( 'should disconnect old provider when sessionId changes' , async ( ) => {
238+ const { TiptapCollabProvider } = await import ( '@hocuspocus/provider' )
239+
240+ const { rerender } = render (
241+ < EditorCacheProvider sessionId = "session-1" >
242+ < TestConsumer />
243+ </ EditorCacheProvider >
244+ )
245+
246+ await waitFor ( ( ) => {
247+ expect ( screen . getByTestId ( 'is-ready' ) ) . toHaveTextContent ( 'yes' )
248+ } )
249+
250+ const oldProvider = vi . mocked ( TiptapCollabProvider ) . mock . results [ 0 ] ?. value
251+
252+ // Change session ID
253+ rerender (
254+ < EditorCacheProvider sessionId = "session-2" >
255+ < TestConsumer />
256+ </ EditorCacheProvider >
257+ )
258+
259+ await waitFor ( ( ) => {
260+ // Old provider should be disconnected
261+ expect ( oldProvider ?. disconnect ) . toHaveBeenCalled ( )
262+ } )
263+
264+ // New provider should be created
265+ expect ( TiptapCollabProvider ) . toHaveBeenCalledTimes ( 2 )
266+ } )
267+ } )
268+
269+ describe ( 'Extension Creation' , ( ) => {
270+ it ( 'should create extensions only once even if synced event fires multiple times' , async ( ) => {
271+ const { Extensions } = await import ( '@/components/ui/coaching-sessions/coaching-notes/extensions' )
272+
273+ // Create a mock provider that we can control
274+ const { TiptapCollabProvider } = await import ( '@hocuspocus/provider' )
275+ let syncedCallback : ( ( ) => void ) | undefined
276+
277+ vi . mocked ( TiptapCollabProvider ) . mockImplementationOnce ( function ( ) {
278+ const provider = {
279+ on : vi . fn ( ( event , callback ) => {
280+ if ( event === 'synced' ) {
281+ syncedCallback = callback
282+ }
283+ return provider
284+ } ) ,
285+ off : vi . fn ( ) ,
286+ setAwarenessField : vi . fn ( ) ,
287+ destroy : vi . fn ( ) ,
288+ disconnect : vi . fn ( ) ,
289+ connect : vi . fn ( )
290+ }
291+ return provider as any
292+ } )
293+
294+ render (
295+ < EditorCacheProvider sessionId = "test-session" >
296+ < TestConsumer />
297+ </ EditorCacheProvider >
298+ )
299+
300+ // Wait for provider initialization
301+ await waitFor ( ( ) => {
302+ expect ( syncedCallback ) . toBeDefined ( )
303+ } )
304+
305+ // Trigger synced event multiple times
306+ act ( ( ) => {
307+ syncedCallback ! ( )
308+ syncedCallback ! ( )
309+ syncedCallback ! ( )
310+ } )
311+
312+ // Extensions should only be created once
313+ expect ( Extensions ) . toHaveBeenCalledTimes ( 1 )
314+ } )
315+ } )
316+
317+ describe ( 'Awareness State Management' , ( ) => {
318+ it ( 'should use setAwarenessField for setting user presence' , async ( ) => {
319+ const { TiptapCollabProvider } = await import ( '@hocuspocus/provider' )
320+
321+ render (
322+ < EditorCacheProvider sessionId = "test-session" >
323+ < TestConsumer />
324+ </ EditorCacheProvider >
325+ )
326+
327+ await waitFor ( ( ) => {
328+ expect ( screen . getByTestId ( 'is-ready' ) ) . toHaveTextContent ( 'yes' )
329+ } )
330+
331+ const mockProvider = vi . mocked ( TiptapCollabProvider ) . mock . results [ 0 ] ?. value
332+
333+ // Should have called setAwarenessField with presence data
334+ expect ( mockProvider ?. setAwarenessField ) . toHaveBeenCalledWith (
335+ 'presence' ,
336+ expect . objectContaining ( {
337+ userId : 'user-1' ,
338+ name : 'Test User' ,
339+ status : 'connected'
340+ } )
341+ )
342+ } )
343+
344+ it ( 'should update presence state when awarenessChange event fires' , async ( ) => {
345+ const { TiptapCollabProvider } = await import ( '@hocuspocus/provider' )
346+ let awarenessCallback : ( ( data : any ) => void ) | undefined
347+
348+ vi . mocked ( TiptapCollabProvider ) . mockImplementationOnce ( function ( ) {
349+ const provider = {
350+ on : vi . fn ( ( event , callback ) => {
351+ if ( event === 'awarenessChange' ) {
352+ awarenessCallback = callback
353+ }
354+ return provider
355+ } ) ,
356+ off : vi . fn ( ) ,
357+ setAwarenessField : vi . fn ( ) ,
358+ destroy : vi . fn ( ) ,
359+ disconnect : vi . fn ( ) ,
360+ connect : vi . fn ( )
361+ }
362+ return provider as any
363+ } )
364+
365+ let cacheRef : any = null
366+
367+ render (
368+ < EditorCacheProvider sessionId = "test-session" >
369+ < TestConsumer onCacheReady = { ( cache ) => { cacheRef = cache } } />
370+ </ EditorCacheProvider >
371+ )
372+
373+ await waitFor ( ( ) => {
374+ expect ( awarenessCallback ) . toBeDefined ( )
375+ } )
376+
377+ // Simulate awareness change with user data
378+ act ( ( ) => {
379+ awarenessCallback ! ( {
380+ states : [
381+ {
382+ clientId : 1 ,
383+ presence : {
384+ userId : 'user-1' ,
385+ name : 'Test User' ,
386+ relationshipRole : 'Coach' ,
387+ color : '#ff0000' ,
388+ status : 'connected'
389+ }
390+ } ,
391+ {
392+ clientId : 2 ,
393+ presence : {
394+ userId : 'user-2' ,
395+ name : 'Other User' ,
396+ relationshipRole : 'Coachee' ,
397+ color : '#00ff00' ,
398+ status : 'connected'
399+ }
400+ }
401+ ]
402+ } )
403+ } )
404+
405+ // Presence state should be updated
406+ expect ( cacheRef ?. presenceState . users . size ) . toBe ( 2 )
407+ expect ( cacheRef ?. presenceState . users . get ( 'user-1' ) ) . toMatchObject ( {
408+ userId : 'user-1' ,
409+ name : 'Test User'
410+ } )
411+ } )
412+
413+ it ( 'should NOT call setAwarenessField on disconnect event (already offline)' , async ( ) => {
414+ const { TiptapCollabProvider } = await import ( '@hocuspocus/provider' )
415+ let disconnectCallback : ( ( ) => void ) | undefined
416+
417+ vi . mocked ( TiptapCollabProvider ) . mockImplementationOnce ( function ( ) {
418+ const provider = {
419+ on : vi . fn ( ( event , callback ) => {
420+ if ( event === 'disconnect' ) {
421+ disconnectCallback = callback
422+ }
423+ return provider
424+ } ) ,
425+ off : vi . fn ( ) ,
426+ setAwarenessField : vi . fn ( ) ,
427+ destroy : vi . fn ( ) ,
428+ disconnect : vi . fn ( ) ,
429+ connect : vi . fn ( )
430+ }
431+ return provider as any
432+ } )
433+
434+ render (
435+ < EditorCacheProvider sessionId = "test-session" >
436+ < TestConsumer />
437+ </ EditorCacheProvider >
438+ )
439+
440+ await waitFor ( ( ) => {
441+ expect ( disconnectCallback ) . toBeDefined ( )
442+ } )
443+
444+ const mockProvider = vi . mocked ( TiptapCollabProvider ) . mock . results [ 0 ] ?. value
445+ vi . clearAllMocks ( )
446+
447+ // Trigger disconnect event
448+ act ( ( ) => {
449+ disconnectCallback ! ( )
450+ } )
451+
452+ // Should NOT call setAwarenessField because we're already disconnected
453+ // The awareness protocol will handle removing stale clients via timeout
454+ expect ( mockProvider ?. setAwarenessField ) . not . toHaveBeenCalled ( )
455+ } )
456+
457+ it ( 'should mark users as disconnected when they disappear from awareness states' , async ( ) => {
458+ const { TiptapCollabProvider } = await import ( '@hocuspocus/provider' )
459+ let awarenessCallback : ( ( data : any ) => void ) | undefined
460+
461+ vi . mocked ( TiptapCollabProvider ) . mockImplementationOnce ( function ( ) {
462+ const provider = {
463+ on : vi . fn ( ( event , callback ) => {
464+ if ( event === 'awarenessChange' ) {
465+ awarenessCallback = callback
466+ }
467+ return provider
468+ } ) ,
469+ off : vi . fn ( ) ,
470+ setAwarenessField : vi . fn ( ) ,
471+ destroy : vi . fn ( ) ,
472+ disconnect : vi . fn ( ) ,
473+ connect : vi . fn ( )
474+ }
475+ return provider as any
476+ } )
477+
478+ let cacheRef : any = null
479+
480+ render (
481+ < EditorCacheProvider sessionId = "test-session" >
482+ < TestConsumer onCacheReady = { ( cache ) => { cacheRef = cache } } />
483+ </ EditorCacheProvider >
484+ )
485+
486+ await waitFor ( ( ) => {
487+ expect ( awarenessCallback ) . toBeDefined ( )
488+ } )
489+
490+ // First, simulate both users being connected
491+ act ( ( ) => {
492+ awarenessCallback ! ( {
493+ states : [
494+ {
495+ clientId : 1 ,
496+ presence : {
497+ userId : 'user-1' ,
498+ name : 'Test User' ,
499+ relationshipRole : 'Coach' ,
500+ color : '#ff0000' ,
501+ isConnected : true
502+ }
503+ } ,
504+ {
505+ clientId : 2 ,
506+ presence : {
507+ userId : 'user-2' ,
508+ name : 'Other User' ,
509+ relationshipRole : 'Coachee' ,
510+ color : '#00ff00' ,
511+ isConnected : true
512+ }
513+ }
514+ ]
515+ } )
516+ } )
517+
518+ // Verify both users are connected
519+ expect ( cacheRef ?. presenceState . users . size ) . toBe ( 2 )
520+ expect ( cacheRef ?. presenceState . users . get ( 'user-2' ) ?. status ) . toBe ( 'connected' )
521+
522+ // Now simulate user-2 disappearing (network disconnect)
523+ act ( ( ) => {
524+ awarenessCallback ! ( {
525+ states : [
526+ {
527+ clientId : 1 ,
528+ presence : {
529+ userId : 'user-1' ,
530+ name : 'Test User' ,
531+ relationshipRole : 'Coach' ,
532+ color : '#ff0000' ,
533+ isConnected : true
534+ }
535+ }
536+ // user-2 is no longer in the states array
537+ ]
538+ } )
539+ } )
540+
541+ // User-2 should still be in the map but marked as disconnected
542+ expect ( cacheRef ?. presenceState . users . size ) . toBe ( 2 )
543+ expect ( cacheRef ?. presenceState . users . get ( 'user-2' ) ?. status ) . toBe ( 'disconnected' )
544+ expect ( cacheRef ?. presenceState . users . get ( 'user-1' ) ?. status ) . toBe ( 'connected' )
545+ } )
546+ } )
201547} )
0 commit comments