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
34 changes: 31 additions & 3 deletions packages/sync/src/quill-delta/Delta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* External dependencies
*/
import type { Change } from 'diff';
import { diffChars } from 'diff';
import { diffChars, diffLines } from 'diff';
import { default as isEqual } from 'fast-deep-equal/es6';

/**
Expand All @@ -23,6 +23,7 @@ function cloneDeep< T >( value: T ): T {
}

const NULL_CHARACTER = String.fromCharCode( 0 ); // Placeholder char for embed in diff()
const STRING_TOO_LARGE_THRESHOLD = 10000; // If either string is larger than this, use a less precise diff algorithm optimized for large strings

/**
* Normalize diff changes so that `count` reflects UTF-16 code-unit length
Expand Down Expand Up @@ -623,12 +624,39 @@ class Delta {
diffWithCursor( other: Delta, cursorAfterChange: number | null ): Delta {
if ( this.ops === other.ops ) {
return new Delta();
}

const strings = this.deltasToStrings( other );

// When large changes are pasted into the code editor, this can
// result in very long strings as a result of comparing the
// `content` of the code editor before and after the change.
// In this case, diffChars() can take a very long time to complete,
// which causes the editor to freeze.
// When we detect strings that are too long, use a less-precise diff
// method that is optimized for large inputs, at the cost of less
// accurate diffing and cursor placement.
const maxStringLength = Math.max(
...strings.map( ( str ) => str.length )
);

if ( maxStringLength > STRING_TOO_LARGE_THRESHOLD ) {
const diffResult = normalizeChangeCounts(
diffLines( strings[ 0 ], strings[ 1 ] )
);
const thisIterLarge = new OpIterator( this.ops );
const otherIterLarge = new OpIterator( other.ops );
return this.convertChangesToDelta(
diffResult,
thisIterLarge,
otherIterLarge
).chop();
} else if ( cursorAfterChange === null ) {
// If no cursor position is provided, do a regular diff.
// If no cursor position is provided and the string
// is a reasonable length, do a regular diff.
return this.diff( other );
}

const strings = this.deltasToStrings( other );
let diffs = normalizeChangeCounts(
diffChars( strings[ 0 ], strings[ 1 ] )
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* External dependencies
*/
import { readFileSync } from 'fs';
import { join } from 'path';

/**
* WordPress dependencies
*/
import { test as base, expect } from '@wordpress/e2e-test-utils-playwright';

/**
* Internal dependencies
*/
import { setCollaboration } from './fixtures/collaboration-utils';

const STEP1_CONTENT = readFileSync(
join( __dirname, 'data', 'code-editor-perf-step1.md' ),
'utf-8'
);

const STEP2_CONTENT = readFileSync(
join( __dirname, 'data', 'code-editor-perf-step2.md' ),
'utf-8'
);

// Maximum time (ms) the editor should take to become responsive after
// pasting new content into the code editor. If this threshold is exceeded
// the test fails, indicating a performance regression.
const RESPONSE_TIMEOUT_MS = 3_000;

type Fixtures = {
collaborationEnabled: boolean;
};

// This test only needs collaboration enabled, not a second user.
const test = base.extend< Fixtures >( {
collaborationEnabled: [
async ( { requestUtils }, use ) => {
await setCollaboration( requestUtils, true );
await use( true );
await setCollaboration( requestUtils, false );
},
{ auto: true },
],
} );

test.describe( 'Collaboration - Code editor performance', () => {
test( 'should not freeze when replacing content with large HTML in code editor', async ( {
admin,
context,
editor,
page,
pageUtils,
} ) => {
await context.grantPermissions( [
'clipboard-read',
'clipboard-write',
] );
await admin.createNewPost();

const codeEditor = page.getByRole( 'textbox', {
name: 'Type text or HTML',
} );

// Step 1: Open the code editor and paste step 1 content.
await pageUtils.pressKeys( 'secondary+M' );
await codeEditor.fill( STEP1_CONTENT );

// Step 2: Save the draft.
await pageUtils.pressKeys( 'primary+s' );
await expect(
page
.getByRole( 'button', { name: 'Dismiss this notice' } )
.filter( { hasText: 'Draft saved' } )
).toBeVisible();

// Step 3: Replace with step 2 content using clipboard paste, which
// triggers the same input event processing as a real user paste.
// This is the operation that causes the editor to freeze.
await codeEditor.focus();

// Write step 2 content to the clipboard.
await page.evaluate(
( text: string ) => window.navigator.clipboard.writeText( text ),
STEP2_CONTENT
);

// Select all existing content and paste over it. Measure wall-clock
// time from the paste through switching to visual mode and blocks
// being fully parsed. This captures any main-thread freeze.
const startTime = performance.now();

// Use pressKeys for select-all (maps to correct modifier per
// platform). We can't use pressKeys for paste because it emulates
// the ClipboardEvent instead of doing a real keyboard paste, so we
// use page.keyboard.press directly with the platform modifier.
await pageUtils.pressKeys( 'primary+a' );
const pasteModifier =
process.platform === 'darwin' ? 'Meta' : 'Control';
await page.keyboard.press( `${ pasteModifier }+v` );

// Step 4: Switch to visual mode. The editor must process the
// pasted content before it can handle this shortcut.
await pageUtils.pressKeys( 'secondary+M' );

// Wait for blocks to be parsed (generous timeout so the test
// doesn't fail from the Playwright timeout itself).
await expect
.poll(
async () => {
const blocks = await editor.getBlocks();
return blocks.some(
( block ) =>
block.name === 'core/heading' &&
( block.attributes?.content as string )?.includes(
'Planned Versions'
)
);
},
{ timeout: 20_000 }
)
.toBe( true );

const elapsed = performance.now() - startTime;

// The actual performance assertion: the entire paste-to-blocks
// cycle must complete within the threshold.
expect( elapsed ).toBeLessThan( RESPONSE_TIMEOUT_MS );
} );
} );
Loading
Loading