@tiptap/y-tiptap 3.0.2 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/y-tiptap.cjs CHANGED
@@ -1416,24 +1416,115 @@ const matchNodeName = (yElement, pNode) =>
1416
1416
  */
1417
1417
  let viewsToUpdate = null;
1418
1418
 
1419
- const updateMetas = () => {
1420
- const ups = /** @type {Map<EditorView, Map<any, any>>} */ (viewsToUpdate);
1421
- viewsToUpdate = null;
1422
- ups.forEach((metas, view) => {
1423
- const tr = view.state.tr;
1424
- const syncState = ySyncPluginKey.getState(view.state);
1419
+ class MetaEntry {
1420
+ /**
1421
+ * @param {EditorView} view
1422
+ * @param {any} key
1423
+ * @param {any} value
1424
+ */
1425
+ constructor (view, key, value) {
1426
+ this.view = view;
1427
+ this.key = key;
1428
+ this.value = value;
1429
+ }
1430
+
1431
+ apply () {
1432
+ const syncState = ySyncPluginKey.getState(this.view.state);
1425
1433
  if (syncState && syncState.binding && !syncState.binding.isDestroyed) {
1426
- metas.forEach((val, key) => {
1427
- tr.setMeta(key, val);
1434
+ const tr = this.view.state.tr;
1435
+ tr.setMeta(this.key, this.value);
1436
+ this.view.dispatch(tr);
1437
+ }
1438
+ }
1439
+ }
1440
+
1441
+ class MetaEntriesQueue {
1442
+ /**
1443
+ * @param {Array<MetaEntry>} [entries=[]]
1444
+ */
1445
+ constructor (entries = []) {
1446
+ this.entries = entries;
1447
+ }
1448
+
1449
+ /**
1450
+ * @return {MetaEntry|undefined}
1451
+ */
1452
+ getFirst () {
1453
+ return this.entries[0]
1454
+ }
1455
+
1456
+ /**
1457
+ * @return {MetaEntry|undefined}
1458
+ */
1459
+ dequeueFirst () {
1460
+ return this.entries.shift()
1461
+ }
1462
+
1463
+ /**
1464
+ * @return {boolean}
1465
+ */
1466
+ isEmpty () {
1467
+ return this.entries.length === 0
1468
+ }
1469
+
1470
+ static fromViewsToUpdate () {
1471
+ const ups = /** @type {Map<EditorView, Map<any, any>>} */ (viewsToUpdate);
1472
+ viewsToUpdate = null;
1473
+ const entries = [];
1474
+ ups.forEach((metas, view) => {
1475
+ metas.forEach((value, key) => {
1476
+ entries.push(new MetaEntry(view, key, value));
1428
1477
  });
1429
- view.dispatch(tr);
1478
+ });
1479
+ return new MetaEntriesQueue(entries)
1480
+ }
1481
+ }
1482
+
1483
+ /**
1484
+ * Dispatch queued plugin metadata in order, retrying only the remaining
1485
+ * entries if a transaction becomes stale while the async queue is flushing.
1486
+ *
1487
+ * Cursor awareness updates are decoration-only refreshes. If a real document
1488
+ * transaction lands before one of these queued meta transactions is applied,
1489
+ * ProseMirror can reject it with a RangeError. In that case we reschedule the
1490
+ * remaining entries on the next tick. If the first remaining entry fails again
1491
+ * on retry, we drop that entry and continue with the rest of the queue instead
1492
+ * of retrying forever or crashing the editor.
1493
+ *
1494
+ * @param {MetaEntriesQueue} [metaEntries=MetaEntriesQueue.fromViewsToUpdate()]
1495
+ * @param {boolean} [isRetry=false]
1496
+ */
1497
+ const updateMetas = (metaEntries = MetaEntriesQueue.fromViewsToUpdate(), isRetry = false) => {
1498
+ let isFirst = true;
1499
+ while (!metaEntries.isEmpty()) {
1500
+ const metaEntry = metaEntries.getFirst();
1501
+ try {
1502
+ metaEntry.apply();
1503
+ } catch (err) {
1504
+ // ProseMirror throws a RangeError when this transaction was created from
1505
+ // an older state and another transaction changed the document before this
1506
+ // meta-only dispatch was applied ("Applying a mismatched transaction").
1507
+ if (err instanceof RangeError) {
1508
+ if (isRetry && isFirst) {
1509
+ // Drop the repeatedly stale entry so the queue can continue flushing.
1510
+ metaEntries.dequeueFirst();
1511
+ }
1512
+ if (!metaEntries.isEmpty()) {
1513
+ eventloop__namespace.timeout(0, () => updateMetas(metaEntries, true));
1514
+ }
1515
+ return
1516
+ }
1517
+ throw err
1430
1518
  }
1431
- });
1519
+ isFirst = false;
1520
+ metaEntries.dequeueFirst();
1521
+ }
1432
1522
  };
1433
1523
 
1434
1524
  const setMeta = (view, key, value) => {
1435
1525
  if (!viewsToUpdate) {
1436
1526
  viewsToUpdate = new Map();
1527
+ // Awareness listeners can fire in bursts, so batch them into one tick.
1437
1528
  eventloop__namespace.timeout(0, updateMetas);
1438
1529
  }
1439
1530
  map__namespace.setIfUndefined(viewsToUpdate, view, map__namespace.create).set(key, value);
@@ -1918,7 +2009,9 @@ const createDecorations = (
1918
2009
  return
1919
2010
  }
1920
2011
 
1921
- if (aw.cursor != null) {
2012
+ // `aw` can be null when a client disconnects, so we guard against it
2013
+ // before reading `cursor` to avoid a TypeError.
2014
+ if (aw && aw.cursor != null) {
1922
2015
  const user = aw.user || {};
1923
2016
  if (user.color == null) {
1924
2017
  user.color = '#ffa500';
@@ -2033,6 +2126,9 @@ const yCursorPlugin = (
2033
2126
  };
2034
2127
  const updateCursorInfo = () => {
2035
2128
  const ystate = ySyncPluginKey.getState(view.state);
2129
+ // `ystate` is undefined during sync-plugin init and Y.Doc transitions;
2130
+ // reading from it here would throw and break cursor tracking for the view.
2131
+ if (!ystate || !ystate.binding) return
2036
2132
  // @note We make implicit checks when checking for the cursor property
2037
2133
  const current = awareness.getLocalState() || {};
2038
2134
  if (view.hasFocus()) {
@@ -2127,11 +2223,26 @@ const yUndoPlugin = ({ protectedNodes = defaultProtectedNodes, trackedOrigins =
2127
2223
  init: (initargs, state) => {
2128
2224
  // TODO: check if plugin order matches and fix
2129
2225
  const ystate = ySyncPluginKey.getState(state);
2130
- const _undoManager = undoManager || new Y.UndoManager(ystate.type, {
2131
- trackedOrigins: new Set([ySyncPluginKey].concat(trackedOrigins)),
2132
- deleteFilter: (item) => defaultDeleteFilter(item, protectedNodes),
2133
- captureTransaction: tr => tr.meta.get('addToHistory') !== false
2134
- });
2226
+ let _undoManager = undoManager;
2227
+ if (!_undoManager) {
2228
+ // Y.UndoManager registers a `doc.on('destroy', )` listener in its
2229
+ // constructor that UndoManager.destroy() never removes. When the doc
2230
+ // outlives the editor (e.g. several editors sharing one provider), that
2231
+ // listener keeps the UndoManager — and everything it references —
2232
+ // reachable from the doc, leaking memory on every editor destroy.
2233
+ // We only own the lifecycle of a manager we create here, so capture the
2234
+ // listener(s) it adds and remove them when the plugin view is destroyed.
2235
+ const doc = ystate.doc;
2236
+ const destroyListenersBefore = new Set(doc ? doc._observers.get('destroy') : []);
2237
+ _undoManager = new Y.UndoManager(ystate.type, {
2238
+ trackedOrigins: new Set([ySyncPluginKey].concat(trackedOrigins)),
2239
+ deleteFilter: (item) => defaultDeleteFilter(item, protectedNodes),
2240
+ captureTransaction: tr => tr.meta.get('addToHistory') !== false
2241
+ });
2242
+ const destroyListenersAfter = doc ? doc._observers.get('destroy') : new Set();
2243
+ _undoManager._yTiptapDocDestroyListeners = Array.from(destroyListenersAfter || [])
2244
+ .filter(listener => !destroyListenersBefore.has(listener));
2245
+ }
2135
2246
  return {
2136
2247
  undoManager: _undoManager,
2137
2248
  prevSel: null,
@@ -2184,6 +2295,13 @@ const yUndoPlugin = ({ protectedNodes = defaultProtectedNodes, trackedOrigins =
2184
2295
  return {
2185
2296
  destroy: () => {
2186
2297
  undoManager.destroy();
2298
+ // Remove the doc 'destroy' listener Y.UndoManager fails to clean up
2299
+ // (only for managers we created — see state.init above).
2300
+ const leakedDestroyListeners = undoManager._yTiptapDocDestroyListeners;
2301
+ if (leakedDestroyListeners && undoManager.doc) {
2302
+ leakedDestroyListeners.forEach(listener => undoManager.doc.off('destroy', listener));
2303
+ undoManager._yTiptapDocDestroyListeners = null;
2304
+ }
2187
2305
  }
2188
2306
  }
2189
2307
  }