Skip to content

Commit a411e58

Browse files
authored
Merge pull request #230 from refactor-group/201-broken-collaboration-cursors
Fix collaboration cursor bubbles not appearing in coaching notes
2 parents 5795d44 + 1c4d370 commit a411e58

File tree

3 files changed

+450
-59
lines changed

3 files changed

+450
-59
lines changed

__tests__/components/ui/coaching-sessions/editor-cache-context.test.tsx

Lines changed: 347 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)