@tuturuuu/ui 0.37.2 → 0.39.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 +55 -0
- package/package.json +10 -7
- package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
- 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/satellite-notification-polling.test.tsx +61 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
- package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
- package/src/components/ui/custom/workspace-select.tsx +12 -4
- package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
- package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
- package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
- package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
- package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
- package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
- package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
- package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
- package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
- package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
- package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
- package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
- package/src/components/ui/finance/invoices/hooks.ts +3 -100
- package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
- package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
- package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
- package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
- package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
- package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
- package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
- package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
- package/src/components/ui/finance/invoices/types.ts +3 -3
- package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
- package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
- package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
- package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
- package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
- package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
- package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
- package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
- 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/legacy-event-modal.tsx +1559 -0
- package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
- package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
- package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
- package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
- package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -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/provider-readonly-dialog.test.tsx +52 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -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/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/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
- package/src/components/ui/popover.tsx +3 -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 +11 -3
- package/src/components/ui/text-editor/extensions.ts +8 -2
- package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
- 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 +45 -102
- package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
- package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
- package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
- package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
- package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
- package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
- package/src/hooks/calendar-connection-types.ts +49 -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-update-queue-types.ts +12 -0
- package/src/hooks/calendar-event-write-payload.ts +57 -0
- package/src/hooks/collaboration-document-hydration.ts +35 -0
- package/src/hooks/notification-subscription.ts +5 -2
- package/src/hooks/supabase-provider.ts +76 -112
- package/src/hooks/use-calendar-sync.tsx +41 -62
- package/src/hooks/use-calendar.tsx +44 -82
- package/src/hooks/use-exchange-rates.ts +4 -15
- package/src/hooks/use-local-storage.ts +9 -11
- package/src/hooks/use-native-calendar-occurrences.ts +85 -0
- package/src/hooks/use-workspace-presence.ts +9 -9
- package/src/hooks/use-yjs-collaboration.ts +68 -27
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type QueryClient, useQueryClient } from '@tanstack/react-query';
|
|
2
2
|
import { useEffect } from 'react';
|
|
3
3
|
|
|
4
|
-
export const NOTIFICATION_REFRESH_INTERVAL_MS =
|
|
4
|
+
export const NOTIFICATION_REFRESH_INTERVAL_MS = 120_000;
|
|
5
5
|
|
|
6
6
|
interface NotificationSubscriptionEntry {
|
|
7
7
|
queryClients: Map<QueryClient, number>;
|
|
@@ -12,7 +12,8 @@ interface NotificationSubscriptionEntry {
|
|
|
12
12
|
const registry = new Map<string, NotificationSubscriptionEntry>();
|
|
13
13
|
|
|
14
14
|
function invalidateVisibleNotifications(entry: NotificationSubscriptionEntry) {
|
|
15
|
-
if (document.visibilityState === 'hidden')
|
|
15
|
+
if (document.visibilityState === 'hidden' || navigator.onLine === false)
|
|
16
|
+
return;
|
|
16
17
|
for (const queryClient of entry.queryClients.keys()) {
|
|
17
18
|
// Only active queries refetch; closed inboxes remain lazy.
|
|
18
19
|
void queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
@@ -22,6 +23,7 @@ function invalidateVisibleNotifications(entry: NotificationSubscriptionEntry) {
|
|
|
22
23
|
function stopEntry(entry: NotificationSubscriptionEntry) {
|
|
23
24
|
clearInterval(entry.interval);
|
|
24
25
|
document.removeEventListener('visibilitychange', entry.onVisibilityChange);
|
|
26
|
+
window.removeEventListener('online', entry.onVisibilityChange);
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -50,6 +52,7 @@ export function useNotificationSubscription(
|
|
|
50
52
|
};
|
|
51
53
|
registry.set(userId, entry);
|
|
52
54
|
document.addEventListener('visibilitychange', refresh);
|
|
55
|
+
window.addEventListener('online', refresh);
|
|
53
56
|
}
|
|
54
57
|
entry.queryClients.set(
|
|
55
58
|
queryClient,
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRealtimeClient,
|
|
3
|
+
type RealtimeChannel,
|
|
4
|
+
} from '@tuturuuu/internal-api/realtime';
|
|
1
5
|
import type { SupabaseClient } from '@tuturuuu/supabase/next/client';
|
|
2
|
-
import type { RealtimeChannel } from '@tuturuuu/supabase/next/realtime';
|
|
3
6
|
import debug from 'debug';
|
|
4
7
|
import { EventEmitter } from 'eventemitter3';
|
|
5
8
|
import * as awarenessProtocol from 'y-protocols/awareness';
|
|
6
9
|
import * as Y from 'yjs';
|
|
10
|
+
import { DocumentHydration } from './collaboration-document-hydration';
|
|
7
11
|
import { isPageVisible } from './use-page-visibility';
|
|
8
12
|
|
|
9
13
|
export const SUPABASE_PROVIDER_SYNC_ORIGIN = Symbol.for(
|
|
@@ -22,12 +26,18 @@ export interface SupabaseProviderConfig {
|
|
|
22
26
|
broadcastDebounceMs?: number; // Debounce time for broadcasting updates (0 = immediate, default: 0)
|
|
23
27
|
loadState?: () => Promise<number[] | null>;
|
|
24
28
|
saveState?: (state: number[]) => Promise<boolean | undefined>;
|
|
29
|
+
ownsDocument?: boolean;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export default class SupabaseProvider extends EventEmitter {
|
|
28
33
|
public awareness: awarenessProtocol.Awareness;
|
|
29
34
|
public connected = false;
|
|
35
|
+
public hydrated = false;
|
|
36
|
+
private connectionGeneration = 0;
|
|
37
|
+
private hydration: DocumentHydration;
|
|
30
38
|
private channel: RealtimeChannel | null = null;
|
|
39
|
+
private realtime = createRealtimeClient();
|
|
40
|
+
private pendingAwarenessClients = new Set<number>();
|
|
31
41
|
|
|
32
42
|
private _synced: boolean = false;
|
|
33
43
|
private resyncInterval: NodeJS.Timeout | undefined;
|
|
@@ -71,15 +81,11 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
71
81
|
return;
|
|
72
82
|
}
|
|
73
83
|
|
|
74
|
-
// Only broadcast and save if connected and not destroyed
|
|
75
84
|
if (!this.connected || this.destroyed) {
|
|
76
|
-
this.logger('skipping broadcast - not connected or destroyed');
|
|
77
85
|
return;
|
|
78
86
|
}
|
|
79
87
|
|
|
80
|
-
// Validate update size (avoid broadcasting empty or corrupted updates)
|
|
81
88
|
if (!update || update.length === 0) {
|
|
82
|
-
this.logger('skipping broadcast - empty update');
|
|
83
89
|
return;
|
|
84
90
|
}
|
|
85
91
|
|
|
@@ -91,8 +97,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
91
97
|
);
|
|
92
98
|
|
|
93
99
|
if (this.broadcastDebounceMs > 0) {
|
|
94
|
-
// Debounced broadcast for free tier — merge rapid incremental edits so
|
|
95
|
-
// peers do not miss insertion structs that later updates depend on.
|
|
96
100
|
this.pendingBroadcastUpdate = this.pendingBroadcastUpdate
|
|
97
101
|
? Y.mergeUpdates([this.pendingBroadcastUpdate, update])
|
|
98
102
|
: update;
|
|
@@ -110,7 +114,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
110
114
|
}
|
|
111
115
|
}, this.broadcastDebounceMs);
|
|
112
116
|
} else {
|
|
113
|
-
// Immediate broadcast for paid tier
|
|
114
117
|
this.emit('message', update);
|
|
115
118
|
}
|
|
116
119
|
|
|
@@ -118,25 +121,17 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
debouncedSave() {
|
|
121
|
-
// Clear any pending save
|
|
122
124
|
if (this.saveTimeout) {
|
|
123
125
|
clearTimeout(this.saveTimeout);
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
// Schedule a new save after the debounce period
|
|
127
128
|
this.saveTimeout = setTimeout(() => {
|
|
128
129
|
this.saveTimeout = undefined;
|
|
129
|
-
this.logger('debounce period elapsed, saving to database');
|
|
130
130
|
void this.save();
|
|
131
131
|
}, this.saveDebounceMs);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
/**
|
|
135
|
-
* Immediately save any pending changes without waiting for debounce period.
|
|
136
|
-
* Also flushes any pending broadcast.
|
|
137
|
-
*/
|
|
138
134
|
async flushSave() {
|
|
139
|
-
// Flush pending broadcast first
|
|
140
135
|
if (this.broadcastDebounceTimeout) {
|
|
141
136
|
clearTimeout(this.broadcastDebounceTimeout);
|
|
142
137
|
this.broadcastDebounceTimeout = undefined;
|
|
@@ -151,29 +146,34 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
151
146
|
if (this.saveTimeout) {
|
|
152
147
|
clearTimeout(this.saveTimeout);
|
|
153
148
|
this.saveTimeout = undefined;
|
|
154
|
-
this.logger('flushing pending save immediately');
|
|
155
149
|
await this.save();
|
|
156
150
|
}
|
|
157
151
|
}
|
|
158
152
|
|
|
159
|
-
onAwarenessUpdate(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (this.
|
|
168
|
-
|
|
169
|
-
|
|
153
|
+
onAwarenessUpdate(
|
|
154
|
+
{
|
|
155
|
+
added,
|
|
156
|
+
updated,
|
|
157
|
+
removed,
|
|
158
|
+
}: { added: number[]; updated: number[]; removed: number[] },
|
|
159
|
+
origin: unknown
|
|
160
|
+
) {
|
|
161
|
+
if (origin === this || this.destroyed) return;
|
|
162
|
+
for (const id of [...added, ...updated, ...removed])
|
|
163
|
+
this.pendingAwarenessClients.add(id);
|
|
164
|
+
if (this.awarenessDebounceTimeout) return;
|
|
170
165
|
this.awarenessDebounceTimeout = setTimeout(() => {
|
|
171
|
-
this.
|
|
166
|
+
this.awarenessDebounceTimeout = undefined;
|
|
167
|
+
const clients = [...this.pendingAwarenessClients];
|
|
168
|
+
this.pendingAwarenessClients.clear();
|
|
169
|
+
this.emit(
|
|
170
|
+
'awareness',
|
|
171
|
+
awarenessProtocol.encodeAwarenessUpdate(this.awareness, clients)
|
|
172
|
+
);
|
|
172
173
|
}, 150);
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
removeSelfFromAwarenessOnUnload() {
|
|
176
|
-
// Flush any pending saves before unload
|
|
177
177
|
this.flushSave();
|
|
178
178
|
awarenessProtocol.removeAwarenessStates(
|
|
179
179
|
this.awareness,
|
|
@@ -184,7 +184,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
184
184
|
|
|
185
185
|
async save() {
|
|
186
186
|
try {
|
|
187
|
-
// Don't save if not connected or destroyed
|
|
188
187
|
if (!this.connected || this.destroyed) {
|
|
189
188
|
this.logger('skipping save - not connected or destroyed');
|
|
190
189
|
return false;
|
|
@@ -192,13 +191,11 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
192
191
|
|
|
193
192
|
const content = Array.from(Y.encodeStateAsUpdate(this.doc));
|
|
194
193
|
|
|
195
|
-
// Skip save if content is empty or too small (likely invalid)
|
|
196
194
|
if (!content || content.length === 0) {
|
|
197
195
|
this.logger('skipping save - empty content');
|
|
198
196
|
return false;
|
|
199
197
|
}
|
|
200
198
|
|
|
201
|
-
// Skip save if content is suspiciously small (might be corrupted)
|
|
202
199
|
if (content.length < 10) {
|
|
203
200
|
this.logger('skipping save - content too small, possibly corrupted');
|
|
204
201
|
return false;
|
|
@@ -229,7 +226,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
229
226
|
if (error) {
|
|
230
227
|
this.logger(`save failed with status ${status}:`, error);
|
|
231
228
|
|
|
232
|
-
// Handle 422 errors - task was deleted, validation failed, or RLS rejected
|
|
233
229
|
if (status === 422) {
|
|
234
230
|
console.warn(
|
|
235
231
|
`Failed to save Yjs state (422 - Unprocessable Entity):`,
|
|
@@ -241,15 +237,12 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
241
237
|
}
|
|
242
238
|
);
|
|
243
239
|
|
|
244
|
-
// Mark as not synced so UI shows warning
|
|
245
240
|
this.synced = false;
|
|
246
241
|
|
|
247
|
-
// Stop trying to save this document
|
|
248
242
|
this.logger('stopping future saves due to 422 error');
|
|
249
243
|
return false;
|
|
250
244
|
}
|
|
251
245
|
|
|
252
|
-
// Handle 404 errors - task doesn't exist
|
|
253
246
|
if (status === 404) {
|
|
254
247
|
console.warn(`Task ${this.config.id} not found, stopping saves`);
|
|
255
248
|
this.synced = false;
|
|
@@ -276,43 +269,22 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
276
269
|
}
|
|
277
270
|
|
|
278
271
|
private async onConnect() {
|
|
279
|
-
this.logger('connected');
|
|
280
|
-
|
|
281
|
-
// Reset reconnect attempts on successful connection
|
|
282
272
|
this.reconnectAttempts = 0;
|
|
283
273
|
if (this.reconnectTimeout) {
|
|
284
274
|
clearTimeout(this.reconnectTimeout);
|
|
285
275
|
this.reconnectTimeout = undefined;
|
|
286
276
|
}
|
|
287
277
|
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
)
|
|
296
|
-
.eq(this.config.idName || 'id', this.config.id)
|
|
297
|
-
.single();
|
|
298
|
-
|
|
299
|
-
this.logger('retrieved data from supabase', status);
|
|
300
|
-
return data?.[this.config.columnName] ?? null;
|
|
301
|
-
})();
|
|
302
|
-
|
|
303
|
-
if (persistedState && persistedState.length > 0) {
|
|
304
|
-
this.logger('applying update to yjs');
|
|
305
|
-
try {
|
|
306
|
-
this.applyUpdate(Uint8Array.from(persistedState), this);
|
|
307
|
-
} catch (error) {
|
|
308
|
-
this.logger(error);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
278
|
+
const generation = this.connectionGeneration;
|
|
279
|
+
if (
|
|
280
|
+
!(await this.hydration.load()) ||
|
|
281
|
+
this.destroyed ||
|
|
282
|
+
generation !== this.connectionGeneration
|
|
283
|
+
)
|
|
284
|
+
return;
|
|
311
285
|
|
|
312
|
-
this.logger('setting connected flag to true');
|
|
313
286
|
this.isOnline(true);
|
|
314
287
|
|
|
315
|
-
// Mark as synced after loading initial data
|
|
316
288
|
this.synced = true;
|
|
317
289
|
|
|
318
290
|
this.emit('status', [{ status: 'connected' }]);
|
|
@@ -331,9 +303,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
331
303
|
this.version++;
|
|
332
304
|
Y.applyUpdate(this.doc, update, origin);
|
|
333
305
|
} catch (error) {
|
|
334
|
-
// Handle DOM reconciliation errors during Yjs sync
|
|
335
|
-
// This happens when ProseMirror/Tiptap tries to manipulate DOM nodes
|
|
336
|
-
// that React has already cleaned up during reconnection scenarios
|
|
337
306
|
if (
|
|
338
307
|
error instanceof DOMException &&
|
|
339
308
|
error.name === 'NotFoundError' &&
|
|
@@ -343,33 +312,31 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
343
312
|
'DOM reconciliation error during Yjs update - this can happen after AFK reconnection'
|
|
344
313
|
);
|
|
345
314
|
console.warn(
|
|
346
|
-
'[
|
|
315
|
+
'[Cloudflare collaboration] DOM reconciliation error during Yjs sync. ' +
|
|
347
316
|
'This is usually harmless and occurs when the editor reconnects after being idle.'
|
|
348
317
|
);
|
|
349
|
-
// Emit event so the component can handle recovery if needed
|
|
350
318
|
this.emit('dom-error', error);
|
|
351
319
|
return;
|
|
352
320
|
}
|
|
353
|
-
// Re-throw other errors
|
|
354
321
|
throw error;
|
|
355
322
|
}
|
|
356
323
|
}
|
|
357
324
|
|
|
358
325
|
private disconnect() {
|
|
359
326
|
if (this.channel) {
|
|
360
|
-
this.
|
|
327
|
+
this.realtime.removeChannel(this.channel);
|
|
361
328
|
this.channel = null;
|
|
362
329
|
}
|
|
363
330
|
}
|
|
364
331
|
|
|
365
332
|
private connect() {
|
|
366
|
-
this.channel = this.
|
|
333
|
+
this.channel = this.realtime.channel(this.config.channel);
|
|
367
334
|
if (this.channel) {
|
|
368
335
|
this.channel
|
|
369
|
-
.on('broadcast', { event: 'message' }, ({ payload }) => {
|
|
336
|
+
.on<number[]>('broadcast', { event: 'message' }, ({ payload }) => {
|
|
370
337
|
this.onMessage(Uint8Array.from(payload), this);
|
|
371
338
|
})
|
|
372
|
-
.on('broadcast', { event: 'awareness' }, ({ payload }) => {
|
|
339
|
+
.on<number[]>('broadcast', { event: 'awareness' }, ({ payload }) => {
|
|
373
340
|
this.onAwareness(Uint8Array.from(payload));
|
|
374
341
|
})
|
|
375
342
|
.subscribe((status, err) => {
|
|
@@ -420,7 +387,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
420
387
|
this.config = config || {};
|
|
421
388
|
this.id = doc.clientID;
|
|
422
389
|
|
|
423
|
-
// Initialize debounce times
|
|
424
390
|
(this as any).saveDebounceMs = this.config.saveDebounceMs ?? 1000;
|
|
425
391
|
(this as any).broadcastDebounceMs = this.config.broadcastDebounceMs ?? 0;
|
|
426
392
|
|
|
@@ -429,12 +395,35 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
429
395
|
this.on('disconnect', this.onDisconnect);
|
|
430
396
|
|
|
431
397
|
this.logger = debug(`y-${doc.clientID}`);
|
|
432
|
-
// turn on debug logging to the console
|
|
433
398
|
this.logger.enabled = true;
|
|
434
399
|
|
|
435
|
-
this.
|
|
436
|
-
|
|
437
|
-
|
|
400
|
+
this.hydration = new DocumentHydration({
|
|
401
|
+
load:
|
|
402
|
+
this.config.loadState ??
|
|
403
|
+
(async () => {
|
|
404
|
+
const { data, error } = await this.supabase
|
|
405
|
+
.from(this.config.tableName as any)
|
|
406
|
+
.select<string, { [key: string]: number[] }>(this.config.columnName)
|
|
407
|
+
.eq(this.config.idName || 'id', this.config.id)
|
|
408
|
+
.single();
|
|
409
|
+
if (error) throw error;
|
|
410
|
+
return data?.[this.config.columnName] ?? null;
|
|
411
|
+
}),
|
|
412
|
+
apply: (state) => Y.applyUpdate(this.doc, state, this),
|
|
413
|
+
isActive: () => !this.destroyed,
|
|
414
|
+
onLoaded: () => {
|
|
415
|
+
this.hydrated = true;
|
|
416
|
+
this.emit('hydrated');
|
|
417
|
+
},
|
|
418
|
+
onError: () =>
|
|
419
|
+
this.emit('error', {
|
|
420
|
+
message: 'Unable to load collaboration document',
|
|
421
|
+
channel: this.config.channel,
|
|
422
|
+
status: 'DOCUMENT_ERROR',
|
|
423
|
+
}),
|
|
424
|
+
});
|
|
425
|
+
// Durable reads must not depend on a successful peer transport connection.
|
|
426
|
+
void this.hydration.load();
|
|
438
427
|
|
|
439
428
|
if (
|
|
440
429
|
this.config.resyncInterval ||
|
|
@@ -448,21 +437,16 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
448
437
|
`setting resync interval to every ${interval / 1000} seconds`
|
|
449
438
|
);
|
|
450
439
|
this.resyncInterval = setInterval(() => {
|
|
451
|
-
// Only resync if connected and not destroyed
|
|
452
440
|
if (!this.connected || this.destroyed) {
|
|
453
441
|
this.logger('skipping resync - not connected or destroyed');
|
|
454
442
|
return;
|
|
455
443
|
}
|
|
456
444
|
|
|
457
|
-
// Skip resync when page is hidden (no one is watching)
|
|
458
445
|
if (!isPageVisible()) {
|
|
459
|
-
this.logger('skipping resync - page not visible');
|
|
460
446
|
return;
|
|
461
447
|
}
|
|
462
448
|
|
|
463
|
-
// Skip resync if no local changes since last resync
|
|
464
449
|
if (!this._dirty) {
|
|
465
|
-
this.logger('skipping resync - no local changes');
|
|
466
450
|
return;
|
|
467
451
|
}
|
|
468
452
|
|
|
@@ -471,7 +455,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
471
455
|
this.logger('resyncing (resync interval elapsed)');
|
|
472
456
|
const update = Y.encodeStateAsUpdate(this.doc);
|
|
473
457
|
|
|
474
|
-
// Validate update before broadcasting
|
|
475
458
|
if (!update || update.length === 0) {
|
|
476
459
|
this.logger('skipping resync - empty update');
|
|
477
460
|
return;
|
|
@@ -497,9 +480,7 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
497
480
|
process.on('exit', this.processExitHandler);
|
|
498
481
|
}
|
|
499
482
|
this.on('awareness', (update) => {
|
|
500
|
-
// Only broadcast if connected and channel exists
|
|
501
483
|
if (this.connected && this.channel && !this.destroyed) {
|
|
502
|
-
// Validate update before broadcasting
|
|
503
484
|
if (update && update.length > 0) {
|
|
504
485
|
this.channel.send({
|
|
505
486
|
type: 'broadcast',
|
|
@@ -510,9 +491,7 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
510
491
|
}
|
|
511
492
|
});
|
|
512
493
|
this.on('message', (update) => {
|
|
513
|
-
// Only broadcast if connected and channel exists
|
|
514
494
|
if (this.connected && this.channel && !this.destroyed) {
|
|
515
|
-
// Validate update before broadcasting
|
|
516
495
|
if (update && update.length > 0) {
|
|
517
496
|
this.channel.send({
|
|
518
497
|
type: 'broadcast',
|
|
@@ -525,7 +504,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
525
504
|
|
|
526
505
|
this.connect();
|
|
527
506
|
|
|
528
|
-
// Reconnect when user returns from AFK (hidden → visible)
|
|
529
507
|
if (typeof document !== 'undefined') {
|
|
530
508
|
this._handleVisibilityChange = () => {
|
|
531
509
|
if (this.destroyed) return;
|
|
@@ -535,7 +513,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
535
513
|
this.resetAndReconnect();
|
|
536
514
|
}
|
|
537
515
|
|
|
538
|
-
// Pause reconnect timer when page goes hidden to conserve attempts
|
|
539
516
|
if (document.visibilityState === 'hidden' && this.reconnectTimeout) {
|
|
540
517
|
this.logger('page hidden during reconnect backoff — pausing timer');
|
|
541
518
|
clearTimeout(this.reconnectTimeout);
|
|
@@ -550,7 +527,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
550
527
|
|
|
551
528
|
this.awareness.on('update', this.boundAwarenessUpdate);
|
|
552
529
|
|
|
553
|
-
// Listen to document updates and broadcast them to peers
|
|
554
530
|
this.doc.on('update', this.boundDocumentUpdate);
|
|
555
531
|
}
|
|
556
532
|
|
|
@@ -569,7 +545,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
569
545
|
|
|
570
546
|
public onConnecting() {
|
|
571
547
|
if (!this.isOnline()) {
|
|
572
|
-
this.logger('connecting');
|
|
573
548
|
this.emit('status', [{ status: 'connecting' }]);
|
|
574
549
|
}
|
|
575
550
|
}
|
|
@@ -578,12 +553,10 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
578
553
|
if (this.destroyed || this.reconnectTimeout) return;
|
|
579
554
|
|
|
580
555
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
581
|
-
this.logger('max reconnect attempts reached');
|
|
582
556
|
this.emit('reconnect-failed');
|
|
583
557
|
return;
|
|
584
558
|
}
|
|
585
559
|
|
|
586
|
-
// Exponential backoff with jitter
|
|
587
560
|
const delay = Math.min(
|
|
588
561
|
this.reconnectDelay * 2 ** this.reconnectAttempts,
|
|
589
562
|
this.maxReconnectDelay
|
|
@@ -600,23 +573,15 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
600
573
|
this.reconnectAttempts++;
|
|
601
574
|
this.logger(`reconnect attempt ${this.reconnectAttempts}`);
|
|
602
575
|
|
|
603
|
-
// Disconnect old channel if it exists
|
|
604
576
|
this.disconnect();
|
|
605
577
|
|
|
606
|
-
// Try to reconnect
|
|
607
578
|
this.connect();
|
|
608
579
|
}, actualDelay);
|
|
609
580
|
}
|
|
610
581
|
|
|
611
|
-
/**
|
|
612
|
-
* Reset backoff state and force a fresh connection cycle.
|
|
613
|
-
* Used when the user returns from AFK (visibilitychange) or when
|
|
614
|
-
* reconnect-failed fires while the page is still active.
|
|
615
|
-
*/
|
|
616
582
|
public resetAndReconnect() {
|
|
617
583
|
if (this.destroyed || this.connected) return;
|
|
618
584
|
|
|
619
|
-
this.logger('resetAndReconnect: resetting backoff and reconnecting');
|
|
620
585
|
this.reconnectAttempts = 0;
|
|
621
586
|
|
|
622
587
|
if (this.reconnectTimeout) {
|
|
@@ -629,17 +594,14 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
629
594
|
}
|
|
630
595
|
|
|
631
596
|
public onDisconnect() {
|
|
632
|
-
this.
|
|
597
|
+
this.connectionGeneration++;
|
|
633
598
|
|
|
634
599
|
this.synced = false;
|
|
635
600
|
this.isOnline(false);
|
|
636
|
-
this.logger('set connected flag to false');
|
|
637
601
|
if (this.isOnline()) {
|
|
638
602
|
this.emit('status', [{ status: 'disconnected' }]);
|
|
639
603
|
}
|
|
640
604
|
|
|
641
|
-
// update awareness (keep all users except local)
|
|
642
|
-
// FIXME? compare to broadcast channel behavior
|
|
643
605
|
const states = Array.from(this.awareness.getStates().keys()).filter(
|
|
644
606
|
(client) => client !== this.doc.clientID
|
|
645
607
|
);
|
|
@@ -670,11 +632,12 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
670
632
|
if (!message) {
|
|
671
633
|
this.logger(`Permission denied to channel`);
|
|
672
634
|
}
|
|
673
|
-
this.logger('processed message (type = MessageAuth)');
|
|
674
635
|
}
|
|
675
636
|
|
|
676
637
|
public destroy() {
|
|
638
|
+
if (this.destroyed) return;
|
|
677
639
|
this.logger('destroying');
|
|
640
|
+
const pendingSave = this.flushSave();
|
|
678
641
|
this.destroyed = true;
|
|
679
642
|
|
|
680
643
|
// Clear reconnect timeout
|
|
@@ -696,9 +659,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
696
659
|
}
|
|
697
660
|
this.pendingBroadcastUpdate = undefined;
|
|
698
661
|
|
|
699
|
-
// Save any pending changes before destroying
|
|
700
|
-
this.flushSave();
|
|
701
|
-
|
|
702
662
|
// Remove visibility change listener
|
|
703
663
|
if (this._handleVisibilityChange && typeof document !== 'undefined') {
|
|
704
664
|
document.removeEventListener(
|
|
@@ -721,5 +681,9 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
721
681
|
this.doc.off('update', this.boundDocumentUpdate);
|
|
722
682
|
|
|
723
683
|
if (this.channel) this.disconnect();
|
|
684
|
+
if (this.config.ownsDocument) {
|
|
685
|
+
this.awareness.destroy();
|
|
686
|
+
void pendingSave.finally(() => this.doc.destroy());
|
|
687
|
+
}
|
|
724
688
|
}
|
|
725
689
|
}
|