Skip to content

Commit 0895a93

Browse files
authored
fix(collab): prevent stale view when remote Y.js changes bypass sdBlockRev increment (#2099)
* fix(collab): prevent stale view when remote Y.js changes bypass sdBlockRev increment FlowBlockCache uses sdBlockRev for O(1) cache invalidation, but the collaboration plugin skips incrementing sdBlockRev for Y.js-origin transactions to avoid an infinite sync loop. This created a window where remote content changes arrive but the cache returns stale blocks because the rev appears unchanged. Fix: add setHasExternalChanges() to FlowBlockCache. When a Y.js-origin transaction is detected in PresentationEditor, the cache falls back to JSON comparison for that cycle instead of trusting the matching rev. The flag auto-clears on commit() so normal editing remains O(1). Also fix file upload in collab mode: use replaceFile() on the existing editor rather than destroying and recreating SuperDoc, which caused a race condition where the empty Y.js room state overwrote the new document content. * fix: preserve nodeJson on fast cache hits for external-change fallback
1 parent 258a24c commit 0895a93

File tree

5 files changed

+287
-15
lines changed

5 files changed

+287
-15
lines changed

packages/layout-engine/pm-adapter/src/cache.d.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,15 @@ export declare class FlowBlockCache {
4040
*/
4141
begin(): void;
4242

43+
/**
44+
* Signal that external changes (e.g. Y.js collaboration) may have modified
45+
* document content without updating sdBlockRev.
46+
*/
47+
setHasExternalChanges(value: boolean): void;
48+
4349
/**
4450
* Look up cached blocks for a paragraph by its stable ID.
45-
* Returns the cached entry only if the node content matches (via JSON comparison).
51+
* Returns the cached entry only if the node content matches.
4652
*
4753
* @param id - Stable paragraph ID (sdBlockId or paraId)
4854
* @param node - Current PM node (JSON object) to compare against cached version

packages/layout-engine/pm-adapter/src/cache.test.ts

Lines changed: 219 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,225 @@
11
import { describe, it, expect } from 'vitest';
2-
import { shiftBlockPositions, shiftCachedBlocks } from './cache.js';
2+
import { FlowBlockCache, shiftBlockPositions, shiftCachedBlocks } from './cache.js';
33
import type { FlowBlock, ParagraphBlock, ImageBlock, DrawingBlock, Run } from '@superdoc/contracts';
44

5+
describe('FlowBlockCache', () => {
6+
const makeParagraphNode = (text: string, rev: number) => ({
7+
type: 'paragraph',
8+
attrs: { sdBlockId: 'p1', sdBlockRev: rev, paraId: null },
9+
content: [{ type: 'run', content: [{ type: 'text', text }] }],
10+
});
11+
12+
const mockBlocks: FlowBlock[] = [
13+
{ kind: 'paragraph', id: 'p1', runs: [{ text: 'hello', pmStart: 0, pmEnd: 5 } as Run] } as ParagraphBlock,
14+
];
15+
16+
it('returns MISS when no cached entry exists', () => {
17+
const cache = new FlowBlockCache();
18+
cache.begin();
19+
20+
const node = makeParagraphNode('hello', 1);
21+
const result = cache.get('p1', node);
22+
23+
expect(result.entry).toBeNull();
24+
});
25+
26+
it('returns HIT when sdBlockRev matches', () => {
27+
const cache = new FlowBlockCache();
28+
const node = makeParagraphNode('hello', 1);
29+
30+
// Populate cache
31+
cache.begin();
32+
cache.set('p1', JSON.stringify(node), 1, mockBlocks, 0);
33+
cache.commit();
34+
35+
// Same node, same rev → HIT
36+
cache.begin();
37+
const result = cache.get('p1', node);
38+
39+
expect(result.entry).not.toBeNull();
40+
expect(result.entry!.blocks).toBe(mockBlocks);
41+
});
42+
43+
it('retains serialized node across fast-path hits so external fallback stays incremental', () => {
44+
const cache = new FlowBlockCache();
45+
const node = makeParagraphNode('hello', 5);
46+
47+
// Render 1: cache is populated with serialized JSON.
48+
cache.begin();
49+
cache.set('p1', JSON.stringify(node), 5, mockBlocks, 0);
50+
cache.commit();
51+
52+
// Render 2: local-only fast path hit, caller writes lookup payload into next generation.
53+
cache.begin();
54+
const fastPathHit = cache.get('p1', node);
55+
expect(fastPathHit.entry).not.toBeNull();
56+
cache.set('p1', fastPathHit.nodeJson, fastPathHit.nodeRev, fastPathHit.entry!.blocks, 0);
57+
cache.commit();
58+
59+
// Render 3: collaboration/external change mode requires JSON fallback.
60+
// With unchanged content this should still be a HIT.
61+
cache.setHasExternalChanges(true);
62+
cache.begin();
63+
const externalFallback = cache.get('p1', node);
64+
65+
expect(externalFallback.entry).not.toBeNull();
66+
});
67+
68+
it('returns MISS when sdBlockRev differs', () => {
69+
const cache = new FlowBlockCache();
70+
const nodeV1 = makeParagraphNode('hello', 1);
71+
const nodeV2 = makeParagraphNode('hello world', 2);
72+
73+
// Populate with v1
74+
cache.begin();
75+
cache.set('p1', JSON.stringify(nodeV1), 1, mockBlocks, 0);
76+
cache.commit();
77+
78+
// v2 has different rev → MISS
79+
cache.begin();
80+
const result = cache.get('p1', nodeV2);
81+
82+
expect(result.entry).toBeNull();
83+
});
84+
85+
it('returns MISS when content changes with same sdBlockRev and externalChanges flag is set', () => {
86+
const cache = new FlowBlockCache();
87+
const nodeOriginal = makeParagraphNode('hello', 5);
88+
const nodeModifiedByYjs = makeParagraphNode('hello world from remote user', 5); // Same rev, different content!
89+
90+
// Populate cache with original content
91+
cache.begin();
92+
cache.set('p1', JSON.stringify(nodeOriginal), 5, mockBlocks, 0);
93+
cache.commit();
94+
95+
// Y.js-origin transaction changed content but blockNodePlugin didn't increment sdBlockRev.
96+
// With the externalChanges flag, the fast path falls through to JSON comparison.
97+
cache.setHasExternalChanges(true);
98+
cache.begin();
99+
const result = cache.get('p1', nodeModifiedByYjs);
100+
101+
expect(result.entry).toBeNull(); // Correct: JSON comparison catches the content change
102+
});
103+
104+
it('returns HIT when content is unchanged even with externalChanges flag', () => {
105+
const cache = new FlowBlockCache();
106+
const node = makeParagraphNode('hello', 5);
107+
108+
cache.begin();
109+
cache.set('p1', JSON.stringify(node), 5, mockBlocks, 0);
110+
cache.commit();
111+
112+
// externalChanges flag is set but content is identical — should still HIT
113+
cache.setHasExternalChanges(true);
114+
cache.begin();
115+
const result = cache.get('p1', node);
116+
117+
expect(result.entry).not.toBeNull(); // JSON comparison confirms content is same
118+
});
119+
120+
it('without externalChanges flag, same sdBlockRev trusts fast path (HIT)', () => {
121+
const cache = new FlowBlockCache();
122+
const nodeOriginal = makeParagraphNode('hello', 5);
123+
const nodeModified = makeParagraphNode('hello world', 5); // Same rev, different content
124+
125+
cache.begin();
126+
cache.set('p1', JSON.stringify(nodeOriginal), 5, mockBlocks, 0);
127+
cache.commit();
128+
129+
// Without the flag, fast path trusts sdBlockRev → HIT (this is the performance path)
130+
cache.begin();
131+
const result = cache.get('p1', nodeModified);
132+
133+
expect(result.entry).not.toBeNull(); // Fast path HIT — correct for local-only edits
134+
});
135+
136+
it('commit() clears externalChanges flag', () => {
137+
const cache = new FlowBlockCache();
138+
const nodeOriginal = makeParagraphNode('hello', 5);
139+
const nodeModified = makeParagraphNode('hello world', 5);
140+
141+
cache.begin();
142+
cache.set('p1', JSON.stringify(nodeOriginal), 5, mockBlocks, 0);
143+
cache.commit();
144+
145+
// Set flag and commit (which should clear it)
146+
cache.setHasExternalChanges(true);
147+
cache.begin();
148+
cache.set('p1', JSON.stringify(nodeOriginal), 5, mockBlocks, 0);
149+
cache.commit(); // This clears externalChanges
150+
151+
// Now query with modified content — flag was cleared, so fast path applies
152+
cache.begin();
153+
const result = cache.get('p1', nodeModified);
154+
155+
expect(result.entry).not.toBeNull(); // Fast path HIT — flag was cleared by commit
156+
});
157+
158+
it('returns MISS via JSON fallback when sdBlockRev is unavailable', () => {
159+
const cache = new FlowBlockCache();
160+
const nodeNoRev = { type: 'paragraph', attrs: { sdBlockId: 'p1' }, content: [{ type: 'text', text: 'hello' }] };
161+
const nodeNoRevModified = {
162+
type: 'paragraph',
163+
attrs: { sdBlockId: 'p1' },
164+
content: [{ type: 'text', text: 'hello world' }],
165+
};
166+
167+
// Populate without rev
168+
cache.begin();
169+
cache.set('p1', JSON.stringify(nodeNoRev), null, mockBlocks, 0);
170+
cache.commit();
171+
172+
// Different content, no rev → falls to JSON comparison → MISS
173+
cache.begin();
174+
const result = cache.get('p1', nodeNoRevModified);
175+
176+
expect(result.entry).toBeNull(); // Correct: JSON comparison catches the change
177+
});
178+
179+
it('clear() resets all cache state', () => {
180+
const cache = new FlowBlockCache();
181+
const node = makeParagraphNode('hello', 1);
182+
183+
cache.begin();
184+
cache.set('p1', JSON.stringify(node), 1, mockBlocks, 0);
185+
cache.commit();
186+
187+
cache.clear();
188+
189+
cache.begin();
190+
const result = cache.get('p1', node);
191+
192+
expect(result.entry).toBeNull(); // Cache was cleared
193+
});
194+
195+
it('commit() discards entries not seen in current render', () => {
196+
const cache = new FlowBlockCache();
197+
const nodeA = makeParagraphNode('hello', 1);
198+
const nodeB = {
199+
...makeParagraphNode('world', 1),
200+
attrs: { ...makeParagraphNode('world', 1).attrs, sdBlockId: 'p2' },
201+
};
202+
203+
// Render 1: both paragraphs
204+
cache.begin();
205+
cache.set('p1', JSON.stringify(nodeA), 1, mockBlocks, 0);
206+
cache.set('p2', JSON.stringify(nodeB), 1, mockBlocks, 10);
207+
cache.commit();
208+
209+
// Render 2: only p1 (p2 was deleted)
210+
cache.begin();
211+
cache.get('p1', nodeA); // access p1
212+
cache.set('p1', JSON.stringify(nodeA), 1, mockBlocks, 0);
213+
cache.commit();
214+
215+
// Render 3: p2 should be gone
216+
cache.begin();
217+
const result = cache.get('p2', nodeB);
218+
219+
expect(result.entry).toBeNull(); // p2 was pruned
220+
});
221+
});
222+
5223
describe('shiftBlockPositions', () => {
6224
describe('paragraph blocks', () => {
7225
it('shifts pmStart and pmEnd in runs', () => {

packages/layout-engine/pm-adapter/src/cache.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export class FlowBlockCache {
6565
#next = new Map<string, CachedParagraphEntry>();
6666
#hits = 0;
6767
#misses = 0;
68+
#hasExternalChanges = false;
6869

6970
/**
7071
* Begin a new render cycle. Clears the "next" map and resets stats.
@@ -75,9 +76,30 @@ export class FlowBlockCache {
7576
this.#misses = 0;
7677
}
7778

79+
/**
80+
* Signal that external changes (e.g. Y.js collaboration) may have modified
81+
* document content without updating sdBlockRev. When set, the fast revision
82+
* comparison falls through to a JSON equality check to prevent false cache hits.
83+
*
84+
* The flag is automatically cleared after {@link commit}.
85+
*/
86+
setHasExternalChanges(value: boolean): void {
87+
this.#hasExternalChanges = value;
88+
}
89+
7890
/**
7991
* Look up cached blocks for a paragraph by its stable ID.
80-
* Returns the cached entry only if the node content matches (via JSON comparison).
92+
* Returns the cached entry only if the node content matches.
93+
*
94+
* Uses a dual comparison strategy:
95+
* 1. Fast path: compare sdBlockRev numbers (O(1)). A different rev is a
96+
* definitive miss. A matching rev is a hit **only** when we trust that
97+
* sdBlockRev is always incremented for content changes (i.e. no external
98+
* changes pending).
99+
* 2. JSON path: full node serialization + string comparison. Used as a
100+
* safety net when external changes may have bypassed the revision counter
101+
* (e.g. Y.js-origin collaboration transactions) or when revision info is
102+
* unavailable.
81103
*
82104
* Always returns the serialized nodeJson to avoid double serialization -
83105
* pass this to set() instead of the node object.
@@ -92,24 +114,28 @@ export class FlowBlockCache {
92114
const cached = this.#previous.get(id);
93115
if (!cached) {
94116
this.#misses++;
95-
if (nodeRev != null) {
96-
return { entry: null, nodeRev };
97-
}
98-
// Serialize once - this is reused in set() to avoid double serialization
99117
const nodeJson = JSON.stringify(node);
100-
return { entry: null, nodeJson };
118+
return { entry: null, nodeJson, nodeRev };
101119
}
102120

103121
if (nodeRev != null && cached.nodeRev != null) {
122+
// Fast rejection: different revision is always a miss
104123
if (cached.nodeRev !== nodeRev) {
105124
this.#misses++;
106125
return { entry: null, nodeRev };
107126
}
108-
this.#hits++;
109-
return { entry: cached, nodeRev };
127+
128+
// Fast acceptance: safe only when all changes go through blockNodePlugin
129+
// (which always increments sdBlockRev for local edits). When external
130+
// changes are pending (e.g. Y.js collaboration), sdBlockRev may not have
131+
// been updated despite content changes — fall through to JSON comparison.
132+
if (!this.#hasExternalChanges) {
133+
this.#hits++;
134+
return { entry: cached, nodeRev, nodeJson: cached.nodeJson };
135+
}
110136
}
111137

112-
// Fallback to JSON comparison when revision is unavailable
138+
// JSON comparison: always correct, handles external changes and missing revisions
113139
const nodeJson = JSON.stringify(node);
114140
if (cached.nodeJson !== nodeJson) {
115141
this.#misses++;
@@ -141,10 +167,12 @@ export class FlowBlockCache {
141167
/**
142168
* Commit the current render cycle.
143169
* Swaps "next" to "previous", so only blocks seen in this render are retained.
170+
* Clears the external-changes flag since the render cycle consumed it.
144171
*/
145172
commit(): void {
146173
this.#previous = this.#next;
147174
this.#next = new Map();
175+
this.#hasExternalChanges = false;
148176
}
149177

150178
/**

packages/super-editor/src/core/presentation-editor/PresentationEditor.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2342,6 +2342,15 @@ export class PresentationEditor extends EventEmitter {
23422342
if (transaction) {
23432343
this.#epochMapper.recordTransaction(transaction);
23442344
this.#selectionSync.setDocEpoch(this.#epochMapper.getCurrentEpoch());
2345+
2346+
// Detect Y.js-origin transactions (remote collaboration changes).
2347+
// These bypass the blockNodePlugin's sdBlockRev increment to prevent
2348+
// feedback loops, so the FlowBlockCache's fast revision comparison
2349+
// cannot be trusted — signal it to fall through to JSON comparison.
2350+
const ySyncMeta = transaction.getMeta?.(ySyncPluginKey);
2351+
if (ySyncMeta?.isChangeOrigin && transaction.docChanged) {
2352+
this.#flowBlockCache?.setHasExternalChanges(true);
2353+
}
23452354
}
23462355
if (trackedChangesChanged || transaction?.docChanged) {
23472356
this.#pendingDocChange = true;

packages/superdoc/src/dev/components/SuperdocDev.vue

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,20 @@ const handleNewFile = async (file) => {
214214
currentFile.value = await getFileObject(url, file.name, file.type);
215215
}
216216
217-
nextTick(() => {
218-
init();
219-
});
217+
// In collab mode, use replaceFile() on the existing editor instead of
218+
// destroying and recreating SuperDoc. This avoids the Y.js race condition
219+
// where empty room state overwrites the DOCX content during reinit.
220+
if (useCollaboration && activeEditor.value && !isMarkdown && !isHtml) {
221+
try {
222+
await activeEditor.value.replaceFile(currentFile.value);
223+
console.log('[collab] Replaced file via editor.replaceFile()');
224+
} catch (err) {
225+
console.error('[collab] replaceFile failed, falling back to full reinit:', err);
226+
nextTick(() => init());
227+
}
228+
} else {
229+
nextTick(() => init());
230+
}
220231
221232
sidebarInstanceKey.value += 1;
222233
};
@@ -713,7 +724,7 @@ onMounted(async () => {
713724
const ydoc = new Y.Doc();
714725
const provider = new HocuspocusProvider({
715726
url: 'ws://localhost:3050',
716-
name: 'superdoc-dev-room',
727+
name: urlParams.get('room') || 'superdoc-dev-room',
717728
document: ydoc,
718729
});
719730

0 commit comments

Comments
 (0)