@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.
Files changed (126) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
  4. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  5. package/src/components/ui/custom/education-todo.tsx +191 -0
  6. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  7. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  8. package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
  9. package/src/components/ui/custom/workspace-select.tsx +12 -4
  10. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  11. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  12. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  13. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  14. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  15. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  16. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  17. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  18. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  19. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  20. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  21. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  22. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  23. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  24. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  25. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  26. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  27. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  28. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  29. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  30. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  31. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  33. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  34. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  35. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  36. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  37. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  38. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  39. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  40. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  41. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  42. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  43. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  44. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  46. package/src/components/ui/finance/invoices/types.ts +3 -3
  47. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  48. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  49. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  50. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  51. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  52. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  53. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  54. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  56. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  57. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  58. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  59. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
  60. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  61. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  62. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  63. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  64. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  65. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  66. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  68. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  69. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  70. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  71. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  72. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  73. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  74. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  75. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  76. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  77. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  78. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  79. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  80. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  81. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  82. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  83. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  84. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  85. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  86. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  87. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  88. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  89. package/src/components/ui/popover.tsx +3 -1
  90. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  91. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  92. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  93. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  94. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  95. package/src/components/ui/text-editor/editor.tsx +11 -3
  96. package/src/components/ui/text-editor/extensions.ts +8 -2
  97. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  98. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  99. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  100. package/src/components/ui/text-editor/markdown.ts +11 -0
  101. package/src/components/ui/text-editor/tool-bar.tsx +45 -102
  102. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  103. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  104. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  105. package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
  106. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
  107. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  108. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  109. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  110. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  111. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
  112. package/src/hooks/calendar-connection-types.ts +49 -0
  113. package/src/hooks/calendar-creation-request.test.ts +62 -0
  114. package/src/hooks/calendar-creation-request.ts +34 -0
  115. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  116. package/src/hooks/calendar-event-write-payload.ts +57 -0
  117. package/src/hooks/collaboration-document-hydration.ts +35 -0
  118. package/src/hooks/notification-subscription.ts +5 -2
  119. package/src/hooks/supabase-provider.ts +76 -112
  120. package/src/hooks/use-calendar-sync.tsx +41 -62
  121. package/src/hooks/use-calendar.tsx +44 -82
  122. package/src/hooks/use-exchange-rates.ts +4 -15
  123. package/src/hooks/use-local-storage.ts +9 -11
  124. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  125. package/src/hooks/use-workspace-presence.ts +9 -9
  126. 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 = 30_000;
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') return;
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({ added, updated, removed }: any, _origin: any) {
160
- const changedClients = added.concat(updated).concat(removed);
161
- const awarenessUpdate = awarenessProtocol.encodeAwarenessUpdate(
162
- this.awareness,
163
- changedClients
164
- );
165
-
166
- // Debounce awareness broadcasts to coalesce rapid cursor/selection changes
167
- if (this.awarenessDebounceTimeout) {
168
- clearTimeout(this.awarenessDebounceTimeout);
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.emit('awareness', awarenessUpdate);
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 persistedState = this.config.loadState
289
- ? await this.config.loadState()
290
- : await (async () => {
291
- const { data, status } = await this.supabase
292
- .from(this.config.tableName as any)
293
- .select<string, { [key: string]: number[] }>(
294
- `${this.config.columnName}`
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
- '[SupabaseProvider] DOM reconciliation error during Yjs sync. ' +
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.supabase.removeChannel(this.channel);
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.supabase.channel(this.config.channel);
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.logger('constructor initializing');
436
- this.logger('connecting to Supabase Realtime', doc.guid);
437
- this.logger(`save debounce set to ${this.saveDebounceMs}ms`);
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.logger('disconnected');
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
  }