Skip to content

Commit 9a52fcb

Browse files
committed
perf(docs): coalesce changeset render refreshes
1 parent 04a7e8e commit 9a52fcb

3 files changed

Lines changed: 103 additions & 22 deletions

File tree

packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-transform-update.controller.ts

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import type { DocumentDataModel, ICommandInfo, IDrawingParam, ITransformState } from '@univerjs/core';
17+
import type { DocumentDataModel, ICommandInfo, IDrawingParam, IExecutionOptions, ITransformState } from '@univerjs/core';
1818
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
1919
import type { Documents, DocumentSkeleton, IDocsCustomBlockRenderViewport, IDocsTableRenderViewport, IDocumentSkeletonHeaderFooter, IDocumentSkeletonPage, IDocumentSkeletonRow, IDocumentSkeletonTable, Image, IRenderContext, IRenderModule } from '@univerjs/engine-render';
2020
import {
@@ -224,6 +224,7 @@ function hasHorizontalTableViewport(viewport: IDocsTableRenderViewport | null |
224224

225225
export class DocDrawingTransformUpdateController extends Disposable implements IRenderModule {
226226
private _liquid = new Liquid();
227+
private _changesetDrawingRefreshScheduled = false;
227228

228229
constructor(
229230
private readonly _context: IRenderContext<DocumentDataModel>,
@@ -274,35 +275,63 @@ export class DocDrawingTransformUpdateController extends Disposable implements I
274275
const updateCommandList = [RichTextEditingMutation.id, SetDocZoomRatioOperation.id];
275276

276277
this.disposeWithMe(
277-
this._commandService.onCommandExecuted((command: ICommandInfo) => {
278+
this._commandService.onCommandExecuted((command: ICommandInfo, options?: IExecutionOptions) => {
278279
if (updateCommandList.includes(command.id)) {
279280
const params = command.params as IRichTextEditingMutationParams;
280281
const { unitId: commandUnitId } = params;
281282

282-
const { unitId, mainComponent } = this._context;
283+
const { unitId } = this._context;
283284

284285
if (commandUnitId !== unitId) {
285286
return;
286287
}
287288

288-
const skeleton = this._docSkeletonManagerService.getSkeleton();
289-
290-
if (skeleton == null) {
291-
return;
292-
}
293-
294-
// TODO: @JOCS, Do not use unitId to check if it's need to render images or isEditor. maybe need a config?
295-
if (this._editorService.isEditor(unitId)) {
296-
mainComponent?.makeDirty();
289+
// TODO(@ai-review): Confirm the second microtask always follows the coalesced Doc layout refresh.
290+
if (command.id === RichTextEditingMutation.id && options?.fromChangeset) {
291+
this._scheduleChangesetDrawingRefresh();
297292
return;
298293
}
299294

300-
this._refreshDrawing(skeleton);
295+
this._refreshCurrentDrawing();
301296
}
302297
})
303298
);
304299
}
305300

301+
private _scheduleChangesetDrawingRefresh(): void {
302+
if (this._changesetDrawingRefreshScheduled) {
303+
return;
304+
}
305+
306+
this._changesetDrawingRefreshScheduled = true;
307+
queueMicrotask(() => {
308+
queueMicrotask(() => {
309+
this._changesetDrawingRefreshScheduled = false;
310+
if (this._disposed) {
311+
return;
312+
}
313+
314+
this._refreshCurrentDrawing();
315+
});
316+
});
317+
}
318+
319+
private _refreshCurrentDrawing(): void {
320+
const skeleton = this._docSkeletonManagerService.getSkeleton();
321+
if (skeleton == null) {
322+
return;
323+
}
324+
325+
const { unitId, mainComponent } = this._context;
326+
// TODO: @JOCS, Do not use unitId to check if it's need to render images or isEditor. maybe need a config?
327+
if (this._editorService.isEditor(unitId)) {
328+
mainComponent?.makeDirty();
329+
return;
330+
}
331+
332+
this._refreshDrawing(skeleton);
333+
}
334+
306335
private _initTransformRefresh() {
307336
this.disposeWithMe(
308337
merge(

packages/docs-ui/src/controllers/__tests__/doc-render-controller.spec.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import type { ICommandInfo } from '@univerjs/core';
17+
import type { ICommandInfo, IExecutionOptions } from '@univerjs/core';
1818
import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY, DocumentFlavor } from '@univerjs/core';
1919
import { RichTextEditingMutation } from '@univerjs/docs';
2020
import { Subject } from 'rxjs';
@@ -106,7 +106,7 @@ function createControllerFixture(options?: {
106106
unitId?: string;
107107
}) {
108108
mockScrollBarProps.length = 0;
109-
const commandCallbacks: Array<(command: ICommandInfo) => void> = [];
109+
const commandCallbacks: Array<(command: ICommandInfo, options?: IExecutionOptions) => void> = [];
110110
const darkMode$ = new Subject<boolean>();
111111
const canvasElement = { style: {} as Record<string, string> };
112112
const canvasColorService = {
@@ -267,6 +267,30 @@ describe('doc render controller', () => {
267267
expect(selectionManager.refreshSelection).toHaveBeenCalledTimes(1);
268268
});
269269

270+
// TODO(@ai-review): Confirm this test continues to model SnapshotService's synchronous changeset replay boundary.
271+
it('coalesces synchronous changeset mutations into one document render', async () => {
272+
const { commandCallbacks, pageLayoutService, selectionManager, skeletonManager } = createControllerFixture();
273+
const mutation = {
274+
id: RichTextEditingMutation.id,
275+
params: {
276+
unitId: 'doc-unit',
277+
actions: [],
278+
},
279+
} as unknown as ICommandInfo;
280+
281+
commandCallbacks[0](mutation, { fromChangeset: true });
282+
commandCallbacks[0](mutation, { fromChangeset: true });
283+
commandCallbacks[0](mutation, { fromChangeset: true });
284+
285+
expect(skeletonManager.getSkeleton().calculate).not.toHaveBeenCalled();
286+
287+
await Promise.resolve();
288+
289+
expect(skeletonManager.getSkeleton().calculate).toHaveBeenCalledTimes(1);
290+
expect(pageLayoutService.calculatePagePosition).toHaveBeenCalledTimes(1);
291+
expect(selectionManager.refreshSelection).toHaveBeenCalledTimes(1);
292+
});
293+
270294
it('keeps modern doc width anchored to page width when a table grows wider than the text column', () => {
271295
const { context, skeletonManager } = createControllerFixture({
272296
documentFlavor: DocumentFlavor.MODERN,

packages/docs-ui/src/controllers/render-controllers/doc.render-controller.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import type { DocumentDataModel, EventState, ICommandInfo, Nullable } from '@univerjs/core';
17+
import type { DocumentDataModel, EventState, ICommandInfo, IExecutionOptions, Nullable } from '@univerjs/core';
1818
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
1919
import type { DocumentSkeleton, IDocumentSkeletonPage, IRenderContext, IRenderModule, IWheelEvent } from '@univerjs/engine-render';
2020
import { DocumentFlavor, ICommandService, Inject, isInternalEditorID, IUniverInstanceService, RxDisposable, ThemeService, UniverInstanceType } from '@univerjs/core';
@@ -29,6 +29,8 @@ import { IEditorService } from '../../services/editor/editor-manager.service';
2929
import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service';
3030

3131
export class DocRenderController extends RxDisposable implements IRenderModule {
32+
private _changesetRenderScheduled = false;
33+
3234
constructor(
3335
private readonly _context: IRenderContext<DocumentDataModel>,
3436
@ICommandService private readonly _commandService: ICommandService,
@@ -220,17 +222,43 @@ export class DocRenderController extends RxDisposable implements IRenderModule {
220222
private _initCommandListener() {
221223
const updateCommandList = [RichTextEditingMutation.id];
222224

223-
this.disposeWithMe(this._commandService.onCommandExecuted((command: ICommandInfo) => {
224-
// TODO@Jocs: performance, only update the skeleton when the command is related to the current unit.
225-
if (updateCommandList.includes(command.id)) {
226-
const params = command.params as IRichTextEditingMutationParams;
227-
const { unitId } = params;
225+
this.disposeWithMe(this._commandService.onCommandExecuted((command: ICommandInfo, options?: IExecutionOptions) => {
226+
if (!updateCommandList.includes(command.id)) {
227+
return;
228+
}
228229

229-
this.reRender(unitId);
230+
const params = command.params as IRichTextEditingMutationParams;
231+
const { unitId } = params;
232+
if (unitId !== this._context.unitId) {
233+
return;
230234
}
235+
236+
// TODO(@ai-review): Verify this coalescing remains correct if changeset replay later yields between mutations.
237+
if (options?.fromChangeset) {
238+
this._scheduleChangesetRender();
239+
return;
240+
}
241+
242+
this.reRender(unitId);
231243
}));
232244
}
233245

246+
private _scheduleChangesetRender(): void {
247+
if (this._changesetRenderScheduled) {
248+
return;
249+
}
250+
251+
this._changesetRenderScheduled = true;
252+
queueMicrotask(() => {
253+
this._changesetRenderScheduled = false;
254+
if (this._disposed) {
255+
return;
256+
}
257+
258+
this.reRender(this._context.unitId);
259+
});
260+
}
261+
234262
private _initThemeListener() {
235263
this.disposeWithMe(this._themeService.darkMode$.pipe(takeUntil(this.dispose$)).subscribe(() => {
236264
this._syncCanvasBackground();

0 commit comments

Comments
 (0)