Skip to content

Commit 663bcdc

Browse files
committed
fix(docs): prevent modern layout starvation
1 parent d4a6b4b commit 663bcdc

5 files changed

Lines changed: 232 additions & 15 deletions

File tree

packages/docs-ui/src/services/__tests__/doc-layout-coordinator.service.spec.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,85 @@ describe('DocLayoutCoordinatorService', () => {
489489
coordinator.dispose();
490490
});
491491

492+
it('keeps Worker background layout moving when the browser has no idle period', async () => {
493+
const requestIdle = vi.fn(() => 1);
494+
vi.stubGlobal('requestIdleCallback', requestIdle);
495+
let identity: IDocLayoutMountIdentity = {
496+
unitId: 'doc-worker-background',
497+
mountId: '',
498+
mountEpoch: 0,
499+
viewportEpoch: 0,
500+
};
501+
const executorService = {
502+
startLayout: vi.fn(async (requestIdentity: IDocLayoutMountIdentity): Promise<IDocLayoutStartResult> => {
503+
identity = requestIdentity;
504+
return {
505+
status: DocLayoutSessionStatus.ACCEPTED,
506+
step: {
507+
...requestIdentity,
508+
progress: createProgress({
509+
generation: 7,
510+
mode: 'continuous',
511+
didPublish: true,
512+
anchorReady: true,
513+
processedBlockCount: 63,
514+
totalBlockCount: 6_264,
515+
}),
516+
publication: null,
517+
modelRevision: 3,
518+
metricsRevision: 5,
519+
},
520+
};
521+
}),
522+
stepLayout: vi.fn(async () => ({
523+
...identity,
524+
progress: createProgress({
525+
generation: 7,
526+
mode: 'continuous',
527+
didPublish: true,
528+
anchorReady: true,
529+
complete: true,
530+
processedBlockCount: 6_264,
531+
totalBlockCount: 6_264,
532+
}),
533+
publication: null,
534+
modelRevision: 3,
535+
metricsRevision: 5,
536+
})),
537+
publishBacklog: vi.fn(async () => ({
538+
...identity,
539+
progress: createProgress({
540+
generation: 7,
541+
mode: 'continuous',
542+
anchorReady: true,
543+
processedBlockCount: 63,
544+
totalBlockCount: 6_264,
545+
}),
546+
publication: null,
547+
modelRevision: 3,
548+
metricsRevision: 5,
549+
})),
550+
cancelLayout: vi.fn(async () => {}),
551+
disposeLayoutMount: vi.fn(async () => {}),
552+
};
553+
const coordinator = new DocLayoutCoordinatorService();
554+
555+
coordinator.scheduleWorker(
556+
'doc-worker-background',
557+
{ beginExternalLayout: vi.fn(), cancelExternalLayout: vi.fn() },
558+
executorService,
559+
{ reason: 'initial' },
560+
undefined,
561+
{ onProgress: vi.fn() },
562+
vi.fn()
563+
);
564+
await vi.runAllTimersAsync();
565+
566+
expect(requestIdle).not.toHaveBeenCalled();
567+
expect(executorService.stepLayout).toHaveBeenCalledTimes(1);
568+
coordinator.dispose();
569+
});
570+
492571
it('keeps an external presentation barrier until Worker pages are published', async () => {
493572
const requestAnimationFrameSpy = vi.fn((callback: FrameRequestCallback) =>
494573
window.setTimeout(() => callback(performance.now()), 0));

packages/docs-ui/src/services/doc-layout-coordinator.service.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { DocLayoutSessionStatus } from '@univerjs/docs';
2121

2222
const FOREGROUND_BUDGET_MS = 8;
2323
const WORKER_FOREGROUND_BUDGET_MS = 32;
24+
const WORKER_BACKGROUND_BUDGET_MS = 128;
2425
const BACKGROUND_BUDGET_MS = 12;
2526
const BACKGROUND_YIELD_MS = 4;
2627
const BACKGROUND_RESUME_DELAY_MS = 4;
@@ -429,7 +430,22 @@ export class DocLayoutCoordinatorService extends Disposable {
429430
this._scheduledLayout.nextBackgroundDelayMs = BACKGROUND_YIELD_MS;
430431
this._fallbackTimerId = setTimeout(() => {
431432
this._fallbackTimerId = null;
432-
if (this._scheduledLayout == null) {
433+
const scheduledLayout = this._scheduledLayout;
434+
if (scheduledLayout == null) {
435+
return;
436+
}
437+
438+
// Worker computation does not consume the main thread's idle budget.
439+
// Waiting for requestIdleCallback here can starve forever while the
440+
// loading skeleton keeps Canvas animating. Preserve the timer boundary
441+
// and input-pending check, then let the Worker advance independently;
442+
// its geometry is still committed only inside a visual frame.
443+
if (scheduledLayout.executor === 'worker') {
444+
if (hasPendingUserInput()) {
445+
this._scheduleBackground();
446+
return;
447+
}
448+
this._runSlice(WORKER_BACKGROUND_BUDGET_MS, true);
433449
return;
434450
}
435451

packages/engine-render/src/components/docs/layout/__tests__/doc-skeleton.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,6 +1282,68 @@ describe('doc skeleton', () => {
12821282
univer.dispose();
12831283
});
12841284

1285+
it('does not report a page backlog for continuous layout', () => {
1286+
const univer = new Univer();
1287+
const localeService = univer.__getInjector().get(LocaleService);
1288+
const content = Array.from(
1289+
{ length: 100 },
1290+
(_, index) => `Continuous paragraph ${index} remains in one document flow.\r`
1291+
).join('');
1292+
const documentModel = createDocumentModelWithStyle(content, {});
1293+
documentModel.updateDocumentStyle({ documentFlavor: DocumentFlavor.MODERN });
1294+
const skeleton = DocumentSkeleton.create(new DocumentViewModel(documentModel), localeService);
1295+
1296+
const generation = skeleton.startIncrementalLayout({ reason: 'initial' });
1297+
const first = skeleton.stepIncrementalLayout(generation, 0);
1298+
const backlog = skeleton.publishIncrementalLayoutBacklog(generation);
1299+
1300+
expect(first.didPublish).toBe(true);
1301+
expect(backlog.didPublish).toBe(false);
1302+
expect(backlog.processedBlockCount).toBe(first.processedBlockCount);
1303+
expect(backlog.publicationRevision).toBe(first.publicationRevision);
1304+
1305+
skeleton.dispose();
1306+
univer.dispose();
1307+
});
1308+
1309+
it('merges legacy section identities into one continuous modern page', () => {
1310+
const first = `First modern section${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.SECTION_BREAK}`;
1311+
const second = `Second modern section${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.SECTION_BREAK}`;
1312+
const univer = new Univer();
1313+
const localeService = univer.__getInjector().get(LocaleService);
1314+
const documentModel = new DocumentDataModel({
1315+
id: 'modern-legacy-sections',
1316+
body: {
1317+
dataStream: `${first}${second}`,
1318+
paragraphs: [
1319+
{ startIndex: first.length - 2, paragraphId: 'first-paragraph' },
1320+
{ startIndex: first.length + second.length - 2, paragraphId: 'second-paragraph' },
1321+
],
1322+
sectionBreaks: [
1323+
{ sectionId: 'legacy-first-section', startIndex: first.length - 1 },
1324+
{ sectionId: 'legacy-second-section', startIndex: first.length + second.length - 1 },
1325+
],
1326+
},
1327+
documentStyle: {
1328+
documentFlavor: DocumentFlavor.MODERN,
1329+
pageSize: { width: 320, height: 400 },
1330+
},
1331+
});
1332+
const skeleton = DocumentSkeleton.create(new DocumentViewModel(documentModel), localeService);
1333+
1334+
const generation = skeleton.startIncrementalLayout({ reason: 'initial' });
1335+
let progress = skeleton.stepIncrementalLayout(generation, 0);
1336+
for (let step = 0; step < 20 && !progress.complete; step++) {
1337+
progress = skeleton.stepIncrementalLayout(generation, 0);
1338+
}
1339+
1340+
expect(progress).toMatchObject({ complete: true, mode: 'continuous', pageCount: 1 });
1341+
expect(skeleton.getSkeletonData()?.pages).toHaveLength(1);
1342+
1343+
skeleton.dispose();
1344+
univer.dispose();
1345+
});
1346+
12851347
it('drains pages sequentially when the final block creates a multi-page tail', () => {
12861348
const univer = new Univer();
12871349
const localeService = univer.__getInjector().get(LocaleService);

packages/engine-render/src/components/docs/layout/doc-skeleton.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -605,12 +605,12 @@ function removeDupPages(ctx: ILayoutContext) {
605605
});
606606
}
607607

608-
function mergeContinuousDuplicatePages(pages: IDocumentSkeletonPage[]) {
608+
function mergeContinuousDuplicatePages(pages: IDocumentSkeletonPage[], mergeAll = false) {
609609
for (let index = 1; index < pages.length;) {
610610
const previousPage = pages[index - 1];
611611
const page = pages[index];
612612

613-
if (previousPage.pageNumber !== page.pageNumber || previousPage.sectionId !== page.sectionId) {
613+
if (!mergeAll && (previousPage.pageNumber !== page.pageNumber || previousPage.sectionId !== page.sectionId)) {
614614
index++;
615615
continue;
616616
}
@@ -1725,6 +1725,12 @@ export class DocumentSkeleton extends Skeleton {
17251725
* completed. It never advances shaping or pagination work.
17261726
*/
17271727
publishIncrementalLayoutBacklog(generation: number): IDocumentLayoutProgress {
1728+
const state = this._activeLayout;
1729+
if (state?.generation === generation && state.mode === 'continuous') {
1730+
const progress = this._getLayoutProgress(state);
1731+
this._layoutProgress$.next(progress);
1732+
return progress;
1733+
}
17281734
return this._stepIncrementalLayout(generation, 0, false);
17291735
}
17301736

@@ -1883,7 +1889,7 @@ export class DocumentSkeleton extends Skeleton {
18831889
// layout is in progress. Final layout merges them before publication; do
18841890
// the same on cloned foreground pages so the temporary page list has the
18851891
// same physical-page identity as the previous complete skeleton.
1886-
mergeContinuousDuplicatePages(activePages);
1892+
mergeContinuousDuplicatePages(activePages, state.mode === 'continuous');
18871893

18881894
const flowPages = [...stablePages, ...activePages];
18891895
const currentPriorityPageIndex = state.priorityAnchor == null
@@ -3984,8 +3990,14 @@ export class DocumentSkeleton extends Skeleton {
39843990
evenAndOddHeaders,
39853991
} = sectionBreakConfig;
39863992
const explicitPageNumberStart = viewModel.getSectionBreak(sectionNode.endIndex)?.pageNumberStart;
3987-
const effectiveSectionType = getEffectiveSectionType(sectionType);
39883993
const layoutAnchor = state.layoutAnchor;
3994+
// Modern documents have one logical flow. Imported legacy section
3995+
// metadata must append to that flow instead of creating physical page
3996+
// fragments. Keep the anchored first section on its established restore
3997+
// path; after the anchor is consumed, subsequent sections are continuous.
3998+
const effectiveSectionType = state.mode === 'continuous' && layoutAnchor == null
3999+
? SectionType.CONTINUOUS
4000+
: getEffectiveSectionType(sectionType);
39894001

39904002
let curSkeletonPage = getLastPage(allSkeletonPages);
39914003
let reuseNextColumn = false;
@@ -4164,7 +4176,7 @@ export class DocumentSkeleton extends Skeleton {
41644176
const { ctx } = state;
41654177
const { skeleton } = ctx;
41664178
removeDupPages(ctx);
4167-
mergeContinuousDuplicatePages(skeleton.pages);
4179+
mergeContinuousDuplicatePages(skeleton.pages, state.mode === 'continuous');
41684180
if (state.mode === 'continuous') {
41694181
updateBlockIndex(skeleton.pages, -1, ctx.docsConfig.documentCompatibilityPolicy);
41704182
updateInlineDrawingCoordsAndBorder(ctx, skeleton.pages);
@@ -4458,7 +4470,10 @@ export class DocumentSkeleton extends Skeleton {
44584470
this._iteratorCount = 0;
44594471
removeDupPages(ctx);
44604472
updateBlockIndex(skeleton.pages, -1, ctx.docsConfig.documentCompatibilityPolicy);
4461-
mergeContinuousDuplicatePages(skeleton.pages);
4473+
mergeContinuousDuplicatePages(
4474+
skeleton.pages,
4475+
ctx.dataModel.documentStyle.documentFlavor === DocumentFlavor.MODERN
4476+
);
44624477
// Calculate inline drawing position and update.
44634478
updateInlineDrawingCoordsAndBorder(ctx, skeleton.pages);
44644479
for (const hSkeMap of skeleton.skeHeaders.values()) {

packages/engine-render/src/components/docs/layout/document-layout-page-patch.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,37 @@ function collectFlowLineEnds(page: IDocumentSkeletonPage): number[] {
294294
return lineEnds;
295295
}
296296

297+
function countFlowLines(page: IDocumentSkeletonPage): number {
298+
let lineCount = 0;
299+
for (const section of page.sections) {
300+
for (const column of section.columns) {
301+
lineCount += column.lines.length;
302+
}
303+
}
304+
return lineCount;
305+
}
306+
307+
function collectFlowLineEndsFromCursor(
308+
page: IDocumentSkeletonPage,
309+
cursor: IDocumentSkeletonFlowCursor
310+
): number[] {
311+
const lineEnds: number[] = [];
312+
for (let sectionIndex = cursor.sectionIndex; sectionIndex < page.sections.length; sectionIndex++) {
313+
const section = page.sections[sectionIndex];
314+
const firstColumnIndex = sectionIndex === cursor.sectionIndex ? cursor.columnIndex : 0;
315+
for (let columnIndex = firstColumnIndex; columnIndex < section.columns.length; columnIndex++) {
316+
const column = section.columns[columnIndex];
317+
const firstLineIndex = sectionIndex === cursor.sectionIndex && columnIndex === cursor.columnIndex
318+
? cursor.lineIndex
319+
: 0;
320+
for (let lineIndex = firstLineIndex; lineIndex < column.lines.length; lineIndex++) {
321+
lineEnds.push(column.lines[lineIndex].ed);
322+
}
323+
}
324+
}
325+
return lineEnds;
326+
}
327+
297328
function findFlowLineIndex(lineEnds: number[], offset: number): number {
298329
const index = lineEnds.findIndex((lineEnd) => lineEnd >= offset);
299330
return index < 0 ? lineEnds.length : index;
@@ -344,17 +375,31 @@ export function serializeDocumentSkeletonContinuousBlock(
344375
previousSnapshot: IDocumentSkeletonContinuousSnapshot | null,
345376
replacementOffset?: number
346377
): { block: IDocumentSkeletonContinuousBlockPatch; snapshot: IDocumentSkeletonContinuousSnapshot } {
347-
const currentLineEnds = collectFlowLineEnds(currentPage);
348378
const previousLineCount = previousSnapshot?.lineEnds.length ?? 0;
349-
const replacementLineIndex = previousSnapshot == null
350-
? 0
351-
: replacementOffset == null
352-
? Math.max(0, Math.min(previousLineCount, currentLineEnds.length) - 1)
379+
let currentLineEnds: number[];
380+
let replacementLineIndex: number;
381+
let cursor: IDocumentSkeletonFlowCursor;
382+
if (previousSnapshot != null && replacementOffset == null) {
383+
replacementLineIndex = Math.max(0, Math.min(previousLineCount, countFlowLines(currentPage)) - 1);
384+
cursor = resolveFlowCursor(currentPage, replacementLineIndex);
385+
const appendedLineEnds = collectFlowLineEndsFromCursor(currentPage, cursor);
386+
currentLineEnds = previousSnapshot.lineEnds;
387+
currentLineEnds.splice(
388+
replacementLineIndex,
389+
currentLineEnds.length - replacementLineIndex,
390+
...appendedLineEnds
391+
);
392+
} else {
393+
currentLineEnds = collectFlowLineEnds(currentPage);
394+
const replacementStart = replacementOffset ?? 0;
395+
replacementLineIndex = previousSnapshot == null
396+
? 0
353397
: Math.min(
354-
findFlowLineIndex(previousSnapshot.lineEnds, replacementOffset),
355-
findFlowLineIndex(currentLineEnds, replacementOffset)
398+
findFlowLineIndex(previousSnapshot.lineEnds, replacementStart),
399+
findFlowLineIndex(currentLineEnds, replacementStart)
356400
);
357-
const cursor = resolveFlowCursor(currentPage, replacementLineIndex);
401+
cursor = resolveFlowCursor(currentPage, replacementLineIndex);
402+
}
358403
const section = currentPage.sections[cursor.sectionIndex];
359404
const column = section?.columns[cursor.columnIndex];
360405
if (section == null || column == null) {

0 commit comments

Comments
 (0)