Skip to content

Commit 5c9f960

Browse files
authored
fix: smooth upgrade-to-collaboration remount and harden rollback (#2509)
1 parent b628be9 commit 5c9f960

2 files changed

Lines changed: 273 additions & 1 deletion

File tree

packages/superdoc/src/core/SuperDoc.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,8 @@ export class SuperDoc extends EventEmitter {
749749

750750
// Capture state for rollback and visual continuity
751751
const rollbackJson = sourceEditor.getJSON();
752+
const rollbackConvertedXml = JSON.parse(JSON.stringify(sourceEditor.converter?.convertedXml ?? {}));
753+
const rollbackMediaFiles = { ...(sourceEditor.options?.mediaFiles ?? {}) };
752754
const hadCommentsList = Boolean(this.commentsList);
753755

754756
// --- Snapshot live DOM before the point of no return ---
@@ -770,7 +772,8 @@ export class SuperDoc extends EventEmitter {
770772
throw remountError;
771773
}
772774

773-
// --- Rollback: keep snapshot visible, rebuild local runtime hidden ---
775+
// --- Rollback: stop the failed collaborative runtime, rebuild local ---
776+
this.#stopRuntime();
774777
this.#detachCollaboration();
775778
this.config.jsonOverride = rollbackJson;
776779

@@ -785,6 +788,7 @@ export class SuperDoc extends EventEmitter {
785788
}
786789

787790
this.config.jsonOverride = null;
791+
this.#restoreRollbackDocumentState(rollbackConvertedXml, rollbackMediaFiles);
788792
this.#revealNewRuntime(snapshot);
789793

790794
if (hadCommentsList) this.addCommentsList();
@@ -851,6 +855,29 @@ export class SuperDoc extends EventEmitter {
851855
}
852856
}
853857

858+
/**
859+
* Restore non-PM document state (parts XML, media files) on the rollback
860+
* editor. The PM JSON is restored via `jsonOverride`, but converter parts
861+
* and media must be patched explicitly since they were lost during
862+
* re-import from the original document source.
863+
*
864+
* @param {Record<string, unknown>} convertedXml
865+
* @param {Record<string, unknown>} mediaFiles
866+
*/
867+
#restoreRollbackDocumentState(convertedXml, mediaFiles) {
868+
try {
869+
const editor = this.#resolveSourceEditor();
870+
if (editor.converter && convertedXml) {
871+
editor.converter.convertedXml = convertedXml;
872+
}
873+
if (mediaFiles) {
874+
editor.options.mediaFiles = mediaFiles;
875+
}
876+
} catch {
877+
// Best-effort — editor may not be resolvable in edge cases
878+
}
879+
}
880+
854881
/**
855882
* Wait for the provider to report synced, with a timeout.
856883
*

packages/superdoc/src/core/upgrade-collaboration.test.js

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,4 +1029,249 @@ describe('upgradeToCollaboration', () => {
10291029
// After upgrade: callback cleared
10301030
expect(instance._upgradeVisualReadyCallback).toBeNull();
10311031
});
1032+
1033+
// -----------------------------------------------------------------------
1034+
// Rollback regression: leaked runtime (Fix 1)
1035+
// -----------------------------------------------------------------------
1036+
1037+
it('stops the failed collaborative runtime before starting rollback (mount throws)', async () => {
1038+
const harness = createAppHarness();
1039+
const instance = new SuperDoc({
1040+
selector: '#host',
1041+
documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }],
1042+
modules: { comments: {} },
1043+
colors: [],
1044+
onException: vi.fn(),
1045+
});
1046+
await flushMicrotasks();
1047+
instance.readyEditors = 1;
1048+
1049+
const collabUnmount = vi.fn();
1050+
let callCount = 0;
1051+
1052+
createVueAppMock.mockImplementation(() => {
1053+
callCount++;
1054+
if (callCount === 1) {
1055+
// Collaborative runtime: app created but mount throws
1056+
return {
1057+
app: {
1058+
mount: vi.fn(() => {
1059+
throw new Error('Collaborative mount failed');
1060+
}),
1061+
unmount: collabUnmount,
1062+
config: { globalProperties: {} },
1063+
},
1064+
pinia: {},
1065+
superdocStore: harness.superdocStore,
1066+
commentsStore: harness.commentsStore,
1067+
highContrastModeStore: {},
1068+
};
1069+
}
1070+
// Rollback runtime: succeeds
1071+
return {
1072+
app: {
1073+
mount: vi.fn((wrapper) => {
1074+
const el = document.createElement('div');
1075+
el.className = 'superdoc';
1076+
wrapper.appendChild(el);
1077+
setTimeout(() => {
1078+
if (instance._upgradeVisualReadyCallback) {
1079+
instance._upgradeVisualReadyCallback();
1080+
}
1081+
}, 0);
1082+
}),
1083+
unmount: vi.fn(),
1084+
config: { globalProperties: {} },
1085+
},
1086+
pinia: {},
1087+
superdocStore: harness.superdocStore,
1088+
commentsStore: harness.commentsStore,
1089+
highContrastModeStore: {},
1090+
};
1091+
});
1092+
1093+
await expect(
1094+
instance.upgradeToCollaboration({
1095+
ydoc: createMockYDoc(),
1096+
provider: createMockProvider(),
1097+
}),
1098+
).rejects.toThrow('Collaborative mount failed');
1099+
1100+
// The collaborative app must be unmounted via #stopRuntime() before rollback
1101+
expect(collabUnmount).toHaveBeenCalled();
1102+
});
1103+
1104+
it('stops a timed-out collaborative runtime before starting rollback', async () => {
1105+
vi.useFakeTimers();
1106+
try {
1107+
const harness = createAppHarness();
1108+
const instance = new SuperDoc({
1109+
selector: '#host',
1110+
documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }],
1111+
modules: { comments: {} },
1112+
colors: [],
1113+
onException: vi.fn(),
1114+
});
1115+
await vi.advanceTimersByTimeAsync(0);
1116+
instance.readyEditors = 1;
1117+
1118+
const collabUnmount = vi.fn();
1119+
let callCount = 0;
1120+
1121+
createVueAppMock.mockImplementation(() => {
1122+
callCount++;
1123+
if (callCount === 1) {
1124+
// Collaborative runtime: mounts but never fires visual-ready
1125+
return {
1126+
app: {
1127+
mount: vi.fn((wrapper) => {
1128+
const el = document.createElement('div');
1129+
el.className = 'superdoc';
1130+
wrapper.appendChild(el);
1131+
// visual-ready callback deliberately NOT called
1132+
}),
1133+
unmount: collabUnmount,
1134+
config: { globalProperties: {} },
1135+
},
1136+
pinia: {},
1137+
superdocStore: harness.superdocStore,
1138+
commentsStore: harness.commentsStore,
1139+
highContrastModeStore: {},
1140+
};
1141+
}
1142+
// Rollback runtime: fires visual-ready immediately
1143+
return {
1144+
app: {
1145+
mount: vi.fn((wrapper) => {
1146+
const el = document.createElement('div');
1147+
el.className = 'superdoc';
1148+
wrapper.appendChild(el);
1149+
// Fire visual-ready synchronously to avoid timer complications
1150+
if (instance._upgradeVisualReadyCallback) {
1151+
instance._upgradeVisualReadyCallback();
1152+
}
1153+
}),
1154+
unmount: vi.fn(),
1155+
config: { globalProperties: {} },
1156+
},
1157+
pinia: {},
1158+
superdocStore: harness.superdocStore,
1159+
commentsStore: harness.commentsStore,
1160+
highContrastModeStore: {},
1161+
};
1162+
});
1163+
1164+
const upgradePromise = instance.upgradeToCollaboration({
1165+
ydoc: createMockYDoc(),
1166+
provider: createMockProvider(),
1167+
});
1168+
1169+
// Attach rejection handler BEFORE advancing timers to avoid unhandled rejection
1170+
const resultPromise = expect(upgradePromise).rejects.toThrow('visually ready within 30 s');
1171+
1172+
// Advance past the 30s collaborative timeout
1173+
await vi.advanceTimersByTimeAsync(30_001);
1174+
1175+
await resultPromise;
1176+
1177+
// The timed-out collaborative app must be unmounted before rollback
1178+
expect(collabUnmount).toHaveBeenCalled();
1179+
} finally {
1180+
vi.useRealTimers();
1181+
}
1182+
});
1183+
1184+
// -----------------------------------------------------------------------
1185+
// Rollback regression: incomplete state restoration (Fix 2)
1186+
// -----------------------------------------------------------------------
1187+
1188+
it('restores convertedXml and mediaFiles on the rollback editor', async () => {
1189+
const harness = createAppHarness();
1190+
1191+
// Simulate pre-upgrade edits that modified parts and media
1192+
harness.mockEditor.converter.convertedXml = {
1193+
'word/styles.xml': { tag: 'w:styles', children: [{ tag: 'w:style', attrs: { id: 'custom' } }] },
1194+
'word/numbering.xml': { tag: 'w:numbering', children: [{ tag: 'w:abstractNum' }] },
1195+
};
1196+
harness.mockEditor.options.mediaFiles = {
1197+
'word/media/image1.png': 'base64-data-here',
1198+
};
1199+
1200+
const instance = new SuperDoc({
1201+
selector: '#host',
1202+
documents: [{ id: 'doc-1', type: DOCX, data: new Blob() }],
1203+
modules: { comments: {} },
1204+
colors: [],
1205+
onException: vi.fn(),
1206+
});
1207+
await flushMicrotasks();
1208+
instance.readyEditors = 1;
1209+
1210+
// The rollback editor starts with empty/reimported state
1211+
const rollbackEditor = {
1212+
converter: { convertedXml: {} },
1213+
options: { mediaFiles: {}, fonts: {} },
1214+
state: harness.mockEditor.state,
1215+
getJSON: harness.mockEditor.getJSON,
1216+
};
1217+
1218+
let callCount = 0;
1219+
createVueAppMock.mockImplementation(() => {
1220+
callCount++;
1221+
if (callCount === 1) {
1222+
// Collaborative: fails during createSuperdocVueApp
1223+
throw new Error('Simulated collab failure');
1224+
}
1225+
// Rollback: succeeds, returns a store with the rollback editor
1226+
return {
1227+
app: {
1228+
mount: vi.fn((wrapper) => {
1229+
const el = document.createElement('div');
1230+
el.className = 'superdoc';
1231+
wrapper.appendChild(el);
1232+
setTimeout(() => {
1233+
if (instance._upgradeVisualReadyCallback) {
1234+
instance._upgradeVisualReadyCallback();
1235+
}
1236+
}, 0);
1237+
}),
1238+
unmount: vi.fn(),
1239+
config: { globalProperties: {} },
1240+
},
1241+
pinia: {},
1242+
superdocStore: {
1243+
documents: [
1244+
{
1245+
id: 'doc-1',
1246+
type: DOCX,
1247+
getEditor: () => rollbackEditor,
1248+
setEditor: vi.fn(),
1249+
},
1250+
],
1251+
init: vi.fn(),
1252+
reset: vi.fn(),
1253+
setExceptionHandler: vi.fn(),
1254+
activeZoom: 100,
1255+
},
1256+
commentsStore: harness.commentsStore,
1257+
highContrastModeStore: {},
1258+
};
1259+
});
1260+
1261+
await expect(
1262+
instance.upgradeToCollaboration({
1263+
ydoc: createMockYDoc(),
1264+
provider: createMockProvider(),
1265+
}),
1266+
).rejects.toThrow('Simulated collab failure');
1267+
1268+
// The rollback editor should have the pre-upgrade parts and media restored
1269+
expect(rollbackEditor.converter.convertedXml).toEqual({
1270+
'word/styles.xml': { tag: 'w:styles', children: [{ tag: 'w:style', attrs: { id: 'custom' } }] },
1271+
'word/numbering.xml': { tag: 'w:numbering', children: [{ tag: 'w:abstractNum' }] },
1272+
});
1273+
expect(rollbackEditor.options.mediaFiles).toEqual({
1274+
'word/media/image1.png': 'base64-data-here',
1275+
});
1276+
});
10321277
});

0 commit comments

Comments
 (0)