Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions packages/super-editor/src/editors/v1/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3808,9 +3808,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
getUpdatedDocs = false,
fieldsHighlightColor = null,
compression,
}: ExportDocxParams = {}): Promise<
Blob | Buffer | Record<string, string | null> | string | ConvertedXmlPart | undefined
> {
}: ExportDocxParams = {}): Promise<Blob | Buffer | Record<string, string | null> | string | ConvertedXmlPart> {
try {
const exportHostEditor = resolveMainBodyEditor(this);
commitLiveStorySessionRuntimes(exportHostEditor);
Expand Down Expand Up @@ -4081,6 +4079,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
const err = error instanceof Error ? error : new Error(String(error));
this.emit('exception', { error: err, editor: this });
console.error(err);
throw err;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
import { Editor } from '@core/Editor.js';

const SAMPLE_JSON = {
type: 'doc',
attrs: { attrs: null },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Export errors should reach the caller' }],
},
],
};

describe('Editor.exportDocx() error handling', () => {
it('emits an exception event and rejects when export fails', async () => {
const editor = await Editor.open(undefined, { json: SAMPLE_JSON });
const exportError = new Error('export failed');
const exceptionListener = vi.fn();
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

editor.on('exception', exceptionListener);
vi.spyOn(editor.converter, 'exportToDocx').mockRejectedValue(exportError);

try {
await expect(editor.exportDocx({ exportXmlOnly: true })).rejects.toBe(exportError);

expect(exceptionListener).toHaveBeenCalledTimes(1);
expect(exceptionListener).toHaveBeenCalledWith({ error: exportError, editor });
expect(consoleErrorSpy).toHaveBeenCalledWith(exportError);
} finally {
consoleErrorSpy.mockRestore();
editor.destroy();
}
});
});
85 changes: 85 additions & 0 deletions packages/superdoc/src/core/SuperDoc.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,91 @@ describe('SuperDoc core', () => {
expect(results).toEqual([originalBlob]);
});

it('falls back to original document data and keeps sibling exports when an editor export rejects', async () => {
createAppHarness();
const onException = vi.fn();

const instance = new SuperDoc({
selector: '#host',
document: 'https://example.com/doc.docx',
documents: [],
modules: { comments: {}, toolbar: {} },
colors: [],
user: { name: 'Jane', email: 'jane@example.com' },
onException,
});
await flushMicrotasks();

const exportError = new Error('export failed');
const originalBlob = new Blob(['fallback'], { type: DOCX });
const siblingBlob = new Blob(['exported'], { type: DOCX });
const failedExportDocxMock = vi.fn().mockRejectedValue(exportError);
const siblingExportDocxMock = vi.fn().mockResolvedValue(siblingBlob);
const failedDoc = {
id: 'doc-1',
type: DOCX,
data: originalBlob,
getEditor: () => ({ exportDocx: failedExportDocxMock }),
};
const siblingDoc = {
id: 'doc-2',
type: DOCX,
data: null,
getEditor: () => ({ exportDocx: siblingExportDocxMock }),
};

instance.superdocStore.documents = [failedDoc, siblingDoc];

const results = await instance.exportEditorsToDOCX();

expect(failedExportDocxMock).toHaveBeenCalledTimes(1);
expect(siblingExportDocxMock).toHaveBeenCalledTimes(1);
expect(results).toEqual([originalBlob, siblingBlob]);
expect(onException).toHaveBeenCalledTimes(1);
expect(onException).toHaveBeenCalledWith({ error: exportError, document: failedDoc });
});

it('does not emit a duplicate wrapper exception when the editor already bridged the export error', async () => {
createAppHarness();
const onException = vi.fn();

const instance = new SuperDoc({
selector: '#host',
document: 'https://example.com/doc.docx',
documents: [],
modules: { comments: {}, toolbar: {} },
colors: [],
user: { name: 'Jane', email: 'jane@example.com' },
onException,
});
await flushMicrotasks();

const exportError = new Error('export failed');
const originalBlob = new Blob(['fallback'], { type: DOCX });
const editor = {
exportDocx: vi.fn(async () => {
instance.emit('exception', { error: exportError, editor });
throw exportError;
}),
};

instance.superdocStore.documents = [
{
id: 'doc-1',
type: DOCX,
data: originalBlob,
getEditor: () => editor,
},
];

const results = await instance.exportEditorsToDOCX();

expect(editor.exportDocx).toHaveBeenCalledTimes(1);
expect(results).toEqual([originalBlob]);
expect(onException).toHaveBeenCalledTimes(1);
expect(onException).toHaveBeenCalledWith({ error: exportError, editor });
});

it('drops non-DOCX fallback data when an editor export yields no blob', async () => {
const { superdocStore } = createAppHarness();

Expand Down
74 changes: 44 additions & 30 deletions packages/superdoc/src/core/SuperDoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2623,39 +2623,53 @@ export class SuperDoc extends EventEmitter<SuperDocEventMap> {
// else: UI store unhydrated → leave `comments` undefined and
// let the engine's `converter.comments` fallback fire.

const docxPromises = this.#requireSuperdocStore('exportEditorsToDOCX').documents.map(
async (doc: RuntimeDocument) => {
if (!doc || doc.type !== DOCX) return null;

const editor = typeof doc.getEditor === 'function' ? doc.getEditor() : null;
const fallbackDocx = () => {
if (!doc.data) return null;
if (doc.data.type && doc.data.type !== DOCX) return null;
return doc.data;
};

if (!editor) return fallbackDocx();
const bridgedExportErrors = new WeakSet<object>();
const rememberBridgedExportError = (payload: SuperDocExceptionPayload) => {
if ('editor' in payload && payload.error && typeof payload.error === 'object') {
bridgedExportErrors.add(payload.error);
}
};

try {
const exported = await editor.exportDocx({
isFinalDoc,
comments: comments as import('@superdoc/super-editor').Comment[] | undefined,
commentsType,
fieldsHighlightColor,
});
if (exported) return exported;
} catch (error) {
this.emit('exception', { error, document: doc });
}
this.on('exception', rememberBridgedExportError);
try {
const docxPromises = this.#requireSuperdocStore('exportEditorsToDOCX').documents.map(
async (doc: RuntimeDocument) => {
if (!doc || doc.type !== DOCX) return null;

const editor = typeof doc.getEditor === 'function' ? doc.getEditor() : null;
const fallbackDocx = () => {
if (!doc.data) return null;
if (doc.data.type && doc.data.type !== DOCX) return null;
return doc.data;
};

if (!editor) return fallbackDocx();

try {
const exported = await editor.exportDocx({
isFinalDoc,
comments: comments as import('@superdoc/super-editor').Comment[] | undefined,
commentsType,
fieldsHighlightColor,
});
if (exported) return exported;
} catch (error) {
if (!error || typeof error !== 'object' || !bridgedExportErrors.has(error)) {
this.emit('exception', { error, document: doc });
}
}

return fallbackDocx();
},
);
return fallbackDocx();
},
);

const docxFiles = await Promise.all(docxPromises);
// Type-predicate filter so callers see `Blob[]` instead of `(Blob | null)[]`.
// `filter(Boolean)` narrows at runtime but not in the type system.
return docxFiles.filter((file): file is Blob => file != null);
const docxFiles = await Promise.all(docxPromises);
// Type-predicate filter so callers see `Blob[]` instead of `(Blob | null)[]`.
// `filter(Boolean)` narrows at runtime but not in the type system.
return docxFiles.filter((file): file is Blob => file != null);
} finally {
this.off('exception', rememberBridgedExportError);
}
}

/**
Expand Down
Loading