Skip to content

Commit 4437680

Browse files
authored
fix: yjs sync updates being blocked by locked sdt and charts (#2506)
* fix: allow Yjs sync through locked content while preserving chart immutability * fix: skip permission-tag repair for Yjs replacements and preserve chart fast path * fix: treat y-prosemirror snapshot exits as authoritative updates
1 parent d73490a commit 4437680

6 files changed

Lines changed: 273 additions & 5 deletions

File tree

packages/super-editor/src/extensions/chart/chart-immutability-plugin.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Plugin, PluginKey } from 'prosemirror-state';
2+
import { ySyncPluginKey } from 'y-prosemirror';
23

34
export const CHART_IMMUTABILITY_KEY = new PluginKey('chartImmutability');
45

@@ -122,15 +123,26 @@ export function createChartImmutabilityPlugin() {
122123
init(_, state) {
123124
return countChartNodes(state.doc);
124125
},
125-
apply(_tr, oldCount) {
126-
// filterTransaction guarantees no chart mutations passed through,
127-
// so the chart count is always unchanged after init.
126+
apply(tr, oldCount, _oldState, newState) {
127+
// Yjs-origin transactions bypass filterTransaction, so the chart
128+
// count may have changed. Recount to keep the fast-path guard
129+
// (oldCount === 0) accurate after collaborative syncs.
130+
if (tr.docChanged && tr.getMeta?.(ySyncPluginKey)) {
131+
// When the document had no charts, only do a full recount if the
132+
// incoming steps actually contain a chart node. This preserves
133+
// O(step slices) cost for text-only remote edits on chart-free docs.
134+
if (oldCount === 0 && !transactionInsertsChart(tr)) {
135+
return 0;
136+
}
137+
return countChartNodes(newState.doc);
138+
}
128139
return oldCount;
129140
},
130141
},
131142

132143
filterTransaction(tr, state) {
133144
if (!tr.docChanged) return true;
145+
if (tr.getMeta?.(ySyncPluginKey)) return true;
134146

135147
const oldCount = CHART_IMMUTABILITY_KEY.getState(state) ?? 0;
136148
if (oldCount === 0) {

packages/super-editor/src/extensions/chart/chart-immutability-plugin.test.js

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { describe, it, expect } from 'vitest';
2-
import { Schema } from 'prosemirror-model';
2+
import { Schema, Slice } from 'prosemirror-model';
33
import { EditorState } from 'prosemirror-state';
4-
import { createChartImmutabilityPlugin } from './chart-immutability-plugin.js';
4+
import { ySyncPluginKey } from 'y-prosemirror';
5+
import { createChartImmutabilityPlugin, CHART_IMMUTABILITY_KEY } from './chart-immutability-plugin.js';
56

67
const schema = new Schema({
78
nodes: {
@@ -107,4 +108,123 @@ describe('chart immutability plugin', () => {
107108
const result = state.applyTransaction(tr);
108109
expect(result.state.doc.textContent).toContain('typing');
109110
});
111+
112+
it('allows remote collaboration replacements that span chart nodes', () => {
113+
const state = createStateWithChart();
114+
const replacementDoc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, schema.text('remote'))]);
115+
const tr = state.tr
116+
.replace(0, state.doc.content.size, new Slice(replacementDoc.content, 0, 0))
117+
.setMeta(ySyncPluginKey, { isChangeOrigin: true });
118+
119+
const result = state.applyTransaction(tr);
120+
121+
expect(result.state.doc.textContent).toContain('remote');
122+
});
123+
124+
it('allows snapshot-exit replacements that span chart nodes (no isChangeOrigin)', () => {
125+
const state = createStateWithChart();
126+
const replacementDoc = schema.nodes.doc.create(null, [
127+
schema.nodes.paragraph.create(null, schema.text('snapshot exit')),
128+
]);
129+
// y-prosemirror's unrenderSnapshot() sets { snapshot: null, prevSnapshot: null }
130+
// with no isChangeOrigin flag.
131+
const tr = state.tr
132+
.replace(0, state.doc.content.size, new Slice(replacementDoc.content, 0, 0))
133+
.setMeta(ySyncPluginKey, { snapshot: null, prevSnapshot: null });
134+
135+
const result = state.applyTransaction(tr);
136+
137+
expect(result.state.doc.textContent).toContain('snapshot exit');
138+
});
139+
140+
it('rejects local chart deletion after Yjs sync inserts a chart', () => {
141+
// Start with no charts
142+
const state = createStateWithoutChart();
143+
144+
// Simulate Yjs sync that introduces a chart
145+
const chart = schema.nodes.chart.create({ chartData: { chartType: 'barChart', series: [] } });
146+
const paraWithChart = schema.nodes.paragraph.create(null, [schema.text('synced '), chart]);
147+
const syncDoc = schema.nodes.doc.create(null, [paraWithChart]);
148+
const syncTr = state.tr
149+
.replace(0, state.doc.content.size, new Slice(syncDoc.content, 0, 0))
150+
.setMeta(ySyncPluginKey, { isChangeOrigin: true });
151+
const synced = state.applyTransaction(syncTr).state;
152+
153+
// Local attempt to delete the chart must be rejected
154+
const chartPos = findChartPos(synced);
155+
expect(chartPos).toBeGreaterThan(-1);
156+
const deleteTr = synced.tr.delete(chartPos, chartPos + 1);
157+
const result = synced.applyTransaction(deleteTr);
158+
expect(result.state.doc.toString()).toBe(synced.doc.toString());
159+
});
160+
161+
it('rejects local attr change on chart after Yjs sync inserts it', () => {
162+
const state = createStateWithoutChart();
163+
164+
// Yjs sync introduces a chart
165+
const chart = schema.nodes.chart.create({ chartData: { chartType: 'barChart', series: [] } });
166+
const paraWithChart = schema.nodes.paragraph.create(null, [chart]);
167+
const syncDoc = schema.nodes.doc.create(null, [paraWithChart]);
168+
const syncTr = state.tr
169+
.replace(0, state.doc.content.size, new Slice(syncDoc.content, 0, 0))
170+
.setMeta(ySyncPluginKey, { isChangeOrigin: true });
171+
const synced = state.applyTransaction(syncTr).state;
172+
173+
// Local setNodeMarkup must be rejected
174+
const chartPos = findChartPos(synced);
175+
const attrTr = synced.tr.setNodeMarkup(chartPos, undefined, {
176+
chartData: { chartType: 'lineChart', series: [] },
177+
width: 999,
178+
height: 999,
179+
});
180+
const result = synced.applyTransaction(attrTr);
181+
expect(result.state.doc.toString()).toBe(synced.doc.toString());
182+
});
183+
184+
it('uses fast path after Yjs sync removes all charts', () => {
185+
const state = createStateWithChart();
186+
187+
// Yjs sync replaces doc with chart-free content
188+
const plainDoc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, schema.text('no charts'))]);
189+
const syncTr = state.tr
190+
.replace(0, state.doc.content.size, new Slice(plainDoc.content, 0, 0))
191+
.setMeta(ySyncPluginKey, { isChangeOrigin: true });
192+
const synced = state.applyTransaction(syncTr).state;
193+
194+
// Local text edits should still work
195+
const textTr = synced.tr.insertText('hello', 1);
196+
const result = synced.applyTransaction(textTr);
197+
expect(result.state.doc.textContent).toContain('hello');
198+
});
199+
200+
it('keeps chart count at 0 for Yjs text-only edits on chart-free docs (fast path)', () => {
201+
const state = createStateWithoutChart();
202+
expect(CHART_IMMUTABILITY_KEY.getState(state)).toBe(0);
203+
204+
// Simulate multiple Yjs text-only remote edits — chart count must stay 0
205+
// without walking the full document each time.
206+
let current = state;
207+
for (let i = 0; i < 5; i++) {
208+
const syncTr = current.tr.insertText(`k${i}`, 1).setMeta(ySyncPluginKey, { isChangeOrigin: true });
209+
current = current.applyTransaction(syncTr).state;
210+
expect(CHART_IMMUTABILITY_KEY.getState(current)).toBe(0);
211+
}
212+
});
213+
214+
it('recounts charts when Yjs sync introduces a chart into a chart-free doc', () => {
215+
const state = createStateWithoutChart();
216+
expect(CHART_IMMUTABILITY_KEY.getState(state)).toBe(0);
217+
218+
// Yjs sync introduces a chart
219+
const chart = schema.nodes.chart.create({ chartData: { chartType: 'barChart', series: [] } });
220+
const paraWithChart = schema.nodes.paragraph.create(null, [schema.text('text '), chart]);
221+
const syncDoc = schema.nodes.doc.create(null, [paraWithChart]);
222+
const syncTr = state.tr
223+
.replace(0, state.doc.content.size, new Slice(syncDoc.content, 0, 0))
224+
.setMeta(ySyncPluginKey, { isChangeOrigin: true });
225+
const synced = state.applyTransaction(syncTr).state;
226+
227+
// Chart count must update to 1
228+
expect(CHART_IMMUTABILITY_KEY.getState(synced)).toBe(1);
229+
});
110230
});

packages/super-editor/src/extensions/permission-ranges/permission-ranges.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Plugin, PluginKey } from 'prosemirror-state';
22
import { Mapping } from 'prosemirror-transform';
3+
import { ySyncPluginKey } from 'y-prosemirror';
34
import { Extension } from '@core/Extension.js';
45

56
const PERMISSION_PLUGIN_KEY = new PluginKey('permissionRanges');
@@ -304,6 +305,11 @@ export const PermissionRanges = Extension.create({
304305
// Appends transactions to the document to ensure permission ranges are updated.
305306
appendTransaction(transactions, oldState, newState) {
306307
if (!transactions.some((tr) => tr.docChanged)) return null;
308+
// Any y-prosemirror transaction (remote sync, snapshot enter/exit)
309+
// carries authoritative state — do not attempt to repair permission
310+
// tags against oldState or we will reinsert stale markers and
311+
// corrupt the shared document.
312+
if (transactions.some((tr) => tr.getMeta?.(ySyncPluginKey))) return null;
307313

308314
const permTypes = getPermissionTypeInfo(newState.schema);
309315
if (!permTypes.startTypes.length || !permTypes.endTypes.length) return null;
@@ -370,6 +376,7 @@ export const PermissionRanges = Extension.create({
370376
// Filters transactions to ensure only allowed edits are applied.
371377
filterTransaction(tr, state) {
372378
if (!tr.docChanged) return true;
379+
if (tr.getMeta?.(ySyncPluginKey)) return true;
373380
if (!editor || editor.options.documentMode !== 'viewing') return true;
374381
const pluginState = PERMISSION_PLUGIN_KEY.getState(state);
375382
if (!pluginState?.hasAllowedRanges) {

packages/super-editor/src/extensions/permission-ranges/permission-ranges.test.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
22
import { TextSelection } from 'prosemirror-state';
3+
import { Slice } from 'prosemirror-model';
4+
import { ySyncPluginKey } from 'y-prosemirror';
35

46
import { Editor } from '@core/index.js';
57
import { SuperConverter } from '@core/super-converter/SuperConverter.js';
@@ -166,6 +168,91 @@ describe('PermissionRanges extension', () => {
166168
expect(instance.state.doc.textBetween(editablePos, editablePos + 2)).toContain('Y');
167169
});
168170

171+
it('allows remote collaboration replacements that span permission ranges', () => {
172+
const instance = createEditor(docWithPermissionRange);
173+
const replacementDoc = instance.schema.nodes.doc.create(null, [
174+
instance.schema.nodes.paragraph.create(null, [instance.schema.text('Remote replacement')]),
175+
]);
176+
const tr = instance.state.tr
177+
.replace(0, instance.state.doc.content.size, new Slice(replacementDoc.content, 0, 0))
178+
.setMeta(ySyncPluginKey, { isChangeOrigin: true });
179+
180+
instance.view.dispatch(tr);
181+
182+
expect(instance.state.doc.textContent).toContain('Remote replacement');
183+
});
184+
185+
it('does not reinsert stale permission tags on snapshot-exit replace (no isChangeOrigin)', () => {
186+
const instance = createEditor(docWithPermissionRange);
187+
188+
// Verify the original doc has permStart and permEnd
189+
let hasPermStart = false;
190+
let hasPermEnd = false;
191+
instance.state.doc.descendants((node) => {
192+
if (node.type?.name === 'permStart') hasPermStart = true;
193+
if (node.type?.name === 'permEnd') hasPermEnd = true;
194+
});
195+
expect(hasPermStart).toBe(true);
196+
expect(hasPermEnd).toBe(true);
197+
198+
// Simulate y-prosemirror's unrenderSnapshot() — full-doc replace with
199+
// { snapshot: null, prevSnapshot: null } and NO isChangeOrigin flag.
200+
const replacementDoc = instance.schema.nodes.doc.create(null, [
201+
instance.schema.nodes.paragraph.create(null, [instance.schema.text('Snapshot exit content')]),
202+
]);
203+
const tr = instance.state.tr
204+
.replace(0, instance.state.doc.content.size, new Slice(replacementDoc.content, 0, 0))
205+
.setMeta(ySyncPluginKey, { snapshot: null, prevSnapshot: null });
206+
207+
instance.view.dispatch(tr);
208+
209+
// The resulting doc must NOT contain stale permission markers
210+
let permStartCount = 0;
211+
let permEndCount = 0;
212+
instance.state.doc.descendants((node) => {
213+
if (node.type?.name === 'permStart') permStartCount++;
214+
if (node.type?.name === 'permEnd') permEndCount++;
215+
});
216+
expect(permStartCount).toBe(0);
217+
expect(permEndCount).toBe(0);
218+
expect(instance.state.doc.textContent).toBe('Snapshot exit content');
219+
});
220+
221+
it('does not reinsert stale permission tags when Yjs replaces content that had them', () => {
222+
const instance = createEditor(docWithPermissionRange);
223+
224+
// Verify the original doc has permStart and permEnd
225+
let hasPermStart = false;
226+
let hasPermEnd = false;
227+
instance.state.doc.descendants((node) => {
228+
if (node.type?.name === 'permStart') hasPermStart = true;
229+
if (node.type?.name === 'permEnd') hasPermEnd = true;
230+
});
231+
expect(hasPermStart).toBe(true);
232+
expect(hasPermEnd).toBe(true);
233+
234+
// Simulate a Yjs sync that replaces the whole doc with content lacking perm tags
235+
const replacementDoc = instance.schema.nodes.doc.create(null, [
236+
instance.schema.nodes.paragraph.create(null, [instance.schema.text('Clean remote content')]),
237+
]);
238+
const tr = instance.state.tr
239+
.replace(0, instance.state.doc.content.size, new Slice(replacementDoc.content, 0, 0))
240+
.setMeta(ySyncPluginKey, { isChangeOrigin: true });
241+
242+
instance.view.dispatch(tr);
243+
244+
// The resulting doc must NOT contain stale permission markers
245+
let permStartCount = 0;
246+
let permEndCount = 0;
247+
instance.state.doc.descendants((node) => {
248+
if (node.type?.name === 'permStart') permStartCount++;
249+
if (node.type?.name === 'permEnd') permEndCount++;
250+
});
251+
expect(permStartCount).toBe(0);
252+
expect(permEndCount).toBe(0);
253+
expect(instance.state.doc.textContent).toBe('Clean remote content');
254+
});
255+
169256
it('blocks edits outside the block permission range but allows edits inside it', () => {
170257
const instance = createEditor(docWithBlockPermissionRange);
171258
const initialJson = instance.state.doc.toJSON();

packages/super-editor/src/extensions/structured-content/structured-content-lock-plugin.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Plugin, PluginKey } from 'prosemirror-state';
2+
import { ySyncPluginKey } from 'y-prosemirror';
23

34
export const STRUCTURED_CONTENT_LOCK_KEY = new PluginKey('structuredContentLock');
45

@@ -159,6 +160,13 @@ export function createStructuredContentLockPlugin() {
159160
return true;
160161
}
161162

163+
// Any y-prosemirror transaction (remote sync, snapshot enter/exit) must
164+
// always be applied locally to keep every client converged, even if the
165+
// incoming step spans locked SDTs.
166+
if (tr.getMeta?.(ySyncPluginKey)) {
167+
return true;
168+
}
169+
162170
const sdtNodes = STRUCTURED_CONTENT_LOCK_KEY.getState(state);
163171
if (sdtNodes.length === 0) {
164172
return true;

packages/super-editor/src/extensions/structured-content/structured-content-lock-plugin.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
22
import { EditorState, TextSelection } from 'prosemirror-state';
3+
import { Slice } from 'prosemirror-model';
4+
import { ySyncPluginKey } from 'y-prosemirror';
35
import { initTestEditor } from '@tests/helpers/helpers.js';
46

57
/**
@@ -376,6 +378,38 @@ describe('StructuredContentLockPlugin', () => {
376378
// Assert: should handle gracefully (exact behavior depends on schema)
377379
expect(newState).toBeDefined();
378380
});
381+
382+
it('allows remote collaboration replacements that span locked SDTs', () => {
383+
const doc = createDocWithSDT('sdtContentLocked', 'structuredContent');
384+
const state = applyDocToEditor(doc);
385+
const replacementParagraph = schema.nodes.paragraph.create(null, schema.text('Remote hello world'));
386+
const replacementDoc = schema.nodes.doc.create(null, [replacementParagraph]);
387+
388+
const tr = state.tr
389+
.replace(0, state.doc.content.size, new Slice(replacementDoc.content, 0, 0))
390+
.setMeta(ySyncPluginKey, { isChangeOrigin: true });
391+
392+
const result = state.applyTransaction(tr);
393+
394+
expect(result.state.doc.textContent).toContain('Remote hello world');
395+
});
396+
397+
it('allows snapshot-exit replacements that span locked SDTs (no isChangeOrigin)', () => {
398+
const doc = createDocWithSDT('sdtContentLocked', 'structuredContent');
399+
const state = applyDocToEditor(doc);
400+
const replacementParagraph = schema.nodes.paragraph.create(null, schema.text('Snapshot exit'));
401+
const replacementDoc = schema.nodes.doc.create(null, [replacementParagraph]);
402+
403+
// y-prosemirror's unrenderSnapshot() sets { snapshot: null, prevSnapshot: null }
404+
// with no isChangeOrigin flag.
405+
const tr = state.tr
406+
.replace(0, state.doc.content.size, new Slice(replacementDoc.content, 0, 0))
407+
.setMeta(ySyncPluginKey, { snapshot: null, prevSnapshot: null });
408+
409+
const result = state.applyTransaction(tr);
410+
411+
expect(result.state.doc.textContent).toContain('Snapshot exit');
412+
});
379413
});
380414

381415
describe('lock mode attribute validation', () => {

0 commit comments

Comments
 (0)