@tuturuuu/ui 0.37.1 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +267 -0
  4. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
  5. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  6. package/src/components/ui/custom/education-todo.tsx +191 -0
  7. package/src/components/ui/custom/hidden-workspaces-settings.tsx +49 -0
  8. package/src/components/ui/custom/mobile-header-height.ts +16 -0
  9. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  10. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  11. package/src/components/ui/custom/structure.tsx +37 -5
  12. package/src/components/ui/custom/user-filters.tsx +2 -1
  13. package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
  14. package/src/components/ui/custom/workspace-picker-content.tsx +356 -0
  15. package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
  16. package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
  17. package/src/components/ui/custom/workspace-select.tsx +95 -359
  18. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  19. package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
  20. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  21. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  22. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  23. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  24. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  25. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  26. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  27. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  28. package/src/components/ui/legacy/calendar/settings/calendar-sync-dashboard.tsx +4 -23
  29. package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
  30. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  31. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  32. package/src/components/ui/popover.tsx +3 -1
  33. package/src/components/ui/resizable.tsx +2 -1
  34. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  35. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  36. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  37. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  38. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  39. package/src/components/ui/text-editor/editor.tsx +8 -3
  40. package/src/components/ui/text-editor/extensions.ts +8 -2
  41. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  42. package/src/components/ui/text-editor/markdown.ts +11 -0
  43. package/src/components/ui/text-editor/tool-bar.tsx +43 -44
  44. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  45. package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
  46. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
  47. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  48. package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
  49. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  50. package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
  51. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
  52. package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -0
  53. package/src/hooks/calendar-creation-request.test.ts +62 -0
  54. package/src/hooks/calendar-creation-request.ts +34 -0
  55. package/src/hooks/calendar-event-write-payload.ts +24 -0
  56. package/src/hooks/notification-subscription.ts +5 -2
  57. package/src/hooks/supabase-provider.ts +38 -72
  58. package/src/hooks/use-calendar.tsx +21 -45
  59. package/src/hooks/use-exchange-rates.ts +4 -15
  60. package/src/hooks/use-visible-workspaces.ts +57 -0
  61. package/src/hooks/use-workspace-presence.ts +9 -9
  62. package/src/hooks/use-workspace-user.ts +15 -3
  63. package/src/hooks/use-workspace-visibility.ts +234 -0
  64. package/src/hooks/use-yjs-collaboration.ts +45 -27
@@ -1,8 +1,8 @@
1
1
  'use client';
2
2
 
3
+ import type { RealtimePresenceState } from '@tuturuuu/internal-api/realtime';
4
+ import { createRealtimeClient } from '@tuturuuu/internal-api/realtime';
3
5
  import { getCurrentUserProfile } from '@tuturuuu/internal-api/users';
4
- import { createClient } from '@tuturuuu/supabase/next/client';
5
- import type { RealtimePresenceState } from '@tuturuuu/supabase/next/realtime';
6
6
  import type { User } from '@tuturuuu/types/primitives/User';
7
7
  import { DEV_MODE } from '@tuturuuu/utils/constants';
8
8
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -149,14 +149,14 @@ export function useWorkspacePresence({
149
149
  if (setupPromiseRef.current) return setupPromiseRef.current;
150
150
 
151
151
  const promise = (async (): Promise<boolean> => {
152
- const supabase = createClient();
152
+ const realtime = createRealtimeClient();
153
153
 
154
154
  try {
155
155
  if (isCleanedUpRef.current) return false;
156
156
 
157
157
  const {
158
158
  data: { user },
159
- } = await supabase.auth.getUser();
159
+ } = await realtime.auth.getUser();
160
160
 
161
161
  if (!user?.id || isCleanedUpRef.current) return false;
162
162
 
@@ -184,12 +184,12 @@ export function useWorkspacePresence({
184
184
 
185
185
  // Clean up any stale channel
186
186
  if (channelRef.current) {
187
- await supabase.removeChannel(channelRef.current);
187
+ await realtime.removeChannel(channelRef.current);
188
188
  channelRef.current = null;
189
189
  lastTrackSignatureRef.current = null;
190
190
  }
191
191
 
192
- const channel = supabase.channel(channelKey, {
192
+ const channel = realtime.channel(channelKey, {
193
193
  config: {
194
194
  presence: {
195
195
  key: user.id,
@@ -238,7 +238,7 @@ export function useWorkspacePresence({
238
238
  setupPromiseRef.current = null;
239
239
  lastTrackSignatureRef.current = null;
240
240
  if (deadCh) {
241
- supabase.removeChannel(deadCh).catch(() => {});
241
+ realtime.removeChannel(deadCh).catch(() => {});
242
242
  }
243
243
  if (
244
244
  retryCountRef.current < MAX_RETRIES &&
@@ -269,7 +269,7 @@ export function useWorkspacePresence({
269
269
  setupPromiseRef.current = null;
270
270
  lastTrackSignatureRef.current = null;
271
271
  if (deadCh) {
272
- supabase.removeChannel(deadCh).catch(() => {});
272
+ realtime.removeChannel(deadCh).catch(() => {});
273
273
  }
274
274
  // Attempt reconnection
275
275
  if (retryCountRef.current < MAX_RETRIES) {
@@ -320,7 +320,7 @@ export function useWorkspacePresence({
320
320
  setupPromiseRef.current = null;
321
321
  if (channelRef.current) {
322
322
  channelRef.current.untrack?.().catch(() => {});
323
- createClient().removeChannel(channelRef.current);
323
+ createRealtimeClient().removeChannel(channelRef.current);
324
324
  channelRef.current = null;
325
325
  lastTrackSignatureRef.current = null;
326
326
  }
@@ -3,6 +3,7 @@
3
3
  import { useQuery } from '@tanstack/react-query';
4
4
  import { getCurrentUserProfile } from '@tuturuuu/internal-api/users';
5
5
  import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
6
+ import { useWorkspaceActor } from './use-workspace-visibility';
6
7
 
7
8
  /**
8
9
  * Hook to fetch the current workspace user with their private details
@@ -10,11 +11,18 @@ import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
10
11
  *
11
12
  * @returns Query object with user data, loading state, and error info
12
13
  */
13
- export function useWorkspaceUser() {
14
- return useQuery({
15
- queryKey: ['workspace-user'],
14
+ export function useWorkspaceUser(actorId?: string) {
15
+ const actor = useWorkspaceActor();
16
+ const resolvedActorId = actorId ?? actor?.actorId;
17
+ const query = useQuery({
18
+ queryKey: ['workspace-user', resolvedActorId],
19
+ enabled: Boolean(actor) && actor?.actorId === resolvedActorId,
16
20
  queryFn: async (): Promise<WorkspaceUser> => {
21
+ actor!.assertActive();
17
22
  const data = await getCurrentUserProfile();
23
+ actor!.assertActive();
24
+ if (data.id !== resolvedActorId)
25
+ throw new Error('Workspace account changed');
18
26
  return {
19
27
  id: data.id,
20
28
  email: data.email,
@@ -30,4 +38,8 @@ export function useWorkspaceUser() {
30
38
  gcTime: 10 * 60 * 1000, // Keep cached data for 10 minutes
31
39
  retry: 1, // Retry once on failure
32
40
  });
41
+ return {
42
+ ...query,
43
+ data: actor?.actorId === resolvedActorId ? query.data : undefined,
44
+ };
33
45
  }
@@ -0,0 +1,234 @@
1
+ 'use client';
2
+
3
+ import {
4
+ type QueryClient,
5
+ useQuery,
6
+ useQueryClient,
7
+ } from '@tanstack/react-query';
8
+ import { InternalApiError } from '@tuturuuu/internal-api/client';
9
+ import {
10
+ getCurrentUserHiddenWorkspaces,
11
+ updateCurrentUserHiddenWorkspace,
12
+ } from '@tuturuuu/internal-api/users';
13
+ import {
14
+ createContext,
15
+ createElement,
16
+ type ReactNode,
17
+ useContext,
18
+ useEffect,
19
+ useMemo,
20
+ useRef,
21
+ useState,
22
+ } from 'react';
23
+
24
+ const controls = new WeakMap<
25
+ QueryClient,
26
+ Map<string, { revision: number; pending: Set<string> }>
27
+ >();
28
+ function ownerControl(client: QueryClient, actorId: string) {
29
+ let actors = controls.get(client);
30
+ if (!actors) {
31
+ actors = new Map();
32
+ controls.set(client, actors);
33
+ }
34
+ let control = actors.get(actorId);
35
+ if (!control) {
36
+ control = { revision: 0, pending: new Set() };
37
+ actors.set(actorId, control);
38
+ }
39
+ return control;
40
+ }
41
+
42
+ type ActorScope = {
43
+ actorId: string;
44
+ lifetime: { active: boolean };
45
+ assertActive: () => void;
46
+ };
47
+ const ActorContext = createContext<ActorScope | null>(null);
48
+
49
+ /** Feed only a server-verified actor; preferences never become membership data. */
50
+ export function WorkspaceVisibilityProvider({
51
+ actorId,
52
+ children,
53
+ }: {
54
+ actorId: string;
55
+ children: ReactNode;
56
+ }) {
57
+ const parent = useContext(ActorContext);
58
+ const current = useRef(actorId);
59
+ current.current = actorId;
60
+ const scope = useMemo(() => {
61
+ if (parent?.actorId === actorId) return parent;
62
+ const lifetime = { active: true };
63
+ return {
64
+ actorId,
65
+ lifetime,
66
+ assertActive() {
67
+ parent?.assertActive();
68
+ if (
69
+ !lifetime.active ||
70
+ current.current !== actorId ||
71
+ (parent && parent.actorId !== actorId)
72
+ ) {
73
+ throw new Error('Workspace account changed');
74
+ }
75
+ },
76
+ };
77
+ }, [actorId, parent]);
78
+ const client = useQueryClient();
79
+ useEffect(() => {
80
+ if (scope === parent) return;
81
+ scope.lifetime.active = true;
82
+ return () => {
83
+ scope.lifetime.active = false;
84
+ controls.get(client)?.delete(scope.actorId);
85
+ // A private cache is never retained across logout/account replacement.
86
+ client.removeQueries({ queryKey: ['workspace-hidden', scope.actorId] });
87
+ client.removeQueries({ queryKey: ['workspace-ui-list', scope.actorId] });
88
+ client.removeQueries({ queryKey: ['user-workspaces', scope.actorId] });
89
+ client.removeQueries({ queryKey: ['all-user-boards', scope.actorId] });
90
+ client.removeQueries({ queryKey: ['chat-workspaces', scope.actorId] });
91
+ client.removeQueries({ queryKey: ['task-source-boards', scope.actorId] });
92
+ client.removeQueries({ queryKey: ['workspace-user', scope.actorId] });
93
+ client.removeQueries({
94
+ queryKey: ['workspace-invitations', scope.actorId],
95
+ });
96
+ client.removeQueries({
97
+ predicate: (query) =>
98
+ query.queryKey[0] === 'global-command-launcher' &&
99
+ query.queryKey.includes(scope.actorId),
100
+ });
101
+ client.removeQueries({
102
+ queryKey: ['workspace-select-current-workspace', scope.actorId],
103
+ });
104
+ };
105
+ }, [scope, parent, client]);
106
+ return createElement(
107
+ ActorContext.Provider,
108
+ {
109
+ key: actorId,
110
+ value: parent && parent.actorId !== actorId ? null : scope,
111
+ },
112
+ children
113
+ );
114
+ }
115
+
116
+ export function useWorkspaceActor() {
117
+ return useContext(ActorContext);
118
+ }
119
+
120
+ export function useWorkspaceVisibility(enabled = true) {
121
+ const scope = useWorkspaceActor();
122
+ const actorId = scope?.actorId;
123
+ const client = useQueryClient();
124
+ const key = ['workspace-hidden', actorId] as const;
125
+ const control = ownerControl(client, actorId ?? 'signed-out');
126
+ const [pending, setPending] = useState<Set<string>>(new Set());
127
+ const [updateError, setUpdateError] = useState<Error | null>(null);
128
+ const query = useQuery({
129
+ queryKey: key,
130
+ enabled: enabled && Boolean(scope),
131
+ queryFn: async () => {
132
+ scope!.assertActive();
133
+ if (control.pending.size > 0) {
134
+ const optimistic = client.getQueryData<string[]>(key);
135
+ if (optimistic) return optimistic;
136
+ }
137
+ const requestRevision = control.revision;
138
+ const response = await getCurrentUserHiddenWorkspaces(actorId!);
139
+ scope!.assertActive();
140
+ // A refresh started before a mutation cannot overwrite its optimistic state.
141
+ if (requestRevision !== control.revision) {
142
+ return (
143
+ client.getQueryData<string[]>(key) ?? response.hiddenWorkspaceIds
144
+ );
145
+ }
146
+ return response.hiddenWorkspaceIds;
147
+ },
148
+ staleTime: 30_000,
149
+ retry: 1,
150
+ });
151
+ const accountDenied =
152
+ query.error instanceof InternalApiError &&
153
+ [401, 403, 409].includes(query.error.status);
154
+ const ids = accountDenied ? undefined : query.data;
155
+ async function setHidden(workspaceId: string, hidden: boolean) {
156
+ if (!scope || !ids || control.pending.has(workspaceId)) return;
157
+ scope.assertActive();
158
+ const ownerKey = ['workspace-hidden', scope.actorId] as const;
159
+ const wasHidden = ids.includes(workspaceId);
160
+ control.pending.add(workspaceId);
161
+ setPending(new Set(control.pending));
162
+ setUpdateError(null);
163
+ control.revision++;
164
+ await client.cancelQueries({ queryKey: ownerKey });
165
+ scope.assertActive();
166
+ client.setQueryData<string[]>(ownerKey, (current = []) =>
167
+ hidden
168
+ ? [...new Set([...current, workspaceId])]
169
+ : current.filter((id) => id !== workspaceId)
170
+ );
171
+ try {
172
+ await updateCurrentUserHiddenWorkspace(
173
+ workspaceId,
174
+ hidden,
175
+ scope.actorId
176
+ );
177
+ scope.assertActive();
178
+ } catch (error) {
179
+ try {
180
+ scope.assertActive();
181
+ } catch {
182
+ return;
183
+ }
184
+ client.setQueryData<string[]>(ownerKey, (current = []) =>
185
+ wasHidden
186
+ ? [...new Set([...current, workspaceId])]
187
+ : current.filter((id) => id !== workspaceId)
188
+ );
189
+ setUpdateError(
190
+ error instanceof Error
191
+ ? error
192
+ : new Error('Unable to update Hidden workspaces')
193
+ );
194
+ throw error;
195
+ } finally {
196
+ control.pending.delete(workspaceId);
197
+ try {
198
+ scope.assertActive();
199
+ setPending(new Set(control.pending));
200
+ if (control.pending.size === 0) {
201
+ void client.invalidateQueries({ queryKey: ownerKey });
202
+ }
203
+ } catch {
204
+ /* An old account cannot publish into the next account. */
205
+ }
206
+ }
207
+ }
208
+ return {
209
+ ...query,
210
+ refetch: async () => {
211
+ if (!scope) return null;
212
+ try {
213
+ scope.assertActive();
214
+ } catch {
215
+ return null;
216
+ }
217
+ const result = await query.refetch();
218
+ try {
219
+ scope.assertActive();
220
+ } catch {
221
+ return null;
222
+ }
223
+ if (!result.error) setUpdateError(null);
224
+ return result;
225
+ },
226
+ accountDenied,
227
+ actorId,
228
+ hiddenIds: ids ?? [],
229
+ known: ids !== undefined,
230
+ pending,
231
+ updateError,
232
+ setHidden,
233
+ };
234
+ }
@@ -37,7 +37,7 @@ export interface YjsCollaborationResult {
37
37
  }
38
38
 
39
39
  /**
40
- * Hook for managing Yjs collaboration with Supabase Realtime.
40
+ * Hook for managing Yjs collaboration with Cloudflare realtime.
41
41
  *
42
42
  * Uses deferred cleanup so that React StrictMode's double-invoke cycle
43
43
  * (mount → cleanup → remount) reuses the existing SupabaseProvider instead
@@ -72,17 +72,27 @@ export function useYjsCollaboration(
72
72
  // Ref that event listeners check — survives StrictMode cleanup/remount
73
73
  const mountedRef = useRef(false);
74
74
 
75
- // Create Yjs document and awareness (stable references)
76
- const doc = useMemo(() => (enabled ? new Y.Doc() : null), [enabled]);
77
-
78
- const awareness = useMemo(
79
- () => (enabled && doc ? new Awareness(doc) : null),
80
- [enabled, doc]
75
+ const hasUser = !!user;
76
+ // Create the document without starting awareness timers during render.
77
+ const documentScope = JSON.stringify([
78
+ channel,
79
+ tableName,
80
+ columnName,
81
+ id,
82
+ user?.id,
83
+ ]);
84
+ const doc = useMemo(
85
+ () => (enabled && hasUser ? new Y.Doc({ guid: documentScope }) : null),
86
+ [enabled, hasUser, documentScope]
81
87
  );
82
88
 
89
+ const awareness =
90
+ providerState && providerState.id === doc?.clientID
91
+ ? providerState.awareness
92
+ : null;
93
+
83
94
  // Stabilize callback and user refs — these should never cause provider
84
95
  // destruction/recreation.
85
- const hasUser = !!user;
86
96
  const userRef = useRef(user);
87
97
  userRef.current = user;
88
98
  const onSyncRef = useRef(onSync);
@@ -94,19 +104,22 @@ export function useYjsCollaboration(
94
104
 
95
105
  // Provider lifecycle: create/destroy based on channel config
96
106
  useEffect(() => {
97
- if (!enabled || !doc || !awareness || !hasUser) return;
107
+ if (!enabled || !doc || !hasUser) return;
98
108
 
99
109
  mountedRef.current = true;
100
110
 
101
111
  // ── StrictMode reuse path ───────────────────────────────────────
102
112
  // If a pending deferred destruction exists, cancel it and reuse
103
- // the existing provider. This prevents the Supabase Realtime
104
- // "leave then immediate re-join" race that causes a 10s timeout.
113
+ // the existing provider only when its document identity still matches.
105
114
  if (destroyTimerRef.current) {
106
115
  clearTimeout(destroyTimerRef.current);
107
116
  destroyTimerRef.current = null;
108
117
 
109
- if (providerRef.current && !providerRef.current.destroyed) {
118
+ if (
119
+ providerRef.current &&
120
+ !providerRef.current.destroyed &&
121
+ providerRef.current.id === doc.clientID
122
+ ) {
110
123
  console.log('♻️ Reusing existing SupabaseProvider (StrictMode)');
111
124
  return () => {
112
125
  mountedRef.current = false;
@@ -133,6 +146,7 @@ export function useYjsCollaboration(
133
146
  }
134
147
 
135
148
  const supabase = createClient();
149
+ const awareness = new Awareness(doc);
136
150
 
137
151
  // Set initial awareness state
138
152
  const currentUser = userRef.current;
@@ -152,6 +166,7 @@ export function useYjsCollaboration(
152
166
  tableName: tableName,
153
167
  columnName: columnName,
154
168
  awareness,
169
+ ownsDocument: true,
155
170
  resyncInterval: 30000,
156
171
  saveDebounceMs: saveDebounceMs ?? 300,
157
172
  loadState: loadDocumentState,
@@ -165,25 +180,25 @@ export function useYjsCollaboration(
165
180
  // Listen to provider events — use mountedRef so listeners stay valid
166
181
  // across StrictMode cleanup/remount without re-registration.
167
182
  provider.on('status', ([{ status }]) => {
168
- if (!mountedRef.current) return;
183
+ if (!mountedRef.current || providerRef.current !== provider) return;
169
184
  console.log('📡 Provider status:', status);
170
185
  setConnected(status === 'connected');
171
186
  });
172
187
 
173
188
  provider.on('synced', ([syncState]) => {
174
- if (!mountedRef.current) return;
189
+ if (!mountedRef.current || providerRef.current !== provider) return;
175
190
  console.log('🔄 Provider synced:', syncState);
176
191
  setSynced(syncState);
177
192
  onSyncRef.current?.(syncState);
178
193
  });
179
194
 
180
195
  provider.on('sync', ([syncState]) => {
181
- if (!mountedRef.current) return;
196
+ if (!mountedRef.current || providerRef.current !== provider) return;
182
197
  console.log('🔄 Provider sync event:', syncState);
183
198
  });
184
199
 
185
200
  provider.on('save', (version) => {
186
- if (!mountedRef.current) return;
201
+ if (!mountedRef.current || providerRef.current !== provider) return;
187
202
  console.log('💾 Document saved to database, version:', version);
188
203
  onSaveRef.current?.(version);
189
204
  });
@@ -196,7 +211,7 @@ export function useYjsCollaboration(
196
211
  channel: string;
197
212
  status: string;
198
213
  }) => {
199
- if (!mountedRef.current) return;
214
+ if (!mountedRef.current || providerRef.current !== provider) return;
200
215
  console.error('❌ Provider error:', {
201
216
  message: errorInfo.message,
202
217
  channel: errorInfo.channel,
@@ -207,19 +222,19 @@ export function useYjsCollaboration(
207
222
  );
208
223
 
209
224
  provider.on('connect', () => {
210
- if (!mountedRef.current) return;
225
+ if (!mountedRef.current || providerRef.current !== provider) return;
211
226
  console.log('✅ Provider connected');
212
227
  });
213
228
 
214
229
  provider.on('disconnect', () => {
215
- if (!mountedRef.current) return;
230
+ if (!mountedRef.current || providerRef.current !== provider) return;
216
231
  console.log('🔌 Provider disconnected');
217
232
  setConnected(false);
218
233
  setSynced(false);
219
234
  });
220
235
 
221
236
  provider.on('dom-error', (error: DOMException) => {
222
- if (!mountedRef.current) return;
237
+ if (!mountedRef.current || providerRef.current !== provider) return;
223
238
  console.warn(
224
239
  '⚠️ DOM reconciliation error handled gracefully:',
225
240
  error.message
@@ -227,7 +242,7 @@ export function useYjsCollaboration(
227
242
  });
228
243
 
229
244
  provider.on('reconnect-failed', () => {
230
- if (!mountedRef.current) return;
245
+ if (!mountedRef.current || providerRef.current !== provider) return;
231
246
  // If page is visible (user is active but network dropped), restart
232
247
  // the backoff cycle after a short delay to avoid hammering
233
248
  if (
@@ -240,8 +255,8 @@ export function useYjsCollaboration(
240
255
  setTimeout(() => {
241
256
  if (
242
257
  mountedRef.current &&
243
- providerRef.current &&
244
- !providerRef.current.destroyed
258
+ providerRef.current === provider &&
259
+ !provider.destroyed
245
260
  ) {
246
261
  providerRef.current.resetAndReconnect();
247
262
  }
@@ -269,7 +284,6 @@ export function useYjsCollaboration(
269
284
  columnName,
270
285
  hasUser,
271
286
  doc,
272
- awareness,
273
287
  enabled,
274
288
  broadcastDebounceMs,
275
289
  saveDebounceMs,
@@ -290,8 +304,12 @@ export function useYjsCollaboration(
290
304
  return {
291
305
  doc,
292
306
  awareness,
293
- provider: providerState,
294
- synced,
295
- connected,
307
+ provider:
308
+ providerState && providerState.id === doc?.clientID
309
+ ? providerState
310
+ : null,
311
+ synced: !!providerState && providerState.id === doc?.clientID && synced,
312
+ connected:
313
+ !!providerState && providerState.id === doc?.clientID && connected,
296
314
  };
297
315
  }