@casualoffice/sheets 0.9.0 → 0.10.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 (67) hide show
  1. package/dist/api-BI2VLYQ6.d.cts +62 -0
  2. package/dist/api-BI2VLYQ6.d.ts +62 -0
  3. package/dist/chrome.cjs +2569 -0
  4. package/dist/chrome.cjs.map +1 -0
  5. package/dist/chrome.d.cts +96 -0
  6. package/dist/chrome.d.ts +96 -0
  7. package/dist/chrome.js +2556 -0
  8. package/dist/chrome.js.map +1 -0
  9. package/dist/collab.cjs +770 -0
  10. package/dist/collab.cjs.map +1 -0
  11. package/dist/collab.d.cts +248 -0
  12. package/dist/collab.d.ts +248 -0
  13. package/dist/collab.js +737 -0
  14. package/dist/collab.js.map +1 -0
  15. package/dist/embed/embed-runtime.js +128 -128
  16. package/dist/embed.cjs +166 -0
  17. package/dist/embed.cjs.map +1 -1
  18. package/dist/embed.d.cts +78 -3
  19. package/dist/embed.d.ts +78 -3
  20. package/dist/embed.js +166 -0
  21. package/dist/embed.js.map +1 -1
  22. package/dist/index.cjs +262 -165
  23. package/dist/index.cjs.map +1 -1
  24. package/dist/index.d.cts +4 -3
  25. package/dist/index.d.ts +4 -3
  26. package/dist/index.js +271 -168
  27. package/dist/index.js.map +1 -1
  28. package/dist/{protocol--KyBQUjU.d.cts → protocol-Cq4Cdoyi.d.cts} +19 -2
  29. package/dist/{protocol-cEzy7S0i.d.ts → protocol-DqDaG2yG.d.ts} +19 -2
  30. package/dist/sheets.cjs +102 -16
  31. package/dist/sheets.cjs.map +1 -1
  32. package/dist/sheets.d.cts +49 -63
  33. package/dist/sheets.d.ts +49 -63
  34. package/dist/sheets.js +111 -19
  35. package/dist/sheets.js.map +1 -1
  36. package/package.json +28 -3
  37. package/src/chrome/AutoSumPicker.tsx +176 -0
  38. package/src/chrome/BordersPicker.tsx +171 -0
  39. package/src/chrome/ChromeBottom.tsx +18 -0
  40. package/src/chrome/ChromeTop.tsx +21 -0
  41. package/src/chrome/ColorPicker.tsx +220 -0
  42. package/src/chrome/FindReplace.tsx +370 -0
  43. package/src/chrome/FormulaBar.tsx +378 -0
  44. package/src/chrome/Icon.tsx +43 -0
  45. package/src/chrome/MenuBar.tsx +336 -0
  46. package/src/chrome/NameBox.tsx +347 -0
  47. package/src/chrome/SheetTabs.tsx +346 -0
  48. package/src/chrome/StatusBar.tsx +232 -0
  49. package/src/chrome/Toolbar.tsx +401 -0
  50. package/src/chrome/fonts.ts +42 -0
  51. package/src/chrome/index.ts +24 -0
  52. package/src/collab/attachCollab.ts +151 -0
  53. package/src/collab/bridge-helpers.ts +97 -0
  54. package/src/collab/bridge.ts +885 -0
  55. package/src/collab/bridge.unit.test.ts +160 -0
  56. package/src/collab/index.ts +38 -0
  57. package/src/collab/replay-retry.ts +137 -0
  58. package/src/collab/replay-retry.unit.test.ts +223 -0
  59. package/src/collab/ws-url.ts +20 -0
  60. package/src/collab/ws-url.unit.test.ts +35 -0
  61. package/src/embed/EmbedHostTransport.ts +16 -1
  62. package/src/embed/EmbedTransport.ts +16 -0
  63. package/src/embed/EmbedTransport.unit.test.ts +88 -2
  64. package/src/embed/index.ts +7 -0
  65. package/src/embed/protocol.ts +34 -0
  66. package/src/embed-runtime/index.tsx +20 -0
  67. package/src/sheets/CasualSheets.tsx +204 -33
@@ -0,0 +1,885 @@
1
+ import type * as Y from 'yjs';
2
+ import type { FUniver } from '@univerjs/core/facade';
3
+ import type { IWorkbookData } from '@univerjs/core';
4
+ import { ICommandService, type ICommandInfo, type IExecutionOptions } from '@univerjs/core';
5
+ import { SetRangeValuesUndoMutationFactory } from '@univerjs/sheets';
6
+ import { deepRewriteUnitId, rewriteJson1OpPathUnitId } from './bridge-helpers';
7
+ import { ensurePluginByName, type LazyPluginGroup } from '../univer';
8
+ import {
9
+ classifyReplayError,
10
+ pushDeadLetter,
11
+ TRANSIENT_RETRY_DELAYS_MS,
12
+ withRetry,
13
+ type ReplayFailureRecord,
14
+ } from './replay-retry';
15
+
16
+ /**
17
+ * Map mutation ids to the lazy-plugin group that owns the matching
18
+ * mutation handler. The joiner replays peer mutations through Univer's
19
+ * command service; if the receiving plugin hasn't been loaded yet
20
+ * (lazy bundling), the mutation handler is missing and the change
21
+ * silently drops on that peer. Bridge waits for the plugin to mount
22
+ * before executing the mutation.
23
+ */
24
+ const MUTATION_TO_LAZY_GROUP: Record<string, LazyPluginGroup> = {
25
+ 'sheet.mutation.add-conditional-rule': 'cf',
26
+ 'sheet.mutation.set-conditional-rule': 'cf',
27
+ 'sheet.mutation.delete-conditional-rule': 'cf',
28
+ 'sheet.mutation.move-conditional-rule': 'cf',
29
+ 'sheet.mutation.add-table': 'table',
30
+ 'sheet.mutation.delete-table': 'table',
31
+ 'sheet.mutation.set-sheet-table': 'table',
32
+ 'sheet.mutation.set-table-filter': 'table',
33
+ 'sheet.mutation.set-filter-criteria': 'filter',
34
+ 'sheet.mutation.set-filter-range': 'filter',
35
+ 'sheet.mutation.remove-filter': 'filter',
36
+ 'sheet.mutation.update-note': 'note',
37
+ 'sheet.mutation.remove-note': 'note',
38
+ 'sheet.mutation.add-hyper-link': 'hyperlink',
39
+ 'sheet.mutation.remove-hyper-link': 'hyperlink',
40
+ 'sheet.mutation.update-hyper-link': 'hyperlink',
41
+ 'data-validation.mutation.addRule': 'dv',
42
+ 'data-validation.mutation.removeRule': 'dv',
43
+ 'data-validation.mutation.updateRule': 'dv',
44
+ 'sheet.mutation.set-drawing-apply': 'drawing',
45
+ };
46
+ // y-protocols ships type declarations only as ESM and our tsconfig
47
+ // doesn't pick them up cleanly; loose-type the Awareness surface we
48
+ // actually use (getStates → Map keyed by clientID).
49
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
+ type Awareness = { getStates(): Map<number, any> };
51
+
52
+ /**
53
+ * Yjs ↔ Univer mutation bridge. See docs/CO-EDITING.md for the design.
54
+ *
55
+ * Strategy: every non-collab mutation gets serialized into a Y.Array log;
56
+ * peers replay each entry with `fromCollab: true`. The log is the source
57
+ * of truth — late joiners read the whole array on connect via Yjs sync
58
+ * and replay it once, ending up at the same state.
59
+ *
60
+ * Why an op log (not a state mirror): writing a per-mutation state mirror
61
+ * for every mutation Univer emits (set-range-values, set-style, insert-row,
62
+ * merge, hide-col, freeze, …) is dozens of handlers. The log generalizes
63
+ * — any deterministic mutation just round-trips its params. Trade-off: no
64
+ * per-cell CRDT merging on concurrent writes (Yjs orders inserts, then
65
+ * Univer re-executes them; last writer wins at the mutation level). For
66
+ * v1 that matches expectations.
67
+ *
68
+ * Echo-loop guard (per CLAUDE.md):
69
+ * - Records carry the emitter's `clientId`. The observer skips records
70
+ * it emitted itself (otherwise we'd double-apply our own writes).
71
+ * - Remote applies pass `fromCollab: true` so Univer's
72
+ * `onMutationExecutedForCollab` listener filters them back out via the
73
+ * options check below.
74
+ */
75
+
76
+ const LOG_KEY = 'ops';
77
+
78
+ /**
79
+ * Allowlist of mutation ids we sync. Listed explicitly to keep
80
+ * undocumented / version-volatile mutations out of the log — anything
81
+ * not here just stays local. Easier to add new ids than to debug a
82
+ * silent corruption from a mutation that secretly references local
83
+ * state (selections, render skeletons, etc.).
84
+ */
85
+ export const SYNCED_MUTATIONS: ReadonlySet<string> = new Set([
86
+ // Cell-level — values, formulas, styles, rich text.
87
+ 'sheet.mutation.set-range-values',
88
+ 'sheet.mutation.set-style',
89
+ // Row / column structural.
90
+ 'sheet.mutation.insert-row',
91
+ 'sheet.mutation.insert-col',
92
+ 'sheet.mutation.remove-row',
93
+ 'sheet.mutation.remove-col',
94
+ 'sheet.mutation.move-rows',
95
+ 'sheet.mutation.move-cols',
96
+ 'sheet.mutation.set-row-hidden',
97
+ 'sheet.mutation.set-row-visible',
98
+ 'sheet.mutation.set-col-hidden',
99
+ 'sheet.mutation.set-col-visible',
100
+ 'sheet.mutation.set-worksheet-row-height',
101
+ 'sheet.mutation.set-worksheet-row-is-auto-height',
102
+ 'sheet.mutation.set-worksheet-col-width',
103
+ // Merges.
104
+ 'sheet.mutation.add-worksheet-merge',
105
+ 'sheet.mutation.remove-worksheet-merge',
106
+ // Sheet lifecycle.
107
+ 'sheet.mutation.insert-sheet',
108
+ 'sheet.mutation.remove-sheet',
109
+ 'sheet.mutation.set-worksheet-name',
110
+ 'sheet.mutation.set-worksheet-order',
111
+ // Sheet visibility — hide/show. NB: `set-worksheet-activate` is
112
+ // deliberately omitted so each peer keeps their own active sheet
113
+ // independent of which sheet another user is editing.
114
+ 'sheet.mutation.set-worksheet-hidden',
115
+ // Freeze.
116
+ 'sheet.mutation.set-frozen',
117
+ // Hyperlinks (sheets-hyper-link).
118
+ 'sheet.mutation.add-hyper-link',
119
+ 'sheet.mutation.remove-hyper-link',
120
+ 'sheet.mutation.update-hyper-link',
121
+ // Tab colour — picks up the right-click "Tab color" menu.
122
+ 'sheet.mutation.set-tab-color',
123
+ // Move + sort. Without these, a peer's cut-and-paste-cell-block or
124
+ // sort-range action silently doesn't appear on receivers.
125
+ 'sheet.mutation.move-range',
126
+ 'sheet.mutation.reorder-range',
127
+ // Per-row / per-column metadata (height, custom style, colData).
128
+ // The narrower set-worksheet-row-height / set-worksheet-col-width
129
+ // are already allowlisted; these are the broader resource-style
130
+ // mutations Univer emits for "Format → Row/Column" operations.
131
+ 'sheet.mutation.set-row-data',
132
+ 'sheet.mutation.set-col-data',
133
+ 'sheet.mutation.set-worksheet-default-style',
134
+ // Format-as-table / sheets-table — adds/removes named tables and
135
+ // their config. Picked up so the table chrome appears on both peers.
136
+ 'sheet.mutation.add-table',
137
+ 'sheet.mutation.delete-table',
138
+ 'sheet.mutation.set-sheet-table',
139
+ 'sheet.mutation.set-table-filter',
140
+ // Autofilter (sheets-filter).
141
+ 'sheet.mutation.set-filter-criteria',
142
+ 'sheet.mutation.set-filter-range',
143
+ 'sheet.mutation.remove-filter',
144
+ // Notes (sheets-note) — the small cell-corner indicator + popup.
145
+ 'sheet.mutation.update-note',
146
+ 'sheet.mutation.remove-note',
147
+ // Conditional formatting (sheets-conditional-formatting). Mutations
148
+ // are self-contained — `add` carries the full rule, `set` carries
149
+ // the patched rule, `delete` / `move` carry rule ids. Univer's
150
+ // `ConditionalFormattingRuleModel` consumes them and triggers a
151
+ // canvas re-render so highlighted cells update on peers. Existing
152
+ // rules in a downloaded seed already load via the workbook's
153
+ // resource channel; the mutations cover deltas during the session.
154
+ 'sheet.mutation.add-conditional-rule',
155
+ 'sheet.mutation.set-conditional-rule',
156
+ 'sheet.mutation.delete-conditional-rule',
157
+ 'sheet.mutation.move-conditional-rule',
158
+ // Data validation (data-validation core). NB: this package uses the
159
+ // `data-validation.mutation.*` prefix, not `sheet.mutation.*`.
160
+ // Mutation handlers live in @univerjs/data-validation; the
161
+ // sheets-data-validation plugin is the lazy-loaded integration our
162
+ // MUTATION_TO_LAZY_GROUP map keys on.
163
+ 'data-validation.mutation.addRule',
164
+ 'data-validation.mutation.removeRule',
165
+ 'data-validation.mutation.updateRule',
166
+ // Drawings / images (sheets-drawing). Single all-purpose mutation
167
+ // wraps add / remove / update via a JSON-1 op + an enum type. Params
168
+ // can be large (embedded image blobs) — accept the bandwidth hit
169
+ // until we move drawings to a side-channel resource model.
170
+ 'sheet.mutation.set-drawing-apply',
171
+ // Workbook / worksheet metadata. Each is rarely changed mid-session
172
+ // but cheap to propagate when it does happen. Without these,
173
+ // renaming the workbook or toggling gridlines silently stays
174
+ // local-only — confusing in a shared room.
175
+ // NOTE: `set-worksheet-right-to-left` is intentionally NOT here —
176
+ // neither the command nor the mutation is registered in
177
+ // @univerjs/sheets@0.22.1 (it's exported but never wired up by any
178
+ // plugin), so nothing in our app can emit it. Add it back if a
179
+ // future Univer bump registers it.
180
+ 'sheet.mutation.set-workbook-name',
181
+ 'sheet.mutation.set-worksheet-row-count',
182
+ 'sheet.mutation.set-worksheet-column-count',
183
+ 'sheet.mutation.toggle-gridlines',
184
+ 'sheet.mutation.set-gridlines-color',
185
+ ]);
186
+
187
+ /** Mutation ids for which the bridge captures undo params before the
188
+ * redo runs. Used by the HistoryPanel's revert action. Restricted to
189
+ * cell-level mutations because the existing Univer factories cover
190
+ * them and they're the dominant case for "undo my edit"; structural
191
+ * ops (insert-row / move-range / sort) need their own factories and
192
+ * are out of scope for v1 revert.
193
+ */
194
+ export const REVERTABLE_MUTATIONS: ReadonlySet<string> = new Set([
195
+ 'sheet.mutation.set-range-values',
196
+ ]);
197
+
198
+ type MutationRecord = {
199
+ kind?: 'op';
200
+ /** Yjs client id of the emitter (string for portability via JSON). */
201
+ c: string;
202
+ /** Wall-clock at emit; diagnostic only. */
203
+ t: number;
204
+ /** Mutation id (e.g. `sheet.mutation.set-range-values`). */
205
+ id: string;
206
+ /** Mutation params, JSON-serializable. */
207
+ p: unknown;
208
+ /** Optional undo params — set for mutations in REVERTABLE_MUTATIONS,
209
+ * computed via Univer's `*UndoMutationFactory` BEFORE the redo
210
+ * runs (so it reads pre-edit state). The HistoryPanel's Revert
211
+ * button feeds this back into `executeCommand(rec.id, rec.u)` to
212
+ * restore the pre-edit values. Older log entries without `u`
213
+ * predate this feature — their Revert button stays disabled. */
214
+ u?: unknown;
215
+ };
216
+
217
+ /**
218
+ * Snapshot entry written into the op log by the designated compactor
219
+ * client (lowest awareness clientId). Replaces all prior entries; any
220
+ * mutation records that come AFTER it in the array are post-compaction
221
+ * incremental edits and replay normally.
222
+ *
223
+ * Pipeline Stage 6 — keeps long-lived rooms from accumulating an
224
+ * unbounded op log. A 24-hour room with light editing could otherwise
225
+ * grow to thousands of records, slowing every late join. Compaction
226
+ * collapses it back to "snapshot + a handful of recent ops".
227
+ */
228
+ type SnapshotRecord = {
229
+ kind: 'snapshot';
230
+ c: string;
231
+ t: number;
232
+ /** Full IWorkbookData. Yes, this is large for big workbooks — but
233
+ * it ships once per compaction interval, not per mutation. The
234
+ * trade-off vs. unbounded op-log growth is straightforward. */
235
+ wb: IWorkbookData;
236
+ };
237
+
238
+ type OpRecord = MutationRecord | SnapshotRecord;
239
+
240
+ export type BridgeHandle = {
241
+ /** Underlying Yjs document — exposed so tests / devtools can introspect. */
242
+ doc: Y.Doc;
243
+ /** Stop listening and detach. */
244
+ dispose: () => void;
245
+ /**
246
+ * How many remote mutations have thrown during replay since the
247
+ * bridge started. Each one is a candidate divergence — the local
248
+ * state didn't accept the peer's change, so the two state vectors
249
+ * are now off by at least that mutation. The CollabDriver
250
+ * subscribes (see `subscribeReplayFailures`) so the indicator can
251
+ * warn the user before they discover it the hard way.
252
+ */
253
+ getReplayFailures: () => number;
254
+ /**
255
+ * Subscribe to replay-failure count changes. Fires after every
256
+ * increment with the new total. Returns a teardown that unhooks
257
+ * the subscriber. No initial-value fire — caller can read
258
+ * `getReplayFailures()` once at subscribe time if they need it.
259
+ */
260
+ subscribeReplayFailures: (cb: (count: number) => void) => () => void;
261
+ /**
262
+ * Snapshot of the dead-letter ring buffer — mutations that
263
+ * exhausted retries (transient class) or failed immediately
264
+ * (permanent class). Capped at DEAD_LETTER_CAP entries; oldest
265
+ * evicts on overflow. UI consumes this to render the per-failure
266
+ * detail panel.
267
+ */
268
+ getReplayDeadLetter: () => readonly ReplayFailureRecord[];
269
+ /**
270
+ * Subscribe to dead-letter changes. Fires after every push with a
271
+ * fresh array (reference change — React state updates see it).
272
+ * Returns a teardown.
273
+ */
274
+ subscribeReplayDeadLetter: (cb: (entries: readonly ReplayFailureRecord[]) => void) => () => void;
275
+ };
276
+
277
+ export type BridgeOptions = {
278
+ /**
279
+ * `view` clients only RECEIVE remote updates — local mutations don't
280
+ * append to the log, so peers never see them. Client-side gate only;
281
+ * a determined user can run the bridge in write mode by editing the
282
+ * URL. Server-side enforcement is a follow-up hardening pass.
283
+ */
284
+ role?: 'view' | 'write';
285
+ /**
286
+ * Provider's Yjs awareness. Used (a) to determine which peer is the
287
+ * designated compactor (lowest known clientId — deterministic and
288
+ * race-free) and (b) so view-only clients don't try to compact. If
289
+ * omitted, compaction is disabled.
290
+ */
291
+ awareness?: Awareness;
292
+ /**
293
+ * Hand a fresh workbook snapshot to the host when a compaction
294
+ * record arrives from a peer. The bridge can't call
295
+ * `replaceWorkbook` directly (it lives in React state); the host
296
+ * (`CollabDriver`) wires this through.
297
+ *
298
+ * MAY return a promise. Replay of subsequent op-log entries is
299
+ * paused until the promise resolves — without that, mutations
300
+ * land on the OLD unit before Univer's async unit-swap completes,
301
+ * which silently forks state on late joiners.
302
+ */
303
+ onSnapshotReceived?: (wb: IWorkbookData) => void | Promise<void>;
304
+ };
305
+
306
+ /**
307
+ * Compaction thresholds. We only attempt to compact when the log has
308
+ * grown past `COMPACT_OPS_THRESHOLD` AND at least
309
+ * `COMPACT_MIN_INTERVAL_MS` has elapsed since the last compaction.
310
+ * The interval guard prevents two designated-writer candidates from
311
+ * racing the compaction; the ops threshold avoids compacting a quiet
312
+ * room over and over.
313
+ */
314
+ const COMPACT_OPS_THRESHOLD = 200;
315
+ const COMPACT_MIN_INTERVAL_MS = 60_000;
316
+ const COMPACT_CHECK_INTERVAL_MS = 30_000;
317
+
318
+ export function startBridge(api: FUniver, doc: Y.Doc, opts: BridgeOptions = {}): BridgeHandle {
319
+ const role = opts.role ?? 'write';
320
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
321
+ const injector = (api as any)._injector as { get: (token: unknown) => unknown } | undefined;
322
+ if (!injector) {
323
+ throw new Error('[collab] FUniver injector not accessible — Univer too old?');
324
+ }
325
+ const cmdSvc = injector.get(ICommandService) as {
326
+ onMutationExecutedForCollab: (l: (info: ICommandInfo, options?: IExecutionOptions) => void) => {
327
+ dispose: () => void;
328
+ };
329
+ beforeCommandExecuted: (l: (info: ICommandInfo, options?: IExecutionOptions) => void) => {
330
+ dispose: () => void;
331
+ };
332
+ executeCommand: (id: string, params: unknown, options?: IExecutionOptions) => Promise<unknown>;
333
+ };
334
+
335
+ const log = doc.getArray<OpRecord>(LOG_KEY);
336
+ const myClientId = String(doc.clientID);
337
+ // One-shot guard for the __splitChunk__ regression watchdog below.
338
+ let splitChunkWarned = false;
339
+
340
+ // Replay-failure tracking — surfaces silent divergences to the UI.
341
+ // Every time a remote mutation throws on apply, this counter ticks
342
+ // and any subscribers (CollabDriver) get the new total. Local writes
343
+ // never tick this (they're applied by Univer before we even append
344
+ // to the log).
345
+ let replayFailures = 0;
346
+ const replayFailureSubscribers = new Set<(count: number) => void>();
347
+ let deadLetter: readonly ReplayFailureRecord[] = [];
348
+ const deadLetterSubscribers = new Set<(entries: readonly ReplayFailureRecord[]) => void>();
349
+ const noteReplayFailure = (rec: ReplayFailureRecord) => {
350
+ replayFailures += 1;
351
+ deadLetter = pushDeadLetter(deadLetter, rec);
352
+ for (const cb of replayFailureSubscribers) {
353
+ try {
354
+ cb(replayFailures);
355
+ } catch (err) {
356
+ console.warn('[collab] replay-failure subscriber threw', err);
357
+ }
358
+ }
359
+ for (const cb of deadLetterSubscribers) {
360
+ try {
361
+ cb(deadLetter);
362
+ } catch (err) {
363
+ console.warn('[collab] dead-letter subscriber threw', err);
364
+ }
365
+ }
366
+ };
367
+ // Undo params keyed by JSON.stringify(params) so we can pair them up
368
+ // when the matching `onMutationExecutedForCollab` fires moments later.
369
+ // Cleared after each pairing — there's no eviction policy because the
370
+ // window between before-execute and after-execute is microseconds.
371
+ const pendingUndo = new Map<string, unknown>();
372
+ // Capture undo params BEFORE the redo runs. The factory walks the
373
+ // current cell state and produces a redo-shaped object that would
374
+ // restore those cells. Only set-range-values for v1; other types fall
375
+ // through with no `u` field — the HistoryPanel disables Revert.
376
+ const subBeforeDispose = cmdSvc.beforeCommandExecuted((info, options) => {
377
+ if (role === 'view') return;
378
+ if (options?.fromCollab) return;
379
+ if (!REVERTABLE_MUTATIONS.has(info.id)) return;
380
+ try {
381
+ // The factory's first arg is described as "accessor" — Univer's
382
+ // accessor IS the injector for our purposes (both expose .get).
383
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
384
+ const undo = SetRangeValuesUndoMutationFactory(injector as any, info.params as any);
385
+ pendingUndo.set(JSON.stringify(info.params), undo);
386
+ } catch (err) {
387
+ // Pre-edit state was unreadable (workbook missing, sheet gone,
388
+ // etc.) — skip. The history entry just won't be revertable.
389
+ console.warn('[collab] failed to capture undo params for', info.id, err);
390
+ }
391
+ });
392
+
393
+ // Local → Yjs: append every synced mutation to the log. Skipped for
394
+ // view-role clients — their local edits never leave their browser.
395
+ //
396
+ // We BATCH the appends across a microtask window so a paste / sort
397
+ // that emits many mutations doesn't trigger one Yjs encode per
398
+ // mutation — that path was the main contributor to "large action
399
+ // takes 3–5 s" on big workbooks. Single Y.Array.push with N entries
400
+ // is one transaction, one encode, one WS frame.
401
+ let pending: OpRecord[] = [];
402
+ let flushScheduled = false;
403
+ const flush = () => {
404
+ flushScheduled = false;
405
+ if (pending.length === 0) return;
406
+ const batch = pending;
407
+ pending = [];
408
+ // doc.transact wraps the push in a single transaction so subscribers
409
+ // see one change event for the whole batch.
410
+ doc.transact(() => {
411
+ log.push(batch);
412
+ });
413
+ };
414
+ const subDispose = cmdSvc.onMutationExecutedForCollab((info, options) => {
415
+ if (role === 'view') return;
416
+ if (options?.fromCollab) return;
417
+ if (!SYNCED_MUTATIONS.has(info.id)) return;
418
+ // Univer 0.22.x doesn't use the chunked-mutation protocol that
419
+ // earlier versions (and the CLAUDE.md hard rule) referenced. If a
420
+ // future upgrade reintroduces __splitChunk__, mutations split
421
+ // across multiple emissions will silently corrupt on peers because
422
+ // our op-log doesn't reassemble them. Warn loudly the FIRST time
423
+ // we see one so an upgrade regression surfaces in the console
424
+ // instead of as a mysterious paste-corruption bug.
425
+ if (!splitChunkWarned && hasSplitChunkMarker(info.params)) {
426
+ splitChunkWarned = true;
427
+ console.warn(
428
+ '[collab] mutation "%s" carries __splitChunk__ — Univer reintroduced chunked mutations; bridge needs reassembly logic. See docs/COLLAB-FIXES.md issue 8.',
429
+ info.id,
430
+ );
431
+ }
432
+ // Pair with the undo params we captured at beforeCommandExecuted
433
+ // (only set for REVERTABLE_MUTATIONS). The before-hook ran a few
434
+ // microseconds ago with the SAME params object — match by
435
+ // stringified key.
436
+ const key = JSON.stringify(info.params);
437
+ const undoParams = pendingUndo.get(key);
438
+ pendingUndo.delete(key);
439
+ pending.push({
440
+ c: myClientId,
441
+ t: Date.now(),
442
+ id: info.id,
443
+ // Univer mutation params are already JSON-friendly (numbers, strings,
444
+ // plain objects). If something carries a Map / Set / cyclic ref we'll
445
+ // discover it via a runtime error; that's the signal to drop the
446
+ // mutation from SYNCED_MUTATIONS.
447
+ p: info.params as unknown,
448
+ ...(undoParams !== undefined ? { u: undoParams } : {}),
449
+ });
450
+ if (!flushScheduled) {
451
+ flushScheduled = true;
452
+ // queueMicrotask runs after the current command completes but
453
+ // before the browser paints — keeps the bridge low-latency while
454
+ // letting a multi-mutation command (paste, sort, fill) coalesce.
455
+ queueMicrotask(flush);
456
+ }
457
+ });
458
+
459
+ // Replay tracking: how many entries we've already executed locally so we
460
+ // don't double-apply on incremental updates. On connect, replay everything
461
+ // we haven't seen — that's how late joiners catch up.
462
+ let appliedCount = 0;
463
+ // Single-flight guard: when a snapshot record needs an async workbook
464
+ // swap, subsequent records have to wait for the swap to land — otherwise
465
+ // they execute against the old unit id and silently fork state. We also
466
+ // want a single observer callback at a time so re-entrant Yjs events
467
+ // don't interleave half-applied loops.
468
+ let replayInFlight: Promise<void> | null = null;
469
+
470
+ const replayPending = (): Promise<void> => {
471
+ if (replayInFlight) return replayInFlight;
472
+ // CRITICAL: assign `replayInFlight = p` BEFORE invoking the async
473
+ // IIFE. The previous version was:
474
+ // replayInFlight = (async () => { try { ... } finally { replayInFlight = null; } })();
475
+ // For an empty log the IIFE body has no `await` and runs
476
+ // synchronously — the `finally` set `replayInFlight = null`
477
+ // BEFORE the outer `replayInFlight = ...promise...` assignment,
478
+ // which then OVERWROTE the null with the freshly-resolved promise.
479
+ // Result: `replayInFlight` stayed truthy forever and every
480
+ // subsequent `replayPending()` returned immediately without
481
+ // doing anything — remote mutations sat in the Yjs log untouched.
482
+ // Tracker: docs/COLLAB-FIXES.md issue #29.
483
+ let resolveOuter!: () => void;
484
+ const p = new Promise<void>((r) => {
485
+ resolveOuter = r;
486
+ });
487
+ replayInFlight = p;
488
+ void (async () => {
489
+ try {
490
+ // Loop until we catch up. `log.length` may grow while we're awaiting
491
+ // a snapshot apply, so re-read on each pass.
492
+
493
+ while (true) {
494
+ const total = log.length;
495
+ // Stage 6 compaction shrinks the log atomically. If our cursor
496
+ // is past the new end, reset to 0 and replay the snapshot record
497
+ // (which is always at position 0 right after compaction).
498
+ if (appliedCount > total) appliedCount = 0;
499
+ if (appliedCount >= total) {
500
+ break;
501
+ }
502
+ const rec = log.get(appliedCount);
503
+ appliedCount += 1;
504
+ if (!rec) continue;
505
+ if (rec.c === myClientId) continue; // our own write — Univer already ran it
506
+ if (rec.kind === 'snapshot') {
507
+ // Compaction record from a peer — replace the local workbook
508
+ // with the snapshot. Without `onSnapshotReceived` wired (e.g.
509
+ // in unit tests that drive the bridge directly), skip the
510
+ // record; the next post-snapshot mutations may still apply
511
+ // cleanly if state is close enough.
512
+ //
513
+ // CRITICAL: await the handler. Univer's unit swap is async, and
514
+ // continuing the loop before the new unit is wired into the
515
+ // facade means rewriteUnitId() reads the OLD active unit and
516
+ // every subsequent mutation targets a stale workbook.
517
+ if (opts.onSnapshotReceived) {
518
+ try {
519
+ await opts.onSnapshotReceived(rec.wb);
520
+ } catch (err) {
521
+ console.warn('[collab] failed to apply compaction snapshot', err);
522
+ }
523
+ } else {
524
+ console.warn(
525
+ '[collab] received compaction snapshot but no handler — workbook may diverge',
526
+ );
527
+ }
528
+ continue;
529
+ }
530
+ // Each browser creates its workbook with its OWN random unit id, so
531
+ // raw replay would target the sender's unit (which doesn't exist
532
+ // here) — rewrite to our local active unit. Sheet ids (`sheet-1`)
533
+ // are already deterministic across the room.
534
+ const params = rewriteUnitId(api, rec.p, rec.id);
535
+ // Univer's ActiveWorksheetController unconditionally switches
536
+ // the active sheet on every insert-sheet mutation — there's no
537
+ // `fromCollab` opt-out inside Univer. Save our current active
538
+ // sheet around the replay and restore it after the next tick
539
+ // so peers don't get yanked to whichever sheet someone else
540
+ // just created.
541
+ const sheetBefore =
542
+ rec.id === 'sheet.mutation.insert-sheet' ? captureActiveSheetId(api) : null;
543
+ // Lazy-plugin gate: if this mutation belongs to a plugin we
544
+ // haven't mounted yet (CF, tables, filter, notes,
545
+ // hyperlinks), the mutation handler is missing and the
546
+ // change drops silently. AWAIT plugin load before executing.
547
+ // For mutations not in the map, this resolves to undefined
548
+ // and the executeCommand fires immediately.
549
+ const lazyGroup = MUTATION_TO_LAZY_GROUP[rec.id];
550
+ // Fire-and-forget; ordering is preserved by Univer's command bus
551
+ // serialising its own dispatch.
552
+ //
553
+ // Failure handling is two-class (see replay-retry.ts):
554
+ // - TRANSIENT (dynamic-import chunk-load failures) → retry
555
+ // with 300/900/2700 ms backoff. The lazy-plugin gate is
556
+ // the common source: a network flap during webpack chunk
557
+ // fetch rejects the import; retries land cleanly once
558
+ // connectivity recovers.
559
+ // - PERMANENT (malformed params, unknown command id, range
560
+ // out-of-bounds) → dead-letter immediately. Retrying
561
+ // just re-throws the same stack.
562
+ //
563
+ // Final failure (after retries exhausted OR permanent on
564
+ // first throw) increments `replayFailures` AND appends to
565
+ // the dead-letter ring buffer for the UI to render.
566
+ const attempt = () =>
567
+ (lazyGroup ? ensurePluginByName(lazyGroup) : Promise.resolve()).then(() =>
568
+ cmdSvc.executeCommand(rec.id, params, { fromCollab: true }),
569
+ );
570
+ void withRetry(
571
+ attempt,
572
+ TRANSIENT_RETRY_DELAYS_MS,
573
+ (err) => classifyReplayError(err) === 'transient',
574
+ )
575
+ .then(() => {
576
+ if (sheetBefore) restoreActiveSheetId(api, sheetBefore);
577
+ })
578
+ .catch((err: unknown) => {
579
+ const cls = classifyReplayError(err);
580
+ const message = err instanceof Error ? err.message : String(err);
581
+ console.warn(
582
+ '[collab] replay failed for',
583
+ rec.id,
584
+ '(class:',
585
+ cls + ',',
586
+ 'gave up)',
587
+ err,
588
+ );
589
+ const now = Date.now();
590
+ const failure: ReplayFailureRecord = {
591
+ id: rec.id,
592
+ params: rec.p,
593
+ lastError: message,
594
+ // Permanent = 1 attempt; transient = 1 + N retries
595
+ // configured in TRANSIENT_RETRY_DELAYS_MS.
596
+ attempts: cls === 'transient' ? 1 + TRANSIENT_RETRY_DELAYS_MS.length : 1,
597
+ firstFailedAt: now,
598
+ lastFailedAt: now,
599
+ classification: cls,
600
+ };
601
+ noteReplayFailure(failure);
602
+ if (sheetBefore) restoreActiveSheetId(api, sheetBefore);
603
+ });
604
+ }
605
+ } finally {
606
+ // Only clear if we're still the in-flight token. A future
607
+ // re-entrant guard scheme might let multiple flights coexist;
608
+ // this check keeps us correct under that.
609
+ if (replayInFlight === p) replayInFlight = null;
610
+ resolveOuter();
611
+ }
612
+ })();
613
+ return p;
614
+ };
615
+
616
+ const observer = (event: Y.YArrayEvent<OpRecord>) => {
617
+ void event;
618
+ void replayPending();
619
+ };
620
+ log.observe(observer);
621
+
622
+ // Cover the initial-state case: when the bridge mounts after Yjs has
623
+ // already synced the existing log (provider was connected before us),
624
+ // observe() won't fire — we'd miss everything. Replay synchronously
625
+ // once on mount to catch up.
626
+ void replayPending();
627
+
628
+ // ── Stage 6: periodic compaction by the designated writer ────────
629
+ // Only one client in the room compacts at a time — the one with the
630
+ // lowest known clientId. The interval guard prevents an over-eager
631
+ // compactor from churning. View-only clients never compact.
632
+ //
633
+ // Seed `lastCompactedAt` to `now` so the first auto-compaction
634
+ // observes the full COMPACT_MIN_INTERVAL_MS cooldown. Without this
635
+ // seed, a fresh session that immediately crosses the op threshold
636
+ // (e.g. a quick paste of >200 cells, or the e2e harness) would see
637
+ // an instant first compaction before the test could observe the
638
+ // pre-compaction log. The explicit `__bridgeForceCompact` path
639
+ // bypasses this guard, so the test still works.
640
+ let lastCompactedAt = Date.now();
641
+ // Both scheduling paths declared in outer scope so the dispose
642
+ // closure below can clean up either one.
643
+ let intervalHandle: ReturnType<typeof setInterval> | null = null;
644
+ let idleHandle: number | null = null;
645
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
646
+ const cic = (globalThis as any).cancelIdleCallback as undefined | ((id: number) => void);
647
+ if (role !== 'view' && opts.awareness) {
648
+ const awareness = opts.awareness;
649
+ const tryCompact = (): void => {
650
+ try {
651
+ if (log.length < COMPACT_OPS_THRESHOLD) return;
652
+ if (Date.now() - lastCompactedAt < COMPACT_MIN_INTERVAL_MS) return;
653
+ // Designated writer = lowest clientId currently in awareness.
654
+ // Math.min over the awareness keys, then compare to ours.
655
+ const keys = Array.from(awareness.getStates().keys()) as number[];
656
+ if (keys.length === 0) return;
657
+ const designated = Math.min(...keys);
658
+ if (designated !== doc.clientID) return;
659
+ // Snapshot the live workbook.
660
+ const wb = api.getActiveWorkbook();
661
+ if (!wb) return;
662
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
663
+ const snap = (wb as any).save() as IWorkbookData;
664
+ const snapshotRec: SnapshotRecord = {
665
+ kind: 'snapshot',
666
+ c: myClientId,
667
+ t: Date.now(),
668
+ wb: snap,
669
+ };
670
+ const opsBefore = log.length;
671
+ // Atomic swap: clear then append. Yjs serializes the whole
672
+ // transaction so subscribers see one consistent change.
673
+ doc.transact(() => {
674
+ log.delete(0, log.length);
675
+ log.push([snapshotRec]);
676
+ });
677
+ // We just rewrote the log; our cursor must stay PAST the
678
+ // snapshot (we already have its state). The replayer's
679
+ // appliedCount > length reset would otherwise re-apply our
680
+ // own snapshot which is a no-op but pointless.
681
+ appliedCount = 1;
682
+ lastCompactedAt = Date.now();
683
+ console.info('[collab] op-log compacted: %d ops → 1 snapshot record', opsBefore);
684
+ } catch (err) {
685
+ console.warn('[collab] compaction attempt failed', err);
686
+ }
687
+ };
688
+ // Schedule `tryCompact` via requestIdleCallback so the heavy
689
+ // `wb.save()` only runs when the main thread is genuinely idle —
690
+ // never mid-keystroke or mid-paste. The browser gives us a
691
+ // deadline; if it expires before we'd start, we skip and wait
692
+ // for the next tick. Fall back to a plain setInterval in
693
+ // environments without rIC (Safari < 18, some test runners).
694
+ //
695
+ // `wb.save()` itself can't move to a Web Worker (the Univer
696
+ // workbook is a main-thread object graph), so the realistic
697
+ // optimisation is "don't run it when the user is busy".
698
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
699
+ const ric = (globalThis as any).requestIdleCallback as
700
+ | undefined
701
+ | ((
702
+ cb: (d: { didTimeout: boolean; timeRemaining: () => number }) => void,
703
+ opts?: { timeout: number },
704
+ ) => number);
705
+ if (typeof ric === 'function') {
706
+ const scheduleNext = () => {
707
+ idleHandle = ric(
708
+ (deadline) => {
709
+ // Only run if we have at least ~5 ms to spare (typical
710
+ // empty-workbook save is <1 ms, big ones a few ms;
711
+ // anything longer should defer to the next idle window).
712
+ if (deadline.didTimeout || deadline.timeRemaining() > 5) {
713
+ tryCompact();
714
+ }
715
+ scheduleNext();
716
+ },
717
+ { timeout: COMPACT_CHECK_INTERVAL_MS },
718
+ );
719
+ };
720
+ scheduleNext();
721
+ } else {
722
+ intervalHandle = setInterval(tryCompact, COMPACT_CHECK_INTERVAL_MS);
723
+ intervalHandle.unref?.();
724
+ }
725
+
726
+ // Diagnostic sinks for the compaction e2e — lets a test trigger
727
+ // compaction without waiting for the 30 s interval and read the live
728
+ // log length. Installed unconditionally: now that the bridge ships
729
+ // from the SDK bundle (not the app's Vite source), there's no reliable
730
+ // `import.meta.env.DEV` to gate on, and these are the same class of
731
+ // read-only/devtools escape hatch the host already exposes via
732
+ // `__univerAPI` / `__hocuspocusProvider`. No secrets; `__bridgeForceCompact`
733
+ // only triggers the compaction that runs on its own anyway.
734
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
735
+ (globalThis as any).__bridgeLogLength = () => log.length;
736
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
737
+ (globalThis as any).__bridgeForceCompact = () => {
738
+ // Bypass the COMPACT_MIN_INTERVAL_MS guard so the test
739
+ // doesn't have to sleep a minute.
740
+ lastCompactedAt = 0;
741
+ tryCompact();
742
+ };
743
+ }
744
+
745
+ return {
746
+ doc,
747
+ dispose: () => {
748
+ subDispose.dispose();
749
+ subBeforeDispose.dispose();
750
+ // Flush any pending batch so an edit-then-leave race doesn't drop
751
+ // the last keystroke on the floor.
752
+ if (pending.length > 0) flush();
753
+ log.unobserve(observer);
754
+ pendingUndo.clear();
755
+ replayFailureSubscribers.clear();
756
+ deadLetterSubscribers.clear();
757
+ // Two scheduling paths in setup above — clean up whichever ran.
758
+ if (intervalHandle) clearInterval(intervalHandle);
759
+ if (idleHandle !== null && typeof cic === 'function') cic(idleHandle);
760
+ },
761
+ getReplayFailures: () => replayFailures,
762
+ subscribeReplayFailures: (cb) => {
763
+ replayFailureSubscribers.add(cb);
764
+ return () => {
765
+ replayFailureSubscribers.delete(cb);
766
+ };
767
+ },
768
+ getReplayDeadLetter: () => deadLetter,
769
+ subscribeReplayDeadLetter: (cb) => {
770
+ deadLetterSubscribers.add(cb);
771
+ return () => {
772
+ deadLetterSubscribers.delete(cb);
773
+ };
774
+ },
775
+ };
776
+ }
777
+
778
+ /**
779
+ * Substitute the active local workbook's unit id into a mutation's
780
+ * `unitId` fields — top-level AND nested ones (e.g. `range.unitId`,
781
+ * `source.unitId`, `target.unitId`) — so cross-peer mutations target
782
+ * our local workbook. Sheet-level `subUnitId` keys (`sheet-1`, …)
783
+ * are deterministic from the emptyWorkbook snapshot, so they pass
784
+ * through unchanged.
785
+ *
786
+ * Returns a structurally cloned params object so we don't mutate the
787
+ * Yjs record (Y.Array entries are frozen plain objects). Walks objects
788
+ * and arrays recursively; stops at non-plain values (strings, numbers,
789
+ * dates, etc.).
790
+ *
791
+ * Performance: most mutations have shallow params — the recursive walk
792
+ * adds microseconds. Per-cell value maps stay shallow because they're
793
+ * indexed by stringified row/col, not nested objects.
794
+ */
795
+ function rewriteUnitId(api: FUniver, params: unknown, mutationId?: string): unknown {
796
+ const wb = api.getActiveWorkbook();
797
+ if (!wb) return params;
798
+ const localUnitId = wb.getId();
799
+ // Capture the sender's unitId BEFORE deepRewriteUnitId swaps it —
800
+ // drawing mutations need it to patch the json1 op path (which
801
+ // carries unitId in position [0] of a positional array, out of
802
+ // deepRewriteUnitId's reach since it only rewrites object KEYS
803
+ // named `unitId`). See bridge-helpers.ts → rewriteJson1OpPathUnitId.
804
+ //
805
+ // Stream F1 fix: without this, set-drawing-apply replays on a
806
+ // joiner with the OWNER's unitId still embedded in the op,
807
+ // json1.type.apply walks a path that doesn't exist locally,
808
+ // throws a bare "Error" with no message, classifier lands it as
809
+ // PERMANENT, and the drawing silently fails to propagate.
810
+ let senderUnitId: string | undefined;
811
+ if (mutationId === 'sheet.mutation.set-drawing-apply' && params && typeof params === 'object') {
812
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
813
+ const u = (params as any).unitId;
814
+ if (typeof u === 'string') senderUnitId = u;
815
+ }
816
+ const rewritten = deepRewriteUnitId(params, localUnitId) as unknown;
817
+ if (!senderUnitId || senderUnitId === localUnitId) return rewritten;
818
+ // Drawing mutations only — patch the op's positional path[0].
819
+ if (rewritten && typeof rewritten === 'object') {
820
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
821
+ const r = rewritten as any;
822
+ const fixedOp = rewriteJson1OpPathUnitId(r.op, senderUnitId, localUnitId);
823
+ if (fixedOp !== r.op) {
824
+ return { ...r, op: fixedOp };
825
+ }
826
+ }
827
+ return rewritten;
828
+ }
829
+
830
+ /**
831
+ * Save the local active sheet id before replaying a `fromCollab`
832
+ * mutation that Univer's controllers may use as a side-channel signal
833
+ * to switch sheets (notably `insert-sheet` — see
834
+ * ActiveWorksheetController in @univerjs/sheets). Returning `null`
835
+ * means "couldn't read, don't try to restore".
836
+ */
837
+ function captureActiveSheetId(api: FUniver): string | null {
838
+ try {
839
+ const wb = api.getActiveWorkbook();
840
+ if (!wb) return null;
841
+ const sheet = wb.getActiveSheet();
842
+ if (!sheet) return null;
843
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
844
+ const id = (sheet as any).getSheetId?.() ?? (sheet as any).getId?.() ?? null;
845
+ return typeof id === 'string' ? id : null;
846
+ } catch {
847
+ return null;
848
+ }
849
+ }
850
+
851
+ function restoreActiveSheetId(api: FUniver, sheetId: string): void {
852
+ try {
853
+ const wb = api.getActiveWorkbook();
854
+ if (!wb) return;
855
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
856
+ const current = (wb.getActiveSheet() as any)?.getSheetId?.();
857
+ if (current === sheetId) return; // nothing to do
858
+ const sheets = wb.getSheets();
859
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
860
+ const target = sheets.find((s: any) => s.getSheetId?.() === sheetId);
861
+ if (!target) return; // sheet got deleted in the meantime — leave Univer's choice alone
862
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
863
+ (wb as any).setActiveSheet?.(target);
864
+ } catch (err) {
865
+ console.warn('[collab] failed to restore active sheet after remote insert-sheet', err);
866
+ }
867
+ }
868
+
869
+ /**
870
+ * Cheap probe for the `__splitChunk__` marker — a flag Univer used in
871
+ * earlier versions to indicate a mutation was one chunk of a larger
872
+ * operation (large paste, copy-worksheet). Univer 0.22.x doesn't emit
873
+ * it, but if a future upgrade reintroduces it our op-log replay would
874
+ * silently corrupt because we don't reassemble chunks. Watchdog logs
875
+ * a warning the first time it sees one so the regression is loud.
876
+ *
877
+ * Walks one level deep — Univer carried the marker on the top-level
878
+ * params object historically. Deeper nesting would be a different
879
+ * shape and warrants a different fix.
880
+ */
881
+ function hasSplitChunkMarker(params: unknown): boolean {
882
+ if (!params || typeof params !== 'object') return false;
883
+
884
+ return Object.prototype.hasOwnProperty.call(params, '__splitChunk__');
885
+ }