@datalayer/lexical-loro 1.0.3 → 1.0.5

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 (113) hide show
  1. package/README.md +2 -2
  2. package/lib/Editor.js +3 -2
  3. package/lib/appSettings.js +1 -1
  4. package/lib/collab/loro/Bindings.js +7 -7
  5. package/lib/collab/loro/Debug.js +35 -35
  6. package/lib/collab/loro/LexicalCollaborationContext.js +1 -1
  7. package/lib/collab/loro/LexicalCollaborationPlugin.d.ts +6 -1
  8. package/lib/collab/loro/LexicalCollaborationPlugin.js +41 -13
  9. package/lib/collab/loro/State.js +1 -1
  10. package/lib/collab/loro/components/LoroCollaborators.js +7 -19
  11. package/lib/collab/loro/components/index.js +1 -1
  12. package/lib/collab/loro/index.js +1 -1
  13. package/lib/collab/loro/integrators/BaseIntegrator.js +1 -1
  14. package/lib/collab/loro/integrators/CounterIntegrator.js +1 -1
  15. package/lib/collab/loro/integrators/ListIntegrator.js +4 -4
  16. package/lib/collab/loro/integrators/MapIntegrator.js +4 -4
  17. package/lib/collab/loro/integrators/TextIntegrator.js +2 -2
  18. package/lib/collab/loro/integrators/TreeIntegrator.d.ts +5 -0
  19. package/lib/collab/loro/integrators/TreeIntegrator.js +184 -142
  20. package/lib/collab/loro/nodes/NodeFactory.js +26 -47
  21. package/lib/collab/loro/nodes/NodesMapper.js +19 -7
  22. package/lib/collab/loro/propagators/DecoratorNodePropagator.js +1 -1
  23. package/lib/collab/loro/propagators/ElementNodePropagator.d.ts +1 -1
  24. package/lib/collab/loro/propagators/ElementNodePropagator.js +102 -35
  25. package/lib/collab/loro/propagators/LineBreakNodePropagator.js +1 -1
  26. package/lib/collab/loro/propagators/RootNodePropagator.js +1 -1
  27. package/lib/collab/loro/propagators/TextNodePropagator.js +55 -45
  28. package/lib/collab/loro/propagators/index.d.ts +1 -1
  29. package/lib/collab/loro/propagators/index.js +1 -1
  30. package/lib/collab/loro/provider/websocket.d.ts +1 -0
  31. package/lib/collab/loro/provider/websocket.js +47 -12
  32. package/lib/collab/loro/servers/index.js +1 -1
  33. package/lib/collab/loro/servers/ws/callback.js +1 -1
  34. package/lib/collab/loro/servers/ws/server.js +1 -1
  35. package/lib/collab/loro/servers/ws/utils.js +35 -35
  36. package/lib/collab/loro/sync/SyncCursors.js +42 -16
  37. package/lib/collab/loro/sync/SyncLexicalToLoro.js +76 -37
  38. package/lib/collab/loro/sync/SyncLoroToLexical.js +32 -26
  39. package/lib/collab/loro/types/LexicalNodeData.js +1 -1
  40. package/lib/collab/loro/useCollaboration.js +173 -28
  41. package/lib/collab/loro/utils/InitialContent.js +1 -1
  42. package/lib/collab/loro/utils/Invariant.d.ts +15 -0
  43. package/lib/collab/loro/utils/Invariant.js +24 -0
  44. package/lib/collab/loro/utils/LexicalToLoro.js +1 -1
  45. package/lib/collab/loro/utils/Utils.js +1 -1
  46. package/lib/collab/loro/wsProvider.js +21 -4
  47. package/lib/collab/utils/invariant.js +1 -1
  48. package/lib/collab/utils/simpleDiffWithCursor.js +1 -1
  49. package/lib/commenting/index.js +1 -1
  50. package/lib/demo.js +1 -1
  51. package/lib/hooks/useFlashMessage.js +1 -1
  52. package/lib/hooks/useReport.js +1 -1
  53. package/lib/index.js +1 -1
  54. package/lib/nodes/AutocompleteNode.js +1 -1
  55. package/lib/nodes/EmojiNode.js +1 -1
  56. package/lib/nodes/KeywordNode.js +1 -1
  57. package/lib/nodes/LayoutContainerNode.js +1 -1
  58. package/lib/nodes/LayoutItemNode.js +1 -1
  59. package/lib/nodes/MentionNode.js +1 -1
  60. package/lib/nodes/PlaygroundNodes.js +1 -1
  61. package/lib/nodes/SpecialTextNode.js +1 -1
  62. package/lib/plugins/AutocompletePlugin/index.js +1 -1
  63. package/lib/plugins/CodeActionMenuPlugin/components/CopyButton/index.js +1 -1
  64. package/lib/plugins/CodeActionMenuPlugin/components/PrettierButton/index.js +1 -1
  65. package/lib/plugins/CodeActionMenuPlugin/utils.js +1 -1
  66. package/lib/plugins/CodeHighlightPrismPlugin/index.js +1 -1
  67. package/lib/plugins/CodeHighlightShikiPlugin/index.js +1 -1
  68. package/lib/plugins/CollapsiblePlugin/CollapsibleContainerNode.js +1 -1
  69. package/lib/plugins/CollapsiblePlugin/CollapsibleContentNode.js +1 -1
  70. package/lib/plugins/CollapsiblePlugin/CollapsibleTitleNode.js +1 -1
  71. package/lib/plugins/CollapsiblePlugin/CollapsibleUtils.js +1 -1
  72. package/lib/plugins/CollapsiblePlugin/index.js +1 -1
  73. package/lib/plugins/CounterPlugin/index.js +1 -1
  74. package/lib/plugins/DateTimePlugin/index.js +1 -1
  75. package/lib/plugins/DebugPlugin/index.js +59 -3
  76. package/lib/plugins/DragDropPastePlugin/index.js +1 -1
  77. package/lib/plugins/EmojiPickerPlugin/index.js +1 -1
  78. package/lib/plugins/EmojisPlugin/index.js +1 -1
  79. package/lib/plugins/FigmaPlugin/index.js +1 -1
  80. package/lib/plugins/KeywordsPlugin/index.js +1 -1
  81. package/lib/plugins/LayoutPlugin/LayoutPlugin.js +1 -1
  82. package/lib/plugins/MarkdownTransformers/index.js +1 -1
  83. package/lib/plugins/MaxLengthPlugin/index.js +1 -1
  84. package/lib/plugins/PageBreakPlugin/index.js +1 -1
  85. package/lib/plugins/ShortcutsPlugin/index.js +1 -1
  86. package/lib/plugins/ShortcutsPlugin/shortcuts.js +1 -1
  87. package/lib/plugins/SpecialTextPlugin/index.js +1 -1
  88. package/lib/plugins/SpeechToTextPlugin/index.js +1 -1
  89. package/lib/plugins/StickyPlugin/index.js +1 -1
  90. package/lib/plugins/TabFocusPlugin/index.js +1 -1
  91. package/lib/plugins/ToolbarPlugin/fontSize.js +1 -1
  92. package/lib/plugins/ToolbarPlugin/utils.js +1 -1
  93. package/lib/plugins/TwitterPlugin/index.js +1 -1
  94. package/lib/plugins/TypingPerfPlugin/index.js +1 -1
  95. package/lib/plugins/YouTubePlugin/index.js +1 -1
  96. package/lib/server/validation.js +1 -1
  97. package/lib/setupEnv.js +1 -1
  98. package/lib/themes/CommentEditorTheme.js +1 -1
  99. package/lib/themes/PlaygroundEditorTheme.js +1 -1
  100. package/lib/themes/StickyEditorTheme.js +1 -1
  101. package/lib/tyes.dt.js +1 -1
  102. package/lib/utils/docSerialization.js +1 -1
  103. package/lib/utils/emoji-list.js +1 -1
  104. package/lib/utils/getDOMRangeRect.js +1 -1
  105. package/lib/utils/getSelectedNode.js +1 -1
  106. package/lib/utils/getThemeSelector.js +1 -1
  107. package/lib/utils/isMobileWidth.js +1 -1
  108. package/lib/utils/joinClasses.js +1 -1
  109. package/lib/utils/setFloatingElemPosition.js +1 -1
  110. package/lib/utils/setFloatingElemPositionForLinkEditor.js +1 -1
  111. package/lib/utils/swipe.js +1 -1
  112. package/lib/utils/url.js +1 -1
  113. package/package.json +4 -4
@@ -1,9 +1,10 @@
1
1
  /*
2
- * Copyright (c) 2023-2025 Datalayer, Inc.
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
3
  * Distributed under the terms of the MIT License.
4
4
  */
5
5
  import { $createParagraphNode, $isElementNode } from 'lexical';
6
6
  import { getNodeMapper } from '../nodes/NodesMapper';
7
+ import { invariant } from '../utils/Invariant';
7
8
  /**
8
9
  * Create ElementNode in Loro tree
9
10
  */
@@ -28,7 +29,7 @@ options) {
28
29
  /**
29
30
  * Update ElementNode in Loro tree
30
31
  */
31
- export function updateElementNodeInLoro(nodeKey, elementType, parentId, index, metadata, lexicalNodeJSON, // JSON object from exportJSON()
32
+ export function updateElementNodeInLoro(nodeKey, elementType, parentId, index, previousSiblingId, nextSiblingId, metadata, lexicalNodeJSON, // JSON object from exportJSON()
32
33
  options) {
33
34
  const mapper = getNodeMapper();
34
35
  // Get the existing tree node using the mapper (don't pass lexicalNode to avoid context issues)
@@ -42,44 +43,87 @@ options) {
42
43
  if (elementType !== undefined) {
43
44
  treeNode.data.set('elementType', elementType);
44
45
  }
45
- // Move the node if parent or position changed
46
+ // Move the node only if its position in Loro actually differs from Lexical.
46
47
  if (parentId !== undefined || index !== undefined) {
47
- // Note: For moving, we need access to tree - this might need to be propagated differently
48
48
  const { tree } = options;
49
- // CRITICAL: Prevent cycle moves - a node cannot be its own parent
50
- if (parentId && treeNode.id === parentId) {
51
- console.warn(`🚨 CYCLE MOVE DETECTED: Node ${treeNode.id} cannot be its own parent! Skipping move operation.`);
52
- return;
53
- }
54
- // Debug: Check if the parent exists and its children count
55
- const parentNode = parentId ? tree.getNodeByID(parentId) : null;
56
- const parentChildCount = parentNode ? parentNode.children.length : tree.roots().length;
57
- // Check if the node is already a child of the target parent
49
+ // A node can never be its own parent that indicates a wrong parent
50
+ // mapping upstream, not something to silently skip.
51
+ invariant(!(parentId && treeNode.id === parentId), 'updateElementNodeInLoro: cycle move (node is its own parent)', { nodeKey, treeId: treeNode.id });
52
+ // Where does the node currently sit in the Loro tree?
58
53
  const currentParent = treeNode.parent();
59
- const isAlreadyChild = currentParent?.id === parentId;
60
- if (index !== undefined) {
61
- let adjustedIndex = index;
62
- if (!isAlreadyChild) {
63
- // Moving to a new parent - check bounds against current child count
64
- if (index > parentChildCount) {
65
- adjustedIndex = parentChildCount;
54
+ const currentParentId = currentParent ? currentParent.id : undefined;
55
+ const siblings = currentParent ? (currentParent.children() ?? []) : tree.roots();
56
+ const currentIndex = siblings.findIndex(child => child.id === treeNode.id);
57
+ const sameParent = currentParentId === parentId;
58
+ // Re-issuing a move on every content edit is unnecessary and races with
59
+ // sibling creation (the classic source of "index out of range"). Only move
60
+ // when the parent or index genuinely changed. This is a real no-op check,
61
+ // not a masked failure.
62
+ const needsMove = !sameParent || (index !== undefined && index !== currentIndex);
63
+ if (needsMove) {
64
+ const parentNode = parentId ? tree.getNodeByID(parentId) : null;
65
+ const parentChildCount = parentNode
66
+ ? (parentNode.children()?.length ?? 0)
67
+ : tree.roots().length;
68
+ if (index !== undefined) {
69
+ // Loro move index bounds:
70
+ // same-parent reorder : [0, children.length - 1]
71
+ // cross-parent move : [0, children.length]
72
+ // An index outside this range means the position was computed against
73
+ // siblings that are not yet synced. Before failing, resolve index
74
+ // from mapped sibling anchors when available.
75
+ let targetIndex = index;
76
+ const maxIndex = sameParent
77
+ ? Math.max(parentChildCount - 1, 0)
78
+ : parentChildCount;
79
+ if (targetIndex < 0 || targetIndex > maxIndex) {
80
+ const targetSiblings = parentNode
81
+ ? (parentNode.children() ?? [])
82
+ : tree.roots();
83
+ if (previousSiblingId) {
84
+ const prevIndex = targetSiblings.findIndex(child => child.id === previousSiblingId);
85
+ if (prevIndex >= 0) {
86
+ targetIndex = prevIndex + 1;
87
+ }
88
+ }
89
+ if ((targetIndex < 0 || targetIndex > maxIndex) && nextSiblingId) {
90
+ const nextIndex = targetSiblings.findIndex(child => child.id === nextSiblingId);
91
+ if (nextIndex >= 0) {
92
+ targetIndex = nextIndex;
93
+ }
94
+ }
95
+ // A move can observe transiently sparse sibling mappings within one
96
+ // update batch (e.g. pressing Enter at the end of the document, where
97
+ // the sibling created in the same batch is not yet mapped). When the
98
+ // anchors cannot resolve the position, clamp into the valid Loro range
99
+ // instead of throwing: a hard failure rolls back the user's keystroke,
100
+ // whereas a bounded placement still converges via the CRDT and can be
101
+ // refined by subsequent updates.
102
+ if (targetIndex < 0 || targetIndex > maxIndex) {
103
+ const clamped = Math.min(Math.max(targetIndex, 0), maxIndex);
104
+ console.warn(`[loro-collab] updateElementNodeInLoro: clamping out-of-range move index`, {
105
+ nodeKey,
106
+ treeId: treeNode.id,
107
+ index,
108
+ targetIndex,
109
+ clamped,
110
+ maxIndex,
111
+ sameParent,
112
+ previousSiblingId,
113
+ nextSiblingId,
114
+ });
115
+ targetIndex = clamped;
116
+ }
66
117
  }
118
+ tree.move(treeNode.id, parentId, targetIndex);
119
+ }
120
+ else {
121
+ tree.move(treeNode.id, parentId, parentChildCount);
67
122
  }
68
- tree.move(treeNode.id, parentId, adjustedIndex);
69
- }
70
- else {
71
- // No specific index, append to end
72
- tree.move(treeNode.id, parentId, parentChildCount);
73
123
  }
74
124
  }
75
- // The exported Lexical node data is already propagated by the mapper
76
- // No additional JSON export needed since mapper propagates exportJSON automatically
77
- try {
78
- treeNode.data.set('lastUpdated', Date.now());
79
- }
80
- catch (error) {
81
- console.warn(`🔄 ElementNode ${treeNode.id} container deleted during timestamp update (normal during operations):`, error.message);
82
- }
125
+ // The exported Lexical node data is already propagated by the mapper.
126
+ treeNode.data.set('lastUpdated', Date.now());
83
127
  }
84
128
  /**
85
129
  * Delete ElementNode from Loro tree
@@ -263,7 +307,7 @@ export function propagateElementNode(update, mutation, nodeKey, options) {
263
307
  // because SyncLexicalToLoro sorts element mutations by depth.
264
308
  // But as a safety net, log a warning.
265
309
  if (parent && !parentId) {
266
- console.warn(`⚠️ ElementNodePropagator: parent mapping missing for ${elementType} nodeKey=${nodeKey}, parentKey=${parent.getKey()}`);
310
+ console.warn(` ElementNodePropagator: parent mapping missing for ${elementType} nodeKey=${nodeKey}, parentKey=${parent.getKey()}`);
267
311
  }
268
312
  // Collect metadata (format, style, direction, etc.)
269
313
  if (typeof currentNode.getFormat === 'function') {
@@ -288,6 +332,8 @@ export function propagateElementNode(update, mutation, nodeKey, options) {
288
332
  if (currentNode && $isElementNode(currentNode)) {
289
333
  // Use editorState.read() to safely access node methods
290
334
  let parent, parentId, index;
335
+ let previousSiblingId;
336
+ let nextSiblingId;
291
337
  let elementType;
292
338
  const metadata = {};
293
339
  // Export node data and collect metadata within editor context
@@ -299,6 +345,27 @@ export function propagateElementNode(update, mutation, nodeKey, options) {
299
345
  const mapper = getNodeMapper();
300
346
  parentId = parent ? mapper.getTreeIDByLexicalKey(parent.getKey()) : undefined;
301
347
  index = currentNode.getIndexWithinParent();
348
+ // Resolve nearest mapped sibling anchors to stabilize target index
349
+ // when Lexical index includes siblings not yet represented in Loro.
350
+ if (parent) {
351
+ const siblings = parent.getChildren();
352
+ for (let i = index - 1; i >= 0; i--) {
353
+ const candidate = siblings[i];
354
+ const candidateTreeId = mapper.getTreeIDByLexicalKey(candidate.getKey());
355
+ if (candidateTreeId && tree.has(candidateTreeId)) {
356
+ previousSiblingId = candidateTreeId;
357
+ break;
358
+ }
359
+ }
360
+ for (let i = index + 1; i < siblings.length; i++) {
361
+ const candidate = siblings[i];
362
+ const candidateTreeId = mapper.getTreeIDByLexicalKey(candidate.getKey());
363
+ if (candidateTreeId && tree.has(candidateTreeId)) {
364
+ nextSiblingId = candidateTreeId;
365
+ break;
366
+ }
367
+ }
368
+ }
302
369
  // Get element type and metadata
303
370
  elementType = currentNode.getType();
304
371
  if (typeof currentNode.getFormat === 'function') {
@@ -314,7 +381,7 @@ export function propagateElementNode(update, mutation, nodeKey, options) {
314
381
  lexicalNodeJSON = currentNode.exportJSON();
315
382
  });
316
383
  // Update the node in Loro after safely reading from editor state
317
- updateElementNodeInLoro(nodeKey, elementType, parentId, index, metadata, lexicalNodeJSON, options);
384
+ updateElementNodeInLoro(nodeKey, elementType, parentId, index, previousSiblingId, nextSiblingId, metadata, lexicalNodeJSON, options);
318
385
  }
319
386
  break;
320
387
  }
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Copyright (c) 2023-2025 Datalayer, Inc.
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
3
  * Distributed under the terms of the MIT License.
4
4
  */
5
5
  import { $createLineBreakNode, $isLineBreakNode } from 'lexical';
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Copyright (c) 2023-2025 Datalayer, Inc.
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
3
  * Distributed under the terms of the MIT License.
4
4
  */
5
5
  import { $getRoot } from 'lexical';
@@ -1,21 +1,21 @@
1
1
  /*
2
- * Copyright (c) 2023-2025 Datalayer, Inc.
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
3
  * Distributed under the terms of the MIT License.
4
4
  */
5
5
  import { $createTextNode, $isTextNode } from 'lexical';
6
6
  import { getNodeMapper } from '../nodes/NodesMapper';
7
7
  import { $diffTextContentAndApplyDelta } from '../utils/Utils';
8
+ import { invariant } from '../utils/Invariant';
8
9
  /**
9
10
  * Create TextNode in Loro tree
10
11
  */
11
12
  export function createTextNodeInLoro(nodeKey, textContent, format, mode, parentId, index, lexicalNodeJSON, // JSON object from exportJSON()
12
13
  options) {
13
14
  const mapper = getNodeMapper();
14
- // Debug logging for text node creation issues
15
- if (!parentId) {
16
- console.warn(`❌ Creating TextNode ${nodeKey} without parent in Loro tree - THIS WILL FAIL`);
17
- return null; // Return early to avoid creating orphaned nodes
18
- }
15
+ // A text node is always a leaf with a parent element. A missing parent means
16
+ // the parent element was not synced first — surface it instead of creating an
17
+ // orphaned (unrenderable) node.
18
+ invariant(parentId !== undefined, 'createTextNodeInLoro: text node has no parent in Loro tree', { nodeKey });
19
19
  // Use mapper to get or create the tree node
20
20
  // Note: We can't pass lexicalNode directly due to context issues, but parentId should be sufficient
21
21
  const treeNode = mapper.getLoroNodeByLexicalKey(nodeKey, undefined, // don't pass lexicalNode to avoid context issues
@@ -49,44 +49,30 @@ options) {
49
49
  export function updateTextNodeInLoro(nodeKey, newTextContent, format, mode, lexicalNodeJSON, // JSON object from exportJSON()
50
50
  options) {
51
51
  const mapper = getNodeMapper();
52
- // Get the existing tree node using the mapper
53
- const treeNode = mapper.getLoroNodeByLexicalKey(nodeKey, undefined);
52
+ // Updates must target an existing mapped node. Creating on update can
53
+ // accidentally create inline nodes at root when parent info is unavailable.
54
+ const treeID = mapper.getTreeIDByLexicalKey(nodeKey);
55
+ if (!treeID || !options?.tree.has(treeID)) {
56
+ return;
57
+ }
58
+ const treeNode = options.tree.getNodeByID(treeID);
54
59
  if (!treeNode) {
55
- console.warn(`📝 TextNode ${nodeKey} not found in Loro, skipping update`);
56
60
  return;
57
61
  }
58
- // Note: Container validation is done in each try-catch block below since
59
- // the container can be deleted between operations
60
- // Store complete lexical node data as clean JSON if provided
62
+ // Store complete lexical node data as clean JSON if provided.
61
63
  if (lexicalNodeJSON) {
62
- try {
63
- // Store complete lexical JSON without the key
64
- if ('key' in lexicalNodeJSON || '__key' in lexicalNodeJSON || 'lexicalKey' in lexicalNodeJSON) {
65
- const { key, __key, lexicalKey, children, ...cleanedData } = lexicalNodeJSON;
66
- treeNode.data.set('lexical', cleanedData);
67
- }
68
- else {
69
- const { children, ...cleanedData } = lexicalNodeJSON;
70
- treeNode.data.set('lexical', cleanedData);
71
- }
64
+ if ('key' in lexicalNodeJSON || '__key' in lexicalNodeJSON || 'lexicalKey' in lexicalNodeJSON) {
65
+ const { key, __key, lexicalKey, children, ...cleanedData } = lexicalNodeJSON;
66
+ treeNode.data.set('lexical', cleanedData);
72
67
  }
73
- catch (error) {
74
- // This is expected during text operations when nodes get deleted/recreated
75
- console.warn(`📝 TextNode ${nodeKey} container was deleted during update, skipping (normal during text operations):`, error.message);
76
- return;
68
+ else {
69
+ const { children, ...cleanedData } = lexicalNodeJSON;
70
+ treeNode.data.set('lexical', cleanedData);
77
71
  }
78
72
  }
79
- // Update only essential metadata
80
- try {
81
- treeNode.data.set('elementType', 'text');
82
- treeNode.data.set('updatedAt', Date.now());
83
- }
84
- catch (error) {
85
- console.warn(`📝 TextNode ${nodeKey} container deleted during metadata update (normal during text operations):`, error.message);
86
- return;
87
- }
88
- // The exported Lexical node data is already propagated by the mapper
89
- // No additional JSON export needed since mapper propagates exportJSON automatically
73
+ // Update only essential metadata.
74
+ treeNode.data.set('elementType', 'text');
75
+ treeNode.data.set('updatedAt', Date.now());
90
76
  }
91
77
  /**
92
78
  * Delete TextNode from Loro tree
@@ -241,7 +227,7 @@ export function applyTextFormatInLoro(nodeKey, formatType, apply, options) {
241
227
  treeNode.data.get('format');
242
228
  }
243
229
  catch (error) {
244
- console.warn(`📝 TextNode ${nodeKey} container deleted during format check (normal during text operations):`, error.message);
230
+ console.warn(` TextNode ${nodeKey} container deleted during format check (normal during text operations):`, error.message);
245
231
  return;
246
232
  }
247
233
  let currentFormat = treeNode.data.get('format') || 0;
@@ -259,7 +245,7 @@ export function applyTextFormatInLoro(nodeKey, formatType, apply, options) {
259
245
  treeNode.data.set('lastUpdated', Date.now());
260
246
  }
261
247
  catch (error) {
262
- console.warn(`📝 TextNode ${nodeKey} container deleted during format update (normal during text operations):`, error.message);
248
+ console.warn(` TextNode ${nodeKey} container deleted during format update (normal during text operations):`, error.message);
263
249
  return;
264
250
  }
265
251
  }
@@ -289,7 +275,7 @@ export function propagateTextNode(update, mutation, nodeKey, options) {
289
275
  }
290
276
  }
291
277
  catch (error) {
292
- console.warn(`📝 Failed to get parent for existing TreeID ${existingTreeID}:`, error);
278
+ console.warn(` Failed to get parent for existing TreeID ${existingTreeID}:`, error);
293
279
  }
294
280
  return { currentParentId, expectedParentId };
295
281
  });
@@ -306,7 +292,7 @@ export function propagateTextNode(update, mutation, nodeKey, options) {
306
292
  }
307
293
  }
308
294
  catch (error) {
309
- console.warn(`📝 Could not get TreeNode for ${existingTreeID} during creation:`, error);
295
+ console.warn(` Could not get TreeNode for ${existingTreeID} during creation:`, error);
310
296
  }
311
297
  // Clear the old mapping first to avoid conflicts
312
298
  mapper.deleteMapping(nodeKey);
@@ -317,7 +303,7 @@ export function propagateTextNode(update, mutation, nodeKey, options) {
317
303
  }
318
304
  }
319
305
  catch (error) {
320
- console.warn(`📝 Failed to delete TreeNode ${actualTreeID} during creation:`, error);
306
+ console.warn(` Failed to delete TreeNode ${actualTreeID} during creation:`, error);
321
307
  }
322
308
  }
323
309
  }
@@ -366,7 +352,7 @@ export function propagateTextNode(update, mutation, nodeKey, options) {
366
352
  }
367
353
  }
368
354
  catch (error) {
369
- console.warn(`📝 Failed to get parent for existing TreeID ${existingTreeID} during update:`, error);
355
+ console.warn(` Failed to get parent for existing TreeID ${existingTreeID} during update:`, error);
370
356
  }
371
357
  return { currentParentId, expectedParentId, parent };
372
358
  });
@@ -380,7 +366,7 @@ export function propagateTextNode(update, mutation, nodeKey, options) {
380
366
  }
381
367
  }
382
368
  catch (error) {
383
- console.warn(`📝 Could not get TreeNode for ${existingTreeID}:`, error);
369
+ console.warn(` Could not get TreeNode for ${existingTreeID}:`, error);
384
370
  }
385
371
  // Clear the old mapping first to avoid conflicts
386
372
  mapper.deleteMapping(nodeKey);
@@ -391,7 +377,7 @@ export function propagateTextNode(update, mutation, nodeKey, options) {
391
377
  }
392
378
  }
393
379
  catch (error) {
394
- console.warn(`📝 Failed to delete TreeNode ${actualTreeID} during update:`, error);
380
+ console.warn(` Failed to delete TreeNode ${actualTreeID} during update:`, error);
395
381
  }
396
382
  // Now recreate the TextNode with the new parent (treat as "created")
397
383
  const { parentId, index, textContent, format, mode, lexicalNodeJSON } = update.editorState.read(() => {
@@ -413,6 +399,30 @@ export function propagateTextNode(update, mutation, nodeKey, options) {
413
399
  return; // Exit early since we recreated the node
414
400
  }
415
401
  }
402
+ // If a text node emits only an "updated" mutation before we have a
403
+ // mapping, treat it as a create so parent/index are explicit.
404
+ if (!existingTreeID) {
405
+ const { parent, parentId, index, textContent, format, mode, lexicalNodeJSON } = update.editorState.read(() => {
406
+ const parent = currentNode.getParent();
407
+ const parentId = parent ? mapper.getTreeIDByLexicalKey(parent.getKey()) : undefined;
408
+ const index = currentNode.getIndexWithinParent();
409
+ const textContent = currentNode.getTextContent();
410
+ const format = currentNode.getFormat();
411
+ const mode = currentNode.getMode();
412
+ let lexicalNodeJSON = undefined;
413
+ try {
414
+ lexicalNodeJSON = currentNode.exportJSON();
415
+ }
416
+ catch (error) {
417
+ console.warn('Failed to export node JSON in propagateTextNode updated(create fallback):', error);
418
+ }
419
+ return { parent, parentId, index, textContent, format, mode, lexicalNodeJSON };
420
+ });
421
+ if (parentId !== undefined) {
422
+ createTextNodeInLoro(nodeKey, textContent, format, mode, parentId, index, lexicalNodeJSON, options);
423
+ }
424
+ return;
425
+ }
416
426
  // Normal update case - parent hasn't changed
417
427
  const { textContent, format, mode, lexicalNodeJSON } = update.editorState.read(() => {
418
428
  const textContent = currentNode.getTextContent();
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { ElementNode, NodeKey } from 'lexical';
8
8
  import { Binding } from '../Bindings';
9
- import { LoroTree } from 'loro-crdt/bundler/loro_wasm';
9
+ import type { LoroTree } from 'loro-crdt';
10
10
  export * from './RootNodePropagator';
11
11
  export * from './LineBreakNodePropagator';
12
12
  export * from './ElementNodePropagator';
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Copyright (c) 2023-2025 Datalayer, Inc.
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
3
  * Distributed under the terms of the MIT License.
4
4
  */
5
5
  // Root Node Propagator
@@ -60,6 +60,7 @@ export declare class WebsocketProvider extends ObservableV2<any> {
60
60
  _exitIntegrator: any;
61
61
  _bcSubscriber: any;
62
62
  _lastExportedVersion: VersionVector;
63
+ _pendingUpdates: Uint8Array[];
63
64
  /**
64
65
  * @param {string} wsServerUrl
65
66
  * @param {string} docId
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Copyright (c) 2023-2025 Datalayer, Inc.
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
3
  * Distributed under the terms of the MIT License.
4
4
  */
5
5
  import { EphemeralStore } from 'loro-crdt';
@@ -28,7 +28,7 @@ class AwarenessAdapter {
28
28
  this.ephemeralStore = ephemeralStore;
29
29
  // Use the same client ID as the binding for consistency
30
30
  this.localClientId = doc ? generateClientID(doc) : generateRandomClientID();
31
- console.log('🔄 AwarenessAdapter created:', {
31
+ console.log(' AwarenessAdapter created:', {
32
32
  localClientId: this.localClientId,
33
33
  docPeerId: doc ? doc.peerId : 'no-doc',
34
34
  existingStatesCount: Object.keys(ephemeralStore.getAllStates()).length
@@ -256,6 +256,7 @@ messageHandlers[messageUpdate] = (provider, message, emitSynced) => {
256
256
  // The key point is that it's NOT our local peerId
257
257
  const importStatus = provider.doc.import(updateBytes);
258
258
  const afterVersion = provider.doc.version();
259
+ console.log('[SEED-DEBUG] messageUpdate: imported', updateBytes.length, 'bytes; importStatus=', JSON.stringify(importStatus));
259
260
  // Update our last exported version to include the remote changes
260
261
  // This ensures we don't re-export remote changes
261
262
  provider._lastExportedVersion = afterVersion;
@@ -265,7 +266,7 @@ messageHandlers[messageUpdate] = (provider, message, emitSynced) => {
265
266
  return null; // No response needed
266
267
  }
267
268
  catch (error) {
268
- console.warn(`❌ [LORO-UPDATE-ERROR] Failed to apply Loro update:`, error);
269
+ console.warn(` [LORO-UPDATE-ERROR] Failed to apply Loro update:`, error);
269
270
  return null;
270
271
  }
271
272
  };
@@ -432,7 +433,7 @@ const setupWS = (provider) => {
432
433
  provider.emit('status', [{
433
434
  status: 'connected'
434
435
  }]);
435
- console.log(' WebSocket connection established, requesting initial data');
436
+ console.log(' WebSocket connection established, requesting initial data');
436
437
  // Since we're in onopen, we know the WebSocket is ready
437
438
  // Use sendMessage directly to avoid any race conditions
438
439
  // Only request snapshot if we haven't already loaded it
@@ -445,12 +446,12 @@ const setupWS = (provider) => {
445
446
  docId: provider.docId,
446
447
  clientId: clientId
447
448
  };
448
- console.log(`🔄 Requesting initial snapshot from server (ID: ${requestId}, clientId: ${clientId}):`, snapshotRequest);
449
- console.log(`🔄 Provider instance ID: ${provider.wsServerUrl}/${provider.docId}, snapshotLoaded: ${provider.snapshotLoaded}`);
449
+ console.log(` Requesting initial snapshot from server (ID: ${requestId}, clientId: ${clientId}):`, snapshotRequest);
450
+ console.log(` Provider instance ID: ${provider.wsServerUrl}/${provider.docId}, snapshotLoaded: ${provider.snapshotLoaded}`);
450
451
  sendMessage(ws, snapshotRequest);
451
452
  }
452
453
  else {
453
- console.log('📸 Snapshot already loaded, skipping request');
454
+ console.log(' Snapshot already loaded, skipping request');
454
455
  }
455
456
  // Then request initial ephemeral state from server
456
457
  const clientId = generateClientID(provider.doc).toString();
@@ -482,6 +483,22 @@ const setupWS = (provider) => {
482
483
  });
483
484
  }
484
485
  }
486
+ // Flush any local document updates that were buffered while the socket
487
+ // was not ready to send (e.g. the initial-content bootstrap ran before
488
+ // the connection opened). Without this, seeded content committed before
489
+ // the socket opened would never reach the server or other collaborators.
490
+ if (provider._pendingUpdates.length > 0) {
491
+ const pending = provider._pendingUpdates;
492
+ provider._pendingUpdates = [];
493
+ console.log('[SEED-DEBUG] ws.onopen: flushing', pending.length, 'buffered local updates');
494
+ for (const pendingUpdate of pending) {
495
+ sendMessage(ws, {
496
+ type: 'update',
497
+ update: Array.from(pendingUpdate),
498
+ docId: provider.docId
499
+ });
500
+ }
501
+ }
485
502
  };
486
503
  provider.emit('status', [{
487
504
  status: 'connecting'
@@ -497,13 +514,13 @@ const broadcastMessage = (provider, message) => {
497
514
  sendMessage(ws, message);
498
515
  }
499
516
  else {
500
- console.log(' [BROADCAST] WebSocket not ready for sending');
517
+ console.log(' [BROADCAST] WebSocket not ready for sending');
501
518
  }
502
519
  if (provider.bcconnected) {
503
520
  bc.publish(provider.bcChannel, JSON.stringify(message), provider);
504
521
  }
505
522
  else {
506
- console.log('📻 [BROADCAST] BroadcastChannel not connected');
523
+ console.log(' [BROADCAST] BroadcastChannel not connected');
507
524
  }
508
525
  };
509
526
  /**
@@ -541,6 +558,7 @@ export class WebsocketProvider extends ObservableV2 {
541
558
  _exitIntegrator = null;
542
559
  _bcSubscriber = null;
543
560
  _lastExportedVersion = null; // Track last exported version for incremental updates
561
+ _pendingUpdates = []; // Local updates buffered while the socket is not ready to send
544
562
  /**
545
563
  * @param {string} wsServerUrl
546
564
  * @param {string} docId
@@ -587,7 +605,7 @@ export class WebsocketProvider extends ObservableV2 {
587
605
  console.log('🆕 Created new global EphemeralStore');
588
606
  }
589
607
  else {
590
- console.log('♻️ Reusing existing global EphemeralStore - cleaning up stale user states');
608
+ console.log(' Reusing existing global EphemeralStore - cleaning up stale user states');
591
609
  // Clean up all existing user states when reusing store to prevent accumulation
592
610
  const allStates = WebsocketProvider.globalEphemeralStore.getAllStates();
593
611
  Object.keys(allStates).forEach(key => {
@@ -595,7 +613,7 @@ export class WebsocketProvider extends ObservableV2 {
595
613
  const clientId = parseInt(key, 10);
596
614
  if (!isNaN(clientId)) {
597
615
  WebsocketProvider.globalEphemeralStore.delete(key);
598
- console.log('🧹 Cleaned up stale user state:', key);
616
+ console.log(' Cleaned up stale user state:', key);
599
617
  }
600
618
  });
601
619
  }
@@ -673,7 +691,24 @@ export class WebsocketProvider extends ObservableV2 {
673
691
  update: Array.from(update),
674
692
  docId: this.docId
675
693
  };
676
- broadcastMessage(this, updateMessage);
694
+ const ws = this.ws;
695
+ if (this.wsconnected && ws && ws.readyState === ws.OPEN) {
696
+ console.log('[SEED-DEBUG] _updateHandler: sending local update over WS', update.length, 'bytes');
697
+ sendMessage(ws, updateMessage);
698
+ }
699
+ else {
700
+ // The socket is not ready yet — this happens when the initial-content
701
+ // bootstrap commits before the connection opens. Loro delivers each
702
+ // local update to subscribeLocalUpdates exactly once, so dropping it
703
+ // here would lose the seeded content permanently (remote peers would
704
+ // never receive it). Buffer it and flush on open instead.
705
+ console.log('[SEED-DEBUG] _updateHandler: WS not ready, buffering local update', update.length, 'bytes (pending now', this._pendingUpdates.length + 1, ')');
706
+ this._pendingUpdates.push(update);
707
+ }
708
+ // Mirror to other same-origin tabs when the BroadcastChannel is available.
709
+ if (this.bcconnected) {
710
+ bc.publish(this.bcChannel, JSON.stringify(updateMessage), this);
711
+ }
677
712
  };
678
713
  // Document update integrater - called when Loro emits document change events
679
714
  /**
@@ -1,4 +1,4 @@
1
1
  /*
2
- * Copyright (c) 2023-2025 Datalayer, Inc.
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
3
  * Distributed under the terms of the MIT License.
4
4
  */
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Copyright (c) 2023-2025 Datalayer, Inc.
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
3
  * Distributed under the terms of the MIT License.
4
4
  */
5
5
  import http from 'http';
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /*
3
- * Copyright (c) 2023-2025 Datalayer, Inc.
3
+ * Copyright (c) 2025-2026 Datalayer, Inc.
4
4
  * Distributed under the terms of the MIT License.
5
5
  */
6
6
  import { WebSocketServer } from 'ws';