@rowsncolumns/y-spreadsheet 14.0.32

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 (53) hide show
  1. package/LICENSE.md +100 -0
  2. package/Readme.md +77 -0
  3. package/dist/cjs/helpers.d.ts +179 -0
  4. package/dist/cjs/helpers.d.ts.map +1 -0
  5. package/dist/cjs/helpers.js +1635 -0
  6. package/dist/cjs/index.d.ts +6 -0
  7. package/dist/cjs/index.d.ts.map +1 -0
  8. package/dist/cjs/index.js +8 -0
  9. package/dist/cjs/observables.d.ts +10 -0
  10. package/dist/cjs/observables.d.ts.map +1 -0
  11. package/dist/cjs/observables.js +90 -0
  12. package/dist/cjs/profiling/batch-profile.spec.d.ts +2 -0
  13. package/dist/cjs/profiling/batch-profile.spec.d.ts.map +1 -0
  14. package/dist/cjs/profiling/batch-profile.spec.js +175 -0
  15. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -0
  16. package/dist/cjs/types.d.ts +62 -0
  17. package/dist/cjs/types.d.ts.map +1 -0
  18. package/dist/cjs/types.js +2 -0
  19. package/dist/cjs/use-yspreadsheet-v2.d.ts +152 -0
  20. package/dist/cjs/use-yspreadsheet-v2.d.ts.map +1 -0
  21. package/dist/cjs/use-yspreadsheet-v2.js +980 -0
  22. package/dist/cjs/use-yspreadsheet.d.ts +39 -0
  23. package/dist/cjs/use-yspreadsheet.d.ts.map +1 -0
  24. package/dist/cjs/use-yspreadsheet.js +430 -0
  25. package/dist/cjs/vitest.config.d.ts +3 -0
  26. package/dist/cjs/vitest.config.d.ts.map +1 -0
  27. package/dist/cjs/vitest.config.js +23 -0
  28. package/dist/esm/helpers.d.ts +179 -0
  29. package/dist/esm/helpers.d.ts.map +1 -0
  30. package/dist/esm/helpers.js +1346 -0
  31. package/dist/esm/index.d.ts +6 -0
  32. package/dist/esm/index.d.ts.map +1 -0
  33. package/dist/esm/index.js +5 -0
  34. package/dist/esm/observables.d.ts +10 -0
  35. package/dist/esm/observables.d.ts.map +1 -0
  36. package/dist/esm/observables.js +51 -0
  37. package/dist/esm/profiling/batch-profile.spec.d.ts +2 -0
  38. package/dist/esm/profiling/batch-profile.spec.d.ts.map +1 -0
  39. package/dist/esm/profiling/batch-profile.spec.js +131 -0
  40. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -0
  41. package/dist/esm/types.d.ts +62 -0
  42. package/dist/esm/types.d.ts.map +1 -0
  43. package/dist/esm/types.js +1 -0
  44. package/dist/esm/use-yspreadsheet-v2.d.ts +152 -0
  45. package/dist/esm/use-yspreadsheet-v2.d.ts.map +1 -0
  46. package/dist/esm/use-yspreadsheet-v2.js +814 -0
  47. package/dist/esm/use-yspreadsheet.d.ts +39 -0
  48. package/dist/esm/use-yspreadsheet.d.ts.map +1 -0
  49. package/dist/esm/use-yspreadsheet.js +304 -0
  50. package/dist/esm/vitest.config.d.ts +3 -0
  51. package/dist/esm/vitest.config.d.ts.map +1 -0
  52. package/dist/esm/vitest.config.js +21 -0
  53. package/package.json +54 -0
@@ -0,0 +1,814 @@
1
+ import { createNewSheet, } from "@rowsncolumns/spreadsheet";
2
+ import { getCellUserEnteredValue, getExtendedValueFormula, isAFormula, isNil, sortSheetsByIndex, } from "@rowsncolumns/utils";
3
+ import { useIsomorphicLayoutEffect } from "@rowsncolumns/ui";
4
+ import { useCallback, useMemo, useRef, useState } from "react";
5
+ import { flushSync } from "react-dom";
6
+ import * as Y from "yjs";
7
+ import { addSheetDataPatches, addSheetDataPatchesV3, applyMapPatches, createCellKeyV3, filterErrorCellPatches, getCellDataFromYSheetData, mergeErrorEffectiveValueIntoCellData, processSheetDataEvents, shouldPersistErrorCellPatch, processSheetDataEventsV3, sheetDataV2ToV3, sheetDataV3ToV2, updateArrayType, validateSheets, } from "./helpers";
8
+ /**
9
+ * Convert Y.js events to SheetDataEvent format for incremental processing
10
+ */
11
+ const convertToSheetDataEvents = (events) => {
12
+ return events.map((event) => {
13
+ const isYArrayEvent = event instanceof Y.YArrayEvent;
14
+ return {
15
+ path: event.path,
16
+ delta: isYArrayEvent
17
+ ? event.changes.delta
18
+ : undefined,
19
+ isYArrayEvent,
20
+ };
21
+ });
22
+ };
23
+ /**
24
+ * Read user entered value directly from Y.js data to avoid race conditions
25
+ * with React state updates. This is used when recalcCells observer fires
26
+ * before sheetData observer has updated React state.
27
+ */
28
+ const getUserEnteredValueFromYDoc = (ySheetData, sheetId, rowIndex, columnIndex) => {
29
+ const sheet = ySheetData.get(String(sheetId));
30
+ if (!sheet || rowIndex >= sheet.length)
31
+ return undefined;
32
+ const row = sheet.get(rowIndex);
33
+ if (!row)
34
+ return undefined;
35
+ const values = row.get("values");
36
+ if (!values || columnIndex >= values.length)
37
+ return undefined;
38
+ const cell = values.get(columnIndex);
39
+ // Extract value from ExtendedValue, matching SheetCell.getUserEnteredValue() logic
40
+ // ue is the abbreviated form of userEnteredValue
41
+ const userEnteredValue = getCellUserEnteredValue(cell);
42
+ return getExtendedValueFormula(userEnteredValue);
43
+ };
44
+ /**
45
+ * Read user entered value from V3 Y.js data structure
46
+ */
47
+ const getUserEnteredValueFromYDocV3 = (ySheetDataV3, sheetId, rowIndex, columnIndex) => {
48
+ const cellKey = createCellKeyV3(sheetId, rowIndex, columnIndex);
49
+ const cellDataV3 = ySheetDataV3.get(cellKey);
50
+ return getExtendedValueFormula(getCellUserEnteredValue(cellDataV3?.value));
51
+ };
52
+ import { createDeepObservable, createObservable, createSheetDataObservable, getPatchDeltasFromEvent, } from "./observables";
53
+ // Add a single null entry to the YSheetData
54
+ const origin = "local";
55
+ export const useYSpreadsheetV2 = ({ onChangeSheetData, onChangeSheets, onChangeTables, onChangeCharts, onChangeEmbeds, onChangeConditionalFormats, onChangeDataValidations, onChangeCitations, onAfterBroadcastPatch, recalcCellsMaxEntries, onChangeNamedRanges, onChangeProtectedRanges, onChangePivotTables, enqueueCalculation, enqueueGraphOperation, reevaluateDerivationsForCells, onChangeCellXfs, onChangeSharedStrings, calculateNow, onChangeActiveSheet, clientOnlyFunctionPattern, initialSheets, awarenessScopeId, userId, title, sheetId, activeCell, kind, agentInitiator, selections, doc, provider, useV3 = false, sheetDataVersion = "v2", onMigrationComplete, deleteV2AfterMigration = false, skipInitialSync = false, supportLegacySharedStringsArray = false, }) => {
56
+ const [synced, setSynced] = useState(false);
57
+ const [users, setUsers] = useState([]);
58
+ const [isLeader, setIsLeader] = useState(false);
59
+ const isLeaderRef = useRef(false);
60
+ const resolvedAwarenessScopeId = awarenessScopeId;
61
+ // Listen to awareness
62
+ const awareness = provider?.awareness;
63
+ useIsomorphicLayoutEffect(() => {
64
+ if (!awareness)
65
+ return;
66
+ const updateLeader = () => {
67
+ const entries = Array.from(awareness.getStates().entries());
68
+ const docStates = resolvedAwarenessScopeId
69
+ ? entries.filter(([_, state]) => state?.docGuid === resolvedAwarenessScopeId)
70
+ : entries;
71
+ if (!docStates.length) {
72
+ setIsLeader(false);
73
+ return;
74
+ }
75
+ const states = docStates.map(([clientId]) => clientId);
76
+ const minId = Math.min(...states);
77
+ // Always include self in the elec
78
+ setIsLeader(doc.clientID === minId);
79
+ };
80
+ awareness.on("change", updateLeader);
81
+ updateLeader(); // Initial check
82
+ return () => awareness.off("change", updateLeader);
83
+ }, [awareness, doc, resolvedAwarenessScopeId]);
84
+ useIsomorphicLayoutEffect(() => {
85
+ isLeaderRef.current = isLeader;
86
+ }, [isLeader]);
87
+ const ySheetDataV2 = useMemo(() => doc.getMap("sheetDataV2"), [doc]);
88
+ // V3: Flat map structure - Map<cellKey, CellDataV3>
89
+ // cellKey format: `${sheetId}!${A1Address}` (e.g., "1!A1", "2!B5")
90
+ const ySheetDataV3 = useMemo(() => doc.getMap("sheetDataV3"), [doc]);
91
+ // Doc metadata - used as fallback for version when prop is undefined
92
+ const yDocMeta = useMemo(() => doc.getMap("docMeta"), [doc]);
93
+ const yDisableRecalc = useMemo(() => doc.getMap("disableRecalc"), [doc]);
94
+ // Recalc cells
95
+ const yRecalcCells = useMemo(() => doc.getArray("recalcCells"), [doc]);
96
+ // Make sure initialSheets is always filled
97
+ const getInitialSheets = useCallback(() => {
98
+ if (initialSheets.length) {
99
+ return initialSheets;
100
+ }
101
+ return [createNewSheet(1, "Sheet1")];
102
+ }, [initialSheets]);
103
+ // cellxfs
104
+ const yCellXfs = useMemo(() => doc.getMap("cellXfs"), [doc]);
105
+ // Shared strings - always use Map for new storage, Array kept for migration
106
+ const ySharedStringsArray = useMemo(() => doc.getArray("sharedStrings"), [doc]);
107
+ const ySharedStringsMap = useMemo(() => doc.getMap("sharedStringsMap"), [doc]);
108
+ // Track if initial sync has changed active sheet
109
+ const isActiveSheetChanged = useRef(false);
110
+ /**
111
+ * Handle shared strings updates from the Y.Map (new format).
112
+ * Merges incoming Map entries into the current shared strings state.
113
+ * Used for documents that store shared strings in the Map format.
114
+ */
115
+ const handleSharedStringsMapChange = useCallback((value) => {
116
+ onChangeSharedStrings?.((currentMap) => {
117
+ const newMap = new Map(currentMap);
118
+ for (const [key, val] of value) {
119
+ newMap.set(key, val);
120
+ }
121
+ return newMap;
122
+ });
123
+ }, [onChangeSharedStrings]);
124
+ /**
125
+ * Handle shared strings updates from the Y.Array (legacy format).
126
+ * Converts array indices to string keys and merges into current state.
127
+ * Provides backwards compatibility for older documents using array storage.
128
+ */
129
+ const handleSharedStringsArrayChange = useCallback((value) => {
130
+ onChangeSharedStrings?.((currentMap) => {
131
+ const newMap = new Map(currentMap);
132
+ value.forEach((v, index) => {
133
+ newMap.set(String(index), v);
134
+ });
135
+ return newMap;
136
+ });
137
+ }, [onChangeSharedStrings]);
138
+ const documentMap = useMemo(() => {
139
+ const entries = [
140
+ ["sheets", [doc.getArray("sheets"), onChangeSheets]],
141
+ ["tables", [doc.getArray("tables"), onChangeTables]],
142
+ [
143
+ "embeds",
144
+ [doc.getArray("embeds"), onChangeEmbeds],
145
+ ],
146
+ [
147
+ "charts",
148
+ [doc.getArray("charts"), onChangeCharts],
149
+ ],
150
+ [
151
+ "conditionalFormats",
152
+ [
153
+ doc.getArray("conditionalFormats"),
154
+ onChangeConditionalFormats,
155
+ ],
156
+ ],
157
+ [
158
+ "dataValidations",
159
+ [
160
+ doc.getArray("dataValidations"),
161
+ onChangeDataValidations,
162
+ ],
163
+ ],
164
+ [
165
+ "pivotTables",
166
+ [doc.getArray("pivotTables"), onChangePivotTables],
167
+ ],
168
+ [
169
+ "namedRanges",
170
+ [doc.getArray("namedRanges"), onChangeNamedRanges],
171
+ ],
172
+ [
173
+ "protectedRanges",
174
+ [
175
+ doc.getArray("protectedRanges"),
176
+ onChangeProtectedRanges,
177
+ ],
178
+ ],
179
+ [
180
+ "citations",
181
+ [doc.getArray("citations"), onChangeCitations],
182
+ ],
183
+ [
184
+ "sharedStrings",
185
+ [ySharedStringsMap, handleSharedStringsMapChange],
186
+ ],
187
+ ...(supportLegacySharedStringsArray
188
+ ? [
189
+ [
190
+ "sharedStringsArray",
191
+ [ySharedStringsArray, handleSharedStringsArrayChange],
192
+ ],
193
+ ]
194
+ : []),
195
+ ];
196
+ return new Map(entries);
197
+ }, [
198
+ doc,
199
+ onChangeSheets,
200
+ onChangeTables,
201
+ onChangeEmbeds,
202
+ onChangeCharts,
203
+ onChangeConditionalFormats,
204
+ onChangeDataValidations,
205
+ onChangePivotTables,
206
+ onChangeNamedRanges,
207
+ onChangeProtectedRanges,
208
+ onChangeCitations,
209
+ ySharedStringsMap,
210
+ handleSharedStringsMapChange,
211
+ supportLegacySharedStringsArray,
212
+ ySharedStringsArray,
213
+ handleSharedStringsArrayChange,
214
+ ]);
215
+ // Get backlog of recalc cells from backend
216
+ const getBacklog = useCallback(() => {
217
+ return yRecalcCells
218
+ .toArray()
219
+ .flatMap((entry) => entry.patches)
220
+ .filter(([, , source]) => source === "backend" || source === "agent");
221
+ }, [yRecalcCells]);
222
+ // Trigger cell recalcs
223
+ const syncRecalcCells = useCallback((recalcCells, origin) => {
224
+ for (const [_, position, source] of recalcCells) {
225
+ // backend will calculate results
226
+ const isAgent = source === "agent"; // Agent already does the calculation
227
+ // Read directly from Y.js to avoid race condition where recalcCells
228
+ // observer fires before sheetData observer has updated React state
229
+ const formulaValue = useV3
230
+ ? getUserEnteredValueFromYDocV3(ySheetDataV3, position.sheetId, position.rowIndex, position.columnIndex)
231
+ : getUserEnteredValueFromYDoc(ySheetDataV2, position.sheetId, position.rowIndex, position.columnIndex);
232
+ // Was this from a changeBatch event in the back-end?
233
+ // To support client functions to run only
234
+ const isChangeBatch = position.force && isAgent;
235
+ const isClientOnly = !isNil(formulaValue) &&
236
+ !isNil(clientOnlyFunctionPattern) &&
237
+ isAFormula(formulaValue)
238
+ ? clientOnlyFunctionPattern.test(String(formulaValue))
239
+ : false;
240
+ /**
241
+ * Determines whether to force immediate execution of the operation.
242
+ *
243
+ * Force execution is allowed only for leaders and under these conditions:
244
+ * - No source specified (backwards compatibility - treats as force exec)
245
+ * - Source is "backend" (backend-initiated changes)
246
+ * - Client-only mode with change batch (batch operations need immediate execution)
247
+ *
248
+ * Note: Agent sources never force execution since they've pre-calculated results.
249
+ */
250
+ const forceExecution = isLeaderRef.current &&
251
+ (!source || // backwards compatible: no source means force exec for leader
252
+ source === "backend" ||
253
+ (isClientOnly && isChangeBatch));
254
+ const enqueue = forceExecution
255
+ ? enqueueCalculation
256
+ : enqueueGraphOperation;
257
+ /**
258
+ * Selects the appropriate queue function for the operation.
259
+ *
260
+ * - enqueueCalculation: Used when forcing execution (immediate formula evaluation)
261
+ * - enqueueGraphOperation: Used for normal graph operations (no formula evaluation)
262
+ */
263
+ enqueue?.({
264
+ type: "add",
265
+ position,
266
+ source,
267
+ force: forceExecution,
268
+ });
269
+ }
270
+ // The agent/backend pre-computes cell VALUES (hence the graph-only
271
+ // enqueues above), but conditional-format / data-validation RESULTS
272
+ // are derived on the client — re-evaluate them for the changed cells
273
+ // so their paint tracks the new values.
274
+ if (recalcCells.length) {
275
+ reevaluateDerivationsForCells?.(recalcCells.map(([, position]) => ({
276
+ sheetId: position.sheetId,
277
+ rowIndex: position.rowIndex,
278
+ columnIndex: position.columnIndex,
279
+ })));
280
+ }
281
+ }, [
282
+ synced,
283
+ clientOnlyFunctionPattern,
284
+ ySheetDataV2,
285
+ ySheetDataV3,
286
+ useV3,
287
+ reevaluateDerivationsForCells,
288
+ ]);
289
+ // Setup initial sync
290
+ const setupInitialSync = useCallback(() => {
291
+ // Migration from sharedStrings (Array) to sharedStringsMap (Map) is disabled.
292
+ // Both formats are now supported simultaneously via handleSharedStringsMapChange
293
+ // and handleSharedStringsArrayChange callbacks.
294
+ const arrayData = ySharedStringsArray.toArray();
295
+ const mapData = ySharedStringsMap.toJSON();
296
+ const hasArrayData = arrayData.length > 0;
297
+ const isMapEmpty = Object.keys(mapData).length === 0;
298
+ // Migrate if array has data and map is empty
299
+ if (!supportLegacySharedStringsArray && hasArrayData && isMapEmpty) {
300
+ doc.transact(() => {
301
+ // Convert array to map with string keys
302
+ arrayData.forEach((value, index) => {
303
+ ySharedStringsMap.set(String(index), value);
304
+ });
305
+ // Optional: Clear the old array to save space
306
+ // Uncomment if you want to remove old data after migration
307
+ // ySharedStringsArray.delete(0, ySharedStringsArray.length);
308
+ }, "migration");
309
+ }
310
+ for (const [mappingKey, [ydoc, callback]] of documentMap.entries()) {
311
+ let value = ydoc.toJSON();
312
+ if (mappingKey === "sheets") {
313
+ const sheets = (Array.isArray(value) && value.length
314
+ ? validateSheets(value)
315
+ : getInitialSheets());
316
+ callback?.(sheets);
317
+ // Set active sheet id
318
+ if (sheets.length && !isActiveSheetChanged.current) {
319
+ onChangeActiveSheet?.(sortSheetsByIndex(sheets)[0].sheetId);
320
+ isActiveSheetChanged.current = true;
321
+ }
322
+ }
323
+ else if (mappingKey === "sharedStrings") {
324
+ // Convert Map object to Map instance for shared strings
325
+ const sharedStringsMap = new Map(Object.entries(value ?? {}));
326
+ callback?.(sharedStringsMap);
327
+ }
328
+ else {
329
+ callback?.(value);
330
+ }
331
+ }
332
+ // Load sheet data based on V2 or V3 mode
333
+ if (useV3) {
334
+ // Check if we need to migrate from V2 to V3
335
+ // Use prop if provided, otherwise fall back to yDocMeta for backwards compatibility
336
+ const resolvedVersion = sheetDataVersion ?? yDocMeta.get("sheetDataVersion");
337
+ if (resolvedVersion !== "v3") {
338
+ const v2Data = ySheetDataV2.toJSON();
339
+ const hasV2Data = Object.keys(v2Data).length > 0;
340
+ if (hasV2Data) {
341
+ // Migrate V2 data to V3
342
+ doc.transact(() => {
343
+ const v3Data = sheetDataV2ToV3(v2Data);
344
+ // Add all V3 entries to the Y.Map
345
+ for (const [key, cellData] of v3Data.entries()) {
346
+ ySheetDataV3.set(key, cellData);
347
+ }
348
+ // Optionally clear V2 data after migration
349
+ if (deleteV2AfterMigration) {
350
+ for (const sheetId of ySheetDataV2.keys()) {
351
+ ySheetDataV2.delete(sheetId);
352
+ }
353
+ }
354
+ }, "migrate-v2-to-v3");
355
+ // Notify backend that migration completed
356
+ onMigrationComplete?.("v3");
357
+ }
358
+ else {
359
+ // No V2 data, notify backend of version
360
+ onMigrationComplete?.("v3");
361
+ }
362
+ }
363
+ // V3: Convert flat map to V2 format for React state
364
+ const v3Data = ySheetDataV3.toJSON();
365
+ const v3Map = new Map(Object.entries(v3Data));
366
+ onChangeSheetData?.(sheetDataV3ToV2(v3Map));
367
+ }
368
+ else {
369
+ onChangeSheetData?.(ySheetDataV2.toJSON());
370
+ }
371
+ // Cellxfs
372
+ const cellXfs = new Map(Object.entries(yCellXfs.toJSON() ?? {}));
373
+ onChangeCellXfs?.(cellXfs);
374
+ }, [
375
+ documentMap,
376
+ onChangeCellXfs,
377
+ onChangeSheetData,
378
+ ySheetDataV2,
379
+ ySheetDataV3,
380
+ yDocMeta,
381
+ yCellXfs,
382
+ getInitialSheets,
383
+ onChangeActiveSheet,
384
+ ySharedStringsArray,
385
+ ySharedStringsMap,
386
+ doc,
387
+ useV3,
388
+ sheetDataVersion,
389
+ onMigrationComplete,
390
+ deleteV2AfterMigration,
391
+ ]);
392
+ // Synced
393
+ const onSynced = useCallback(() => {
394
+ if (!synced) {
395
+ if (!skipInitialSync) {
396
+ // Use flushSync to ensure React state is updated before continuing
397
+ // This guarantees sheetData is available when syncRecalcCells runs
398
+ flushSync(() => {
399
+ setupInitialSync();
400
+ });
401
+ // Build dependency graph, then sync backlog cells
402
+ requestAnimationFrame(async () => {
403
+ await calculateNow({
404
+ disableEvaluation: true,
405
+ shouldResetCellDependencyGraph: true,
406
+ });
407
+ // Sync backlog after graph is initialized
408
+ const backlog = getBacklog();
409
+ if (backlog.length) {
410
+ syncRecalcCells(backlog, "backend");
411
+ // Clear processed backlog entries from yRecalcCells
412
+ doc.transact(() => {
413
+ yRecalcCells.delete(0, yRecalcCells.length);
414
+ }, origin);
415
+ }
416
+ });
417
+ }
418
+ setSynced(true);
419
+ }
420
+ }, [
421
+ calculateNow,
422
+ setupInitialSync,
423
+ synced,
424
+ syncRecalcCells,
425
+ getBacklog,
426
+ doc,
427
+ yRecalcCells,
428
+ skipInitialSync,
429
+ ]);
430
+ // Sync state
431
+ if (provider?.synced && !synced) {
432
+ onSynced();
433
+ }
434
+ useIsomorphicLayoutEffect(() => {
435
+ provider?.on("synced", onSynced);
436
+ return () => {
437
+ provider?.off("synced", onSynced);
438
+ };
439
+ }, [provider]);
440
+ // Only for sheet Data (V2 mode)
441
+ useIsomorphicLayoutEffect(() => {
442
+ if (useV3)
443
+ return; // Skip V2 observer in V3 mode
444
+ // Subscribe to sheet data
445
+ const subscription = createSheetDataObservable(ySheetDataV2).subscribe((events) => {
446
+ /**
447
+ * Disable recalc is a flag that is set ONLY locally
448
+ * when a calculation completes
449
+ *
450
+ * This is to prevent yjs from observing sheetdata
451
+ * and updating it, which is not necessary
452
+ */
453
+ if (events.length) {
454
+ const origin = events?.[0]?.transaction?.origin;
455
+ if (origin !== "local") {
456
+ // Convert Y.js events to SheetDataEvent format and apply incremental updates
457
+ const sheetDataEvents = convertToSheetDataEvents(events);
458
+ onChangeSheetData((prevSheetData) => {
459
+ const result = processSheetDataEvents(sheetDataEvents, prevSheetData, ySheetDataV2);
460
+ // Fall back to full serialization only if incremental update fails
461
+ return result ?? ySheetDataV2.toJSON();
462
+ });
463
+ }
464
+ }
465
+ });
466
+ return () => {
467
+ subscription.unsubscribe();
468
+ };
469
+ }, [onChangeSheetData, ySheetDataV2, useV3]);
470
+ // Only for sheet Data (V3 mode)
471
+ useIsomorphicLayoutEffect(() => {
472
+ if (!useV3)
473
+ return; // Skip V3 observer in V2 mode
474
+ // Subscribe to V3 sheet data (flat Map)
475
+ const callback = (event) => {
476
+ if (event.transaction.origin !== "local") {
477
+ // Extract changes synchronously - Yjs requires accessing event.changes
478
+ // within the event handler, not in a deferred callback like setState
479
+ const changedKeys = new Map();
480
+ event.changes.keys.forEach((change, key) => {
481
+ changedKeys.set(key, { action: change.action });
482
+ });
483
+ // Now we can safely use the extracted data in the async setState callback
484
+ onChangeSheetData((prevSheetData) => {
485
+ const result = processSheetDataEventsV3(changedKeys, prevSheetData, ySheetDataV3);
486
+ if (result) {
487
+ return result;
488
+ }
489
+ // Fall back to full conversion
490
+ const v3Data = ySheetDataV3.toJSON();
491
+ const v3Map = new Map(Object.entries(v3Data));
492
+ return sheetDataV3ToV2(v3Map);
493
+ });
494
+ }
495
+ };
496
+ ySheetDataV3.observe(callback);
497
+ return () => {
498
+ ySheetDataV3.unobserve(callback);
499
+ };
500
+ }, [onChangeSheetData, ySheetDataV3, useV3]);
501
+ // CellXfs
502
+ useIsomorphicLayoutEffect(() => {
503
+ // Subscribe to sheet data
504
+ const callback = (event) => {
505
+ if (event.transaction.origin !== "local") {
506
+ const cellXfs = new Map(Object.entries(yCellXfs.toJSON() ?? {}));
507
+ onChangeCellXfs?.(cellXfs);
508
+ }
509
+ };
510
+ yCellXfs.observe(callback);
511
+ return () => {
512
+ yCellXfs.unobserve(callback);
513
+ };
514
+ }, [onChangeCellXfs, yCellXfs]);
515
+ // All others
516
+ useIsomorphicLayoutEffect(() => {
517
+ const subs = [];
518
+ for (const [mappingKey, [ydoc, callback]] of documentMap.entries()) {
519
+ // Only observe Arrays (shared strings Map is handled separately if needed)
520
+ if (ydoc instanceof Y.Array) {
521
+ const subscription = createDeepObservable(ydoc).subscribe((events) => {
522
+ for (const event of events) {
523
+ if (event.transaction.origin !== "local") {
524
+ let json = ydoc.toJSON();
525
+ if (Array.isArray(json) &&
526
+ json.length &&
527
+ json[0]?.userId) {
528
+ continue;
529
+ }
530
+ callback?.(json);
531
+ }
532
+ }
533
+ });
534
+ subs.push(subscription);
535
+ }
536
+ else if (ydoc instanceof Y.Map && mappingKey === "sharedStrings") {
537
+ // Handle shared strings Map observation
538
+ const callback_map = (event) => {
539
+ if (event.transaction.origin !== "local") {
540
+ const json = ydoc.toJSON();
541
+ const sharedStringsMap = new Map(Object.entries(json ?? {}));
542
+ callback?.(sharedStringsMap);
543
+ }
544
+ };
545
+ ydoc.observe(callback_map);
546
+ subs.push({
547
+ unsubscribe: () => ydoc.unobserve(callback_map),
548
+ });
549
+ }
550
+ }
551
+ return () => {
552
+ subs.forEach((sub) => sub.unsubscribe());
553
+ };
554
+ }, [documentMap]);
555
+ // Only for recalc cells
556
+ useIsomorphicLayoutEffect(() => {
557
+ if (!synced) {
558
+ return;
559
+ }
560
+ // Sync all cells that are updated in the backend
561
+ // // Observe recalc cells separat
562
+ const onRecalcCellsChange = (event) => {
563
+ const patches = getPatchDeltasFromEvent(event);
564
+ const origin = event.transaction.origin;
565
+ if (origin !== "local") {
566
+ requestAnimationFrame(() => {
567
+ syncRecalcCells(patches, origin);
568
+ });
569
+ }
570
+ };
571
+ // Skip the first recalc cells change
572
+ const sub = createObservable(yRecalcCells).subscribe(onRecalcCellsChange);
573
+ return () => sub.unsubscribe();
574
+ }, [syncRecalcCells, recalcCellsMaxEntries, synced, yRecalcCells]);
575
+ // Broadcast patch
576
+ const onBroadcastPatch = useCallback((patches) => {
577
+ // Ignore push flag
578
+ const disableRecalc = Boolean(patches.find(([patch]) => patch?.disableRecalc));
579
+ // Check if this is a full recalc
580
+ const isFullRecalc = Boolean(patches.find(([patch]) => patch?.isFullRecalc));
581
+ const disableSync = isFullRecalc ? false : disableRecalc && !isLeader;
582
+ // Only leader can broadcast recalc patches. Error effective values are
583
+ // the exception: async custom-function failures (#VALUE!, #NAME?, …)
584
+ // only exist as disableRecalc calc-result patches, and the leader's
585
+ // write is deferred/lossy — if non-leaders drop them too, the error
586
+ // paints during the live session but the cell is blank after reload.
587
+ // Sync just the error-bearing cell patches; ordinary recalc results
588
+ // stay leader-gated so N clients don't all write identical results.
589
+ if (disableSync) {
590
+ const errorCellPatches = [];
591
+ for (let [patch, type = "redo"] of patches) {
592
+ if (!patch?.sheetData) {
593
+ continue;
594
+ }
595
+ type = type ?? "redo";
596
+ const patchKey = type === "redo" ? "patches" : "inversePatches";
597
+ const sheetDataPatches = patch.sheetData[patchKey];
598
+ if (sheetDataPatches?.length) {
599
+ errorCellPatches.push(...filterErrorCellPatches(sheetDataPatches));
600
+ }
601
+ }
602
+ // Containment for this non-leader write path: an error may only
603
+ // fill a cell with no effective value (the blank-after-reload case)
604
+ // or replace an existing error — never a non-error result; a cell
605
+ // whose formula (ue) changed since the calculation started is
606
+ // skipped; identical persisted errors are skipped so concurrent
607
+ // non-leaders don't append redundant Y.js updates; and a proceeding
608
+ // write merges ONLY the error effective value into the existing doc
609
+ // cell, preserving every other persisted field.
610
+ const persistableErrorCellPatches = [];
611
+ for (const errorPatch of errorCellPatches) {
612
+ const [patchSheetId, rowIndex, , columnIndex] = errorPatch.path;
613
+ const currentCellData = useV3
614
+ ? ySheetDataV3.get(createCellKeyV3(Number(patchSheetId), Number(rowIndex), Number(columnIndex)))?.value
615
+ : getCellDataFromYSheetData(ySheetDataV2, patchSheetId, Number(rowIndex), Number(columnIndex));
616
+ const incomingCellData = errorPatch.value;
617
+ if (!shouldPersistErrorCellPatch(currentCellData, incomingCellData)) {
618
+ continue;
619
+ }
620
+ persistableErrorCellPatches.push({
621
+ ...errorPatch,
622
+ value: mergeErrorEffectiveValueIntoCellData(currentCellData, incomingCellData),
623
+ });
624
+ }
625
+ if (persistableErrorCellPatches.length) {
626
+ doc.transact(() => {
627
+ if (useV3) {
628
+ addSheetDataPatchesV3(ySheetDataV3, persistableErrorCellPatches);
629
+ }
630
+ else {
631
+ addSheetDataPatches(ySheetDataV2, persistableErrorCellPatches);
632
+ }
633
+ }, origin);
634
+ }
635
+ return;
636
+ }
637
+ doc.transact(() => {
638
+ for (let [patch, type = "redo"] of patches) {
639
+ // Check for existence of patch
640
+ // Investigate when when importing csv, it create a [undefined, undefined] patch object
641
+ // todo @Vinay
642
+ if (!patch) {
643
+ continue;
644
+ }
645
+ type = type ?? "redo";
646
+ const patchKey = type === "redo" ? "patches" : "inversePatches";
647
+ if (patch.sheetData) {
648
+ const patches = patch.sheetData?.[patchKey];
649
+ if (useV3) {
650
+ addSheetDataPatchesV3(ySheetDataV3, patches);
651
+ }
652
+ else {
653
+ addSheetDataPatches(ySheetDataV2, patches);
654
+ }
655
+ }
656
+ if (patch.cellXfs) {
657
+ const patches = patch.cellXfs?.[patchKey];
658
+ applyMapPatches(yCellXfs, patches);
659
+ }
660
+ for (const [mappingKey, [ydoc]] of documentMap.entries()) {
661
+ if (patch?.[mappingKey]) {
662
+ const patches = patch[mappingKey]?.[patchKey];
663
+ // Handle shared strings Map (always use Map now)
664
+ if (mappingKey === "sharedStrings" && ydoc instanceof Y.Map) {
665
+ applyMapPatches(ydoc, patches);
666
+ }
667
+ else if (ydoc instanceof Y.Array) {
668
+ // Check if we are updating sheets
669
+ if (mappingKey === "sheets" && ydoc.length === 0) {
670
+ // Add initial sheets
671
+ ydoc.insert(0, getInitialSheets());
672
+ }
673
+ updateArrayType(ydoc, patches);
674
+ }
675
+ }
676
+ }
677
+ // Recalc cells
678
+ if (patch.recalcCells?.[type]) {
679
+ if (isLeader) {
680
+ // Remove old cells
681
+ yRecalcCells.delete(0, yRecalcCells.length);
682
+ }
683
+ yRecalcCells.push([
684
+ {
685
+ userId,
686
+ patches: Array.from(patch.recalcCells[type]),
687
+ },
688
+ ]);
689
+ }
690
+ }
691
+ }, origin);
692
+ onAfterBroadcastPatch?.(patches, doc);
693
+ }, [
694
+ onAfterBroadcastPatch,
695
+ documentMap,
696
+ yRecalcCells,
697
+ doc,
698
+ userId,
699
+ yDisableRecalc,
700
+ ySheetDataV2,
701
+ ySheetDataV3,
702
+ isLeader,
703
+ recalcCellsMaxEntries,
704
+ useV3,
705
+ ]);
706
+ // Listen to active cell change
707
+ useIsomorphicLayoutEffect(() => {
708
+ if (activeCell) {
709
+ awareness?.setLocalStateField("activeCell", activeCell);
710
+ }
711
+ }, [awareness, activeCell]);
712
+ // Listen to sheet change
713
+ useIsomorphicLayoutEffect(() => {
714
+ awareness?.setLocalStateField("sheetId", sheetId);
715
+ }, [awareness, sheetId]);
716
+ // Listen to selection range change
717
+ useIsomorphicLayoutEffect(() => {
718
+ if (selections && selections.length > 0) {
719
+ awareness?.setLocalStateField("selections", selections);
720
+ }
721
+ }, [awareness, selections]);
722
+ // Awareness
723
+ useIsomorphicLayoutEffect(() => {
724
+ const onChangeAwareness = () => {
725
+ if (awareness) {
726
+ const collaborators = [];
727
+ for (const [_, value] of awareness.getStates().entries()) {
728
+ if (!value)
729
+ continue;
730
+ // States with no docGuid are accepted as a wildcard so REST
731
+ // writers or pre-scoped clients still appear as collaborators.
732
+ const docMatches = !resolvedAwarenessScopeId ||
733
+ !value.docGuid ||
734
+ value.docGuid === resolvedAwarenessScopeId;
735
+ if (!docMatches)
736
+ continue;
737
+ // Canonical shape is the nested `state.user = { kind, name, userId,
738
+ // agentInitiator? }` used by Olympus, pptx-studio, and the keryx
739
+ // agentTask helper. Fall back to legacy flat fields for one
740
+ // release so callers can migrate at their own pace.
741
+ const userBlock = (value.user ?? null);
742
+ const resolvedUserId = userBlock?.userId ?? value.userId;
743
+ const resolvedTitle = userBlock?.name ?? value.title;
744
+ const resolvedKind = userBlock?.kind ?? value.kind;
745
+ const resolvedAgentInitiator = userBlock?.agentInitiator ?? value.agentInitiator;
746
+ if (!resolvedUserId)
747
+ continue;
748
+ if (!value.activeCell && !value.selections)
749
+ continue;
750
+ collaborators.push({
751
+ activeCell: value.activeCell,
752
+ title: (resolvedTitle ?? ""),
753
+ userId: resolvedUserId,
754
+ sheetId: value.sheetId,
755
+ kind: resolvedKind,
756
+ agentInitiator: resolvedAgentInitiator,
757
+ selections: value.selections,
758
+ });
759
+ }
760
+ setUsers(collaborators);
761
+ }
762
+ };
763
+ // Listen to awareness
764
+ awareness?.on("change", onChangeAwareness);
765
+ return () => {
766
+ awareness?.off("change", onChangeAwareness);
767
+ };
768
+ }, [awareness, resolvedAwarenessScopeId]);
769
+ // Set local awareness state.
770
+ //
771
+ // Use setLocalStateField for each field individually so sibling consumers
772
+ // (Olympus's unified presence layer, agent-task helpers, etc.) can compose
773
+ // their own fields on the same awareness state without us clobbering them.
774
+ useIsomorphicLayoutEffect(() => {
775
+ if (!awareness)
776
+ return;
777
+ awareness.setLocalStateField("userId", userId);
778
+ awareness.setLocalStateField("title", title);
779
+ awareness.setLocalStateField("sheetId", sheetId);
780
+ if (resolvedAwarenessScopeId) {
781
+ awareness.setLocalStateField("docGuid", resolvedAwarenessScopeId);
782
+ }
783
+ // Mirror the canonical nested shape so other Athena editors can read
784
+ // state.user.* without falling back to the legacy flat fields.
785
+ const userBlock = {
786
+ userId,
787
+ name: title,
788
+ };
789
+ if (kind)
790
+ userBlock.kind = kind;
791
+ if (agentInitiator)
792
+ userBlock.agentInitiator = agentInitiator;
793
+ awareness.setLocalStateField("user", userBlock);
794
+ if (kind)
795
+ awareness.setLocalStateField("kind", kind);
796
+ if (agentInitiator)
797
+ awareness.setLocalStateField("agentInitiator", agentInitiator);
798
+ }, [
799
+ awareness,
800
+ resolvedAwarenessScopeId,
801
+ userId,
802
+ title,
803
+ sheetId,
804
+ kind,
805
+ agentInitiator,
806
+ ]);
807
+ return {
808
+ synced,
809
+ users,
810
+ onBroadcastPatch,
811
+ isLeader,
812
+ setupInitialSync,
813
+ };
814
+ };