@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.js CHANGED
@@ -1382,24 +1382,115 @@ const matchNodeName = (yElement, pNode) =>
1382
1382
  */
1383
1383
  let viewsToUpdate = null;
1384
1384
 
1385
- const updateMetas = () => {
1386
- const ups = /** @type {Map<EditorView, Map<any, any>>} */ (viewsToUpdate);
1387
- viewsToUpdate = null;
1388
- ups.forEach((metas, view) => {
1389
- const tr = view.state.tr;
1390
- const syncState = ySyncPluginKey.getState(view.state);
1385
+ class MetaEntry {
1386
+ /**
1387
+ * @param {EditorView} view
1388
+ * @param {any} key
1389
+ * @param {any} value
1390
+ */
1391
+ constructor (view, key, value) {
1392
+ this.view = view;
1393
+ this.key = key;
1394
+ this.value = value;
1395
+ }
1396
+
1397
+ apply () {
1398
+ const syncState = ySyncPluginKey.getState(this.view.state);
1391
1399
  if (syncState && syncState.binding && !syncState.binding.isDestroyed) {
1392
- metas.forEach((val, key) => {
1393
- tr.setMeta(key, val);
1400
+ const tr = this.view.state.tr;
1401
+ tr.setMeta(this.key, this.value);
1402
+ this.view.dispatch(tr);
1403
+ }
1404
+ }
1405
+ }
1406
+
1407
+ class MetaEntriesQueue {
1408
+ /**
1409
+ * @param {Array<MetaEntry>} [entries=[]]
1410
+ */
1411
+ constructor (entries = []) {
1412
+ this.entries = entries;
1413
+ }
1414
+
1415
+ /**
1416
+ * @return {MetaEntry|undefined}
1417
+ */
1418
+ getFirst () {
1419
+ return this.entries[0]
1420
+ }
1421
+
1422
+ /**
1423
+ * @return {MetaEntry|undefined}
1424
+ */
1425
+ dequeueFirst () {
1426
+ return this.entries.shift()
1427
+ }
1428
+
1429
+ /**
1430
+ * @return {boolean}
1431
+ */
1432
+ isEmpty () {
1433
+ return this.entries.length === 0
1434
+ }
1435
+
1436
+ static fromViewsToUpdate () {
1437
+ const ups = /** @type {Map<EditorView, Map<any, any>>} */ (viewsToUpdate);
1438
+ viewsToUpdate = null;
1439
+ const entries = [];
1440
+ ups.forEach((metas, view) => {
1441
+ metas.forEach((value, key) => {
1442
+ entries.push(new MetaEntry(view, key, value));
1394
1443
  });
1395
- view.dispatch(tr);
1444
+ });
1445
+ return new MetaEntriesQueue(entries)
1446
+ }
1447
+ }
1448
+
1449
+ /**
1450
+ * Dispatch queued plugin metadata in order, retrying only the remaining
1451
+ * entries if a transaction becomes stale while the async queue is flushing.
1452
+ *
1453
+ * Cursor awareness updates are decoration-only refreshes. If a real document
1454
+ * transaction lands before one of these queued meta transactions is applied,
1455
+ * ProseMirror can reject it with a RangeError. In that case we reschedule the
1456
+ * remaining entries on the next tick. If the first remaining entry fails again
1457
+ * on retry, we drop that entry and continue with the rest of the queue instead
1458
+ * of retrying forever or crashing the editor.
1459
+ *
1460
+ * @param {MetaEntriesQueue} [metaEntries=MetaEntriesQueue.fromViewsToUpdate()]
1461
+ * @param {boolean} [isRetry=false]
1462
+ */
1463
+ const updateMetas = (metaEntries = MetaEntriesQueue.fromViewsToUpdate(), isRetry = false) => {
1464
+ let isFirst = true;
1465
+ while (!metaEntries.isEmpty()) {
1466
+ const metaEntry = metaEntries.getFirst();
1467
+ try {
1468
+ metaEntry.apply();
1469
+ } catch (err) {
1470
+ // ProseMirror throws a RangeError when this transaction was created from
1471
+ // an older state and another transaction changed the document before this
1472
+ // meta-only dispatch was applied ("Applying a mismatched transaction").
1473
+ if (err instanceof RangeError) {
1474
+ if (isRetry && isFirst) {
1475
+ // Drop the repeatedly stale entry so the queue can continue flushing.
1476
+ metaEntries.dequeueFirst();
1477
+ }
1478
+ if (!metaEntries.isEmpty()) {
1479
+ eventloop.timeout(0, () => updateMetas(metaEntries, true));
1480
+ }
1481
+ return
1482
+ }
1483
+ throw err
1396
1484
  }
1397
- });
1485
+ isFirst = false;
1486
+ metaEntries.dequeueFirst();
1487
+ }
1398
1488
  };
1399
1489
 
1400
1490
  const setMeta = (view, key, value) => {
1401
1491
  if (!viewsToUpdate) {
1402
1492
  viewsToUpdate = new Map();
1493
+ // Awareness listeners can fire in bursts, so batch them into one tick.
1403
1494
  eventloop.timeout(0, updateMetas);
1404
1495
  }
1405
1496
  map.setIfUndefined(viewsToUpdate, view, map.create).set(key, value);
@@ -1884,7 +1975,9 @@ const createDecorations = (
1884
1975
  return
1885
1976
  }
1886
1977
 
1887
- if (aw.cursor != null) {
1978
+ // `aw` can be null when a client disconnects, so we guard against it
1979
+ // before reading `cursor` to avoid a TypeError.
1980
+ if (aw && aw.cursor != null) {
1888
1981
  const user = aw.user || {};
1889
1982
  if (user.color == null) {
1890
1983
  user.color = '#ffa500';
@@ -1999,6 +2092,9 @@ const yCursorPlugin = (
1999
2092
  };
2000
2093
  const updateCursorInfo = () => {
2001
2094
  const ystate = ySyncPluginKey.getState(view.state);
2095
+ // `ystate` is undefined during sync-plugin init and Y.Doc transitions;
2096
+ // reading from it here would throw and break cursor tracking for the view.
2097
+ if (!ystate || !ystate.binding) return
2002
2098
  // @note We make implicit checks when checking for the cursor property
2003
2099
  const current = awareness.getLocalState() || {};
2004
2100
  if (view.hasFocus()) {
@@ -2093,11 +2189,26 @@ const yUndoPlugin = ({ protectedNodes = defaultProtectedNodes, trackedOrigins =
2093
2189
  init: (initargs, state) => {
2094
2190
  // TODO: check if plugin order matches and fix
2095
2191
  const ystate = ySyncPluginKey.getState(state);
2096
- const _undoManager = undoManager || new UndoManager(ystate.type, {
2097
- trackedOrigins: new Set([ySyncPluginKey].concat(trackedOrigins)),
2098
- deleteFilter: (item) => defaultDeleteFilter(item, protectedNodes),
2099
- captureTransaction: tr => tr.meta.get('addToHistory') !== false
2100
- });
2192
+ let _undoManager = undoManager;
2193
+ if (!_undoManager) {
2194
+ // Y.UndoManager registers a `doc.on('destroy', )` listener in its
2195
+ // constructor that UndoManager.destroy() never removes. When the doc
2196
+ // outlives the editor (e.g. several editors sharing one provider), that
2197
+ // listener keeps the UndoManager — and everything it references —
2198
+ // reachable from the doc, leaking memory on every editor destroy.
2199
+ // We only own the lifecycle of a manager we create here, so capture the
2200
+ // listener(s) it adds and remove them when the plugin view is destroyed.
2201
+ const doc = ystate.doc;
2202
+ const destroyListenersBefore = new Set(doc ? doc._observers.get('destroy') : []);
2203
+ _undoManager = new UndoManager(ystate.type, {
2204
+ trackedOrigins: new Set([ySyncPluginKey].concat(trackedOrigins)),
2205
+ deleteFilter: (item) => defaultDeleteFilter(item, protectedNodes),
2206
+ captureTransaction: tr => tr.meta.get('addToHistory') !== false
2207
+ });
2208
+ const destroyListenersAfter = doc ? doc._observers.get('destroy') : new Set();
2209
+ _undoManager._yTiptapDocDestroyListeners = Array.from(destroyListenersAfter || [])
2210
+ .filter(listener => !destroyListenersBefore.has(listener));
2211
+ }
2101
2212
  return {
2102
2213
  undoManager: _undoManager,
2103
2214
  prevSel: null,
@@ -2150,6 +2261,13 @@ const yUndoPlugin = ({ protectedNodes = defaultProtectedNodes, trackedOrigins =
2150
2261
  return {
2151
2262
  destroy: () => {
2152
2263
  undoManager.destroy();
2264
+ // Remove the doc 'destroy' listener Y.UndoManager fails to clean up
2265
+ // (only for managers we created — see state.init above).
2266
+ const leakedDestroyListeners = undoManager._yTiptapDocDestroyListeners;
2267
+ if (leakedDestroyListeners && undoManager.doc) {
2268
+ leakedDestroyListeners.forEach(listener => undoManager.doc.off('destroy', listener));
2269
+ undoManager._yTiptapDocDestroyListeners = null;
2270
+ }
2153
2271
  }
2154
2272
  }
2155
2273
  }