@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.
- package/CHANGELOG.md +40 -0
- package/package.json +10 -7
- package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +267 -0
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
- package/src/components/ui/custom/education-todo.test.tsx +131 -0
- package/src/components/ui/custom/education-todo.tsx +191 -0
- package/src/components/ui/custom/hidden-workspaces-settings.tsx +49 -0
- package/src/components/ui/custom/mobile-header-height.ts +16 -0
- package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
- package/src/components/ui/custom/structure.tsx +37 -5
- package/src/components/ui/custom/user-filters.tsx +2 -1
- package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
- package/src/components/ui/custom/workspace-picker-content.tsx +356 -0
- package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
- package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
- package/src/components/ui/custom/workspace-select.tsx +95 -359
- package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
- package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
- package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
- package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
- package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
- package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
- package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
- package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
- package/src/components/ui/legacy/calendar/settings/calendar-sync-dashboard.tsx +4 -23
- package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
- package/src/components/ui/popover.tsx +3 -1
- package/src/components/ui/resizable.tsx +2 -1
- package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
- package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
- package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
- package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
- package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
- package/src/components/ui/text-editor/editor.tsx +8 -3
- package/src/components/ui/text-editor/extensions.ts +8 -2
- package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
- package/src/components/ui/text-editor/markdown.ts +11 -0
- package/src/components/ui/text-editor/tool-bar.tsx +43 -44
- package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
- package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
- package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
- package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
- package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -0
- package/src/hooks/calendar-creation-request.test.ts +62 -0
- package/src/hooks/calendar-creation-request.ts +34 -0
- package/src/hooks/calendar-event-write-payload.ts +24 -0
- package/src/hooks/notification-subscription.ts +5 -2
- package/src/hooks/supabase-provider.ts +38 -72
- package/src/hooks/use-calendar.tsx +21 -45
- package/src/hooks/use-exchange-rates.ts +4 -15
- package/src/hooks/use-visible-workspaces.ts +57 -0
- package/src/hooks/use-workspace-presence.ts +9 -9
- package/src/hooks/use-workspace-user.ts +15 -3
- package/src/hooks/use-workspace-visibility.ts +234 -0
- 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
|
|
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
|
|
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
|
|
187
|
+
await realtime.removeChannel(channelRef.current);
|
|
188
188
|
channelRef.current = null;
|
|
189
189
|
lastTrackSignatureRef.current = null;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
const channel =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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 || !
|
|
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
|
|
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 (
|
|
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
|
-
!
|
|
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:
|
|
294
|
-
|
|
295
|
-
|
|
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
|
}
|