Skip to content

imp(): implement all operations transformations#115

Merged
e11sy merged 24 commits intomainfrom
format-stacks-on-remote-op
Apr 15, 2026
Merged

imp(): implement all operations transformations#115
e11sy merged 24 commits intomainfrom
format-stacks-on-remote-op

Conversation

@e11sy
Copy link
Copy Markdown
Member

@e11sy e11sy commented Apr 11, 2025

Changes

  • OperationsBatch now extends Operation
  1. It has own lits of operations
  2. It can transform list of operations against one
  3. It can inverse list of operations
  • Added OperationType.Neutral - operation, that does not affect model on apply, so could be skipped, this type of operations could appear after transformation
  • Now Undo/Redo stacks and current OperationsBatch are transformed on event, got from server (with userId !== userId of the current client)
  • Supported all transformations for Operation
  • Prevent default browser behaviour on Ctrl + Z to avoid manipulations with browser folders

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 11, 2025

⏭️ No files to mutate for ./packages/model

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 11, 2025

⏭️ No files to mutate for ./packages/dom-adapters

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 11, 2025

Coverage report for ./packages/dom-adapters

St.
Category Percentage Covered / Total
🟢 Statements 100% 0/0
🟢 Branches 100% 0/0
🟢 Functions 100% 0/0
🟢 Lines 100% 0/0

Test suite run success

1 tests passing in 1 suite.

Report generated by 🧪jest coverage report action from 6c185cb

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 11, 2025

Coverage report for ./packages/model

St.
Category Percentage Covered / Total
🟢 Statements 99.88% 856/857
🟢 Branches 99.57% 232/233
🟢 Functions 98.1% 207/211
🟢 Lines 99.88% 823/824

Test suite run success

442 tests passing in 25 suites.

Report generated by 🧪jest coverage report action from 6c185cb

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 11, 2025

Coverage report for ./packages/ot-server

St.
Category Percentage Covered / Total
🟡 Statements 70.97% 22/31
🔴 Branches 20% 1/5
🟡 Functions 75% 6/8
🟡 Lines 68.97% 20/29

Test suite run success

4 tests passing in 1 suite.

Report generated by 🧪jest coverage report action from 6c185cb

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 11, 2025

Coverage report for ./packages/collaboration-manager

St.
Category Percentage Covered / Total
🟡 Statements
75.72% (+8.21% 🔼)
262/346
🟢 Branches
82.35% (+2.35% 🔼)
98/119
🟡 Functions
71.74% (+14.6% 🔼)
33/46
🟡 Lines
75.66% (+8.14% 🔼)
258/341
Show new covered files 🐣
St.
File Statements Branches Functions Lines
🟢
... / getRangesIntersectionType.ts
100% 100% 100% 100%
🟢 OperationsTransformer.ts 98.88% 90.24% 100% 98.88%
🟢 BatchedOperation.ts 91.67% 82.35% 100% 91.43%
Show files with reduced coverage 🔻
St.
File Statements Branches Functions Lines
🟢 Operation.ts
96.67% (+0.67% 🔼)
90% (-6% 🔻)
100% (+16.67% 🔼)
96.67% (+0.67% 🔼)
🟢 CollaborationManager.ts
82.35% (-1.47% 🔻)
75% (+1.09% 🔼)
66.67% (+4.17% 🔼)
82.14% (-1.68% 🔻)

Test suite run success

110 tests passing in 6 suites.

Report generated by 🧪jest coverage report action from 6c185cb

@neSpecc neSpecc requested a review from Copilot April 11, 2025 23:10
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

packages/collaboration-manager/src/BatchedOperation.ts:13

  • [nitpick] The file is named 'BatchedOperation.ts' while the exported class is named 'OperationsBatch', which may lead to confusion. Consider renaming either the file or the class so that they are consistent.
export class OperationsBatch<T extends OperationType> extends Operation<T> {

Comment on lines +223 to 227
public transform<K extends OperationType>(againstOp: Operation<K> | Operation<OperationType.Neutral>): Operation<T | OperationType.Neutral> {
return this.#transformer.transform(this, againstOp);
}

/**
Copy link

Copilot AI Apr 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The transformation logic does not check if the adjusted text range becomes negative. This can cause tests (e.g., expecting null when over-deleting) to fail. Consider adding a validation step in the transformer methods (such as in #transformAgainstTextDelete) to return null if the resulting text range is invalid.

Suggested change
public transform<K extends OperationType>(againstOp: Operation<K> | Operation<OperationType.Neutral>): Operation<T | OperationType.Neutral> {
return this.#transformer.transform(this, againstOp);
}
/**
public transform<K extends OperationType>(againstOp: Operation<K> | Operation<OperationType.Neutral>): Operation<T | OperationType.Neutral> | null {
const transformedOp = this.#transformer.transform(this, againstOp);
// Validate the resulting operation's index or range
if (!this.isValidOperation(transformedOp)) {
return null;
}
return transformedOp;
}
/**
* Validates the operation to ensure its index or range is valid
*
* @param operation - operation to validate
*/
private isValidOperation(operation: Operation<T | OperationType.Neutral> | null): boolean {
if (!operation) {
return false;
}
// Example validation: Ensure index is non-negative
if (operation.index.start < 0 || operation.index.end < 0) {
return false;
}
// Add additional validation logic as needed
return true;
}
/**

Copilot uses AI. Check for mistakes.
Comment thread packages/collaboration-manager/src/OperationsTransformer.ts
*
* Operations are batched on timeout basis or if batch is terminated from the outside
*/
export class OperationsBatch<T extends OperationType> extends Operation<T> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename pls

Comment thread packages/collaboration-manager/src/BatchedOperation.ts
*
* @param opBatch - operation batch to clone
*/
public static from<T extends OperationType>(opBatch: OperationsBatch<T>): OperationsBatch<T>;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Static methods should go before instance methods

/**
* Every batch should have at least one operation
*/
const batch = new OperationsBatch(opBatchOrJSON.operations.shift()!);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This way the first operation would be copied by reference not by value

/**
* Array of operations to batch
*/
operations: (Operation<T> | Operation<OperationType.Neutral>)[] = [];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modifier is missing

#transformAgainstTextInsert<T extends OperationType>(operation: Operation<T>, againstOp: Operation<OperationType>): Operation<T> | Operation<OperationType.Neutral> {
const newIndexBuilder = new IndexBuilder().from(operation.index);

const amountOfInsertedCharacters = againstOp.data.payload!.length;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const amountOfInsertedCharacters = againstOp.data.payload!.length;
const insertedLength = againstOp.data.payload!.length;

newIndexBuilder.addTextRange([operation.index.textRange![0], operation.index.textRange![1] + amountOfInsertedCharacters]);
}

return new Operation(operation.type, newIndexBuilder.build(), operation.data, operation.userId, operation.rev);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const newOp = Operation.from(operation);

newOp.index = newIndexBuilder.build();

return newOp;


const deletedRightSide = (againstOp.index.textRange![0] > operation.index.textRange![0])
&& (againstOp.index.textRange![0] < operation.index.textRange![1])
&& (againstOp.index.textRange![1] <= operation.index.textRange![1]);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
&& (againstOp.index.textRange![1] <= operation.index.textRange![1]);
&& (againstOp.index.textRange![1] >= operation.index.textRange![1]);

* Cover case 2.1
*/
if (deletedLeftSide) {
const deletedFromCurrentOpRange = operation.index.textRange![0] - againstOp.index.textRange![1];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const deletedFromCurrentOpRange = operation.index.textRange![0] - againstOp.index.textRange![1];
const deletedFromCurrentOpRange = againstOp.index.textRange![1] - operation.index.textRange![0];

if (deletedLeftSide) {
const deletedFromCurrentOpRange = operation.index.textRange![0] - againstOp.index.textRange![1];

newIndexBuilder.addTextRange([againstOp.index.textRange![0], operation.index.textRange![1] - deletedFromCurrentOpRange]);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
newIndexBuilder.addTextRange([againstOp.index.textRange![0], operation.index.textRange![1] - deletedFromCurrentOpRange]);
newIndexBuilder.addTextRange([againstOp.index.textRange![0], operation.index.textRange![1] - deletedAmount]);

Comment on lines +49 to +68
if (againstIndex.isBlockIndex && currentIndex.blockIndex !== undefined) {
/**
* Check that againstOp affects current operation
*/
if (againstIndex.blockIndex! <= currentIndex.blockIndex) {
return this.#transformAgainstBlockOperation(operation, againstOp);
}
}

/**
* Cover 4 case
*
* Check that againstOp is a text operation and current operation is also a text operation
*/
if (againstIndex.isTextIndex && currentIndex.isTextIndex) {
/**
* Check that againstOp affects current operation (text operation on the same block and same input)
* and against op happened on the left side or has overlapping range
*/
if (currentIndex.dataKey === againstIndex.dataKey && currentIndex.blockIndex === againstIndex.blockIndex && againstIndex.textRange![0] <= currentIndex.textRange![0]) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace with

switch (true) {
  case againstOp.index.isBlockIndex:
    return this.#transformAgainsBlockOperation(...);
  case againstOp.index.isTextIndex:
    return this.#transformAgainsTextOperation(...);
  default:
    return Operation.from(operation);
};

And move other conditions into the respective methods

Comment on lines +126 to +132
if (operation instanceof BatchedOperation) {
operation.operations.forEach((op) => {
this.applyOperation(op);
});
} else {
this.applyOperation(operation);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to move this condition into the applyOperation method since BatchedOperation is also an operation

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, it's already there. So you can remove it here

* @todo - add debounce timeout 500ms
*/
if (!this.#currentBatch.canAdd(operation)) {
this.#undoRedoManager.put(this.#currentBatch);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can call emptyBatch here to be consistent

/**
* If current operation could not be added to the batch, then terminate current batch and create a new one with current operation
*
* @todo - add debounce timeout 500ms
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be great if you can implement it in the current PR as this functionality is already in the main branch



describe('Operation', () => {
describe('.transform()', () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't delete it, those tests still should pass even tho the functionality in another file now

/**
* Puts current batch to the undo stack and clears the batch
*/
#emptyBatch(): void {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like the name as it implies the current batch is being emptied. Also it doesn't show that the current batch is being added to the undo redo manager

* Cover case 2
*/
case OperationType.Delete:
if (operation.index.blockIndex !== undefined) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've already checked that on line 90

Comment on lines +179 to +181
if (sameInput && sameBlock && againstIndex.textRange![0] > index.textRange![1]) {
return Operation.from(operation);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can extend condition here to check if it's not the same input or not the same block

case (RangeIntersectionType.Right):
const overlapLength = index.textRange![1] - againstIndex.textRange![0];

newIndexBuilder.addTextRange([index.textRange![0], index.textRange![1] - overlapLength]);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can put againstOp.textRange[0] as end index here

* @param operation - operation to transform against
* @param stack - stack to transform
*/
public transformStack(operation: Operation, stack: Operation[]): void {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make it pure function please.

Also could be private

* @param stack - stack to transform
*/
public transformStack(operation: Operation, stack: Operation[]): void {
const transformed = stack.flatMap((op) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why flatMap? Transform doesn't return null anymore

Copy link
Copy Markdown
Member

@gohabereg gohabereg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of minor comments, looks good to me otherwise

*/
const batch = new BatchedOperation(Operation.from(opBatchOrJSON.operations[0]));

opBatchOrJSON.operations.slice(1).forEach((op) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps the linter is not set up, but would be great to have chained calls on the new lines. Here and in the other places

Suggested change
opBatchOrJSON.operations.slice(1).forEach((op) => {
opBatchOrJSON.operations.slice(1)
.forEach((op) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still relevant

Comment thread packages/collaboration-manager/src/BatchedOperation.ts
Comment thread packages/collaboration-manager/src/CollaborationManager.ts
Comment thread packages/collaboration-manager/src/CollaborationManager.ts
Copy link
Copy Markdown
Member

@gohabereg gohabereg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed the most important file

* Cover case 2
*/
case OperationType.Delete:
if (againstOp.index.blockIndex! >= operation.index.blockIndex!) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be just ===, you've checked > on line 104

/**
* Cover case 2.1
*/
case (RangeIntersectionType.Left):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

intersectionType contains the type of current op compared to againstOp. So with RageIntersectionType.Left the current op is on the left — the against op is on the right. It should be RangeIntersectionType.Right I guess

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not quite, RangeIntersectionType.Left means that left side of the operation intersects with right side of againstOp — that means that againstOp is on the left

/**
* Cover case 2.2
*/
case (RangeIntersectionType.Right):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here should be RangeIntersectionType.Left

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should add more test cases to cover all of that

@e11sy e11sy force-pushed the format-stacks-on-remote-op branch 2 times, most recently from b3d2fef to 2254965 Compare April 13, 2026 21:20
expect(getRangesIntersectionType([3, 8], [5, 12])).toBe(RangeIntersectionType.Right);
});

it('returns Left when the left end enters the compared rannge but does not reach its start', () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to add edge cases like getRangesIntersectionTypes([3, 8], [5, 8]) and similar. Is it Includes or Left?

*/
const batch = new BatchedOperation(Operation.from(opBatchOrJSON.operations[0]));

opBatchOrJSON.operations.slice(1).forEach((op) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still relevant


const userId = 'user';

describe('Batch', () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
describe('Batch', () => {
describe('BatchedOperation', () => {

@e11sy e11sy force-pushed the format-stacks-on-remote-op branch from 50b9c81 to 8510ad0 Compare April 13, 2026 23:00
@e11sy e11sy force-pushed the format-stacks-on-remote-op branch from 0625ed5 to 89766b6 Compare April 14, 2026 11:49
@e11sy e11sy force-pushed the format-stacks-on-remote-op branch from 17be736 to 82f9dd0 Compare April 14, 2026 19:51
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 14, 2026

Coverage report for ./packages/core

St.
Category Percentage Covered / Total
🟢 Statements 100% 98/98
🟢 Branches 100% 36/36
🟢 Functions 100% 24/24
🟢 Lines 100% 91/91

Test suite run success

66 tests passing in 5 suites.

Report generated by 🧪jest coverage report action from 6c185cb

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 14, 2026

⏭️ No files to mutate for ./packages/core

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 14 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

});

it('should not change operation if operation is not Block or Text operation', () => {
it('should throw Unsupppoted index type error if op is not Block or Text operation', () => {
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in test name: "Unsupppoted" → "Unsupported".

Suggested change
it('should throw Unsupppoted index type error if op is not Block or Text operation', () => {
it('should throw Unsupported index type error if op is not Block or Text operation', () => {

Copilot uses AI. Check for mistakes.
Comment on lines +73 to +74
it('returns Right when the right end of range enters the compared range but does not reach its end', () => {
expect(getRangesIntersectionType([3, 8], [5, 12])).toBe(RangeIntersectionType.Right);
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two identical test cases under RangeIntersectionType.Right (same description and same expectation). This is redundant and doesn't add coverage; consider removing one or changing it to cover a different Right-intersection scenario.

Suggested change
it('returns Right when the right end of range enters the compared range but does not reach its end', () => {
expect(getRangesIntersectionType([3, 8], [5, 12])).toBe(RangeIntersectionType.Right);
it('returns Right when range starts further left and overlaps only the left part of the compared range', () => {
expect(getRangesIntersectionType([0, 6], [4, 10])).toBe(RangeIntersectionType.Right);

Copilot uses AI. Check for mistakes.
Comment on lines +89 to +93
const newBatchedOperation = new BatchedOperation<InvertedOperationType<T>>(lastOp.inverse());

this.operations.toReversed().slice(1)
.map(op => newBatchedOperation.add(op.inverse()));

Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inverse() uses .map(...) for side effects (newBatchedOperation.add(...)). This is misleading because the returned array is unused. Prefer forEach (or a simple loop) to make intent clear and avoid accidental misuse later.

Copilot uses AI. Check for mistakes.
Comment on lines +78 to +80
private transformStack(operation: Operation, stack: Operation[]): Operation[] {
return stack.map((op: Operation) => op.transform(operation));
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transformStack() keeps transformed operations even if they become OperationType.Neutral. That can leave no-op entries in the undo/redo stacks (user presses undo and nothing happens). If Neutral is meant to be skipped, consider filtering those out here (or when performing undo/redo) to keep stacks meaningful.

Copilot uses AI. Check for mistakes.
import { type ModifyOperationData, Operation, OperationType } from './Operation.js';
import { UndoRedoManager } from './UndoRedoManager.js';

const DEBOUCE_TIMEOUT = 500;
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constant name DEBOUCE_TIMEOUT is misspelled, which makes it harder to search/maintain. Consider renaming it to DEBOUNCE_TIMEOUT (and updating its usages).

Suggested change
const DEBOUCE_TIMEOUT = 500;
const DEBOUNCE_TIMEOUT = 500;

Copilot uses AI. Check for mistakes.
Comment on lines 29 to +33
holder: document.getElementById('editorjs') as HTMLElement,
userId: userId,
documentId: 'test',
// collaborationServer: 'ws://localhost:8080',
// collaborationServer: 'wss://lirili-larila.codex.so/',
collaborationServer: 'ws://localhost:8080',
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collaborationServer is hardcoded to ws://localhost:8080 while the production-like wss://... is commented out. This makes it easy to accidentally ship a dev endpoint or break the playground depending on environment. Consider wiring this via an environment variable (e.g. import.meta.env) or keeping only one clearly intended default.

Copilot uses AI. Check for mistakes.
}

// Disable event handling
// Disable handling
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment typo: "Disable handling" looks like it lost a word (likely "event handling"). Please fix for clarity.

Suggested change
// Disable handling
// Disable event handling

Copilot uses AI. Check for mistakes.
Comment on lines +231 to +249
/**
* If operation is local, send it to the server
*/
if (operation.userId === this.#config.userId) {
void this.#client?.send(operation);
} else {
this.#putBatchToUndo();

/**
* If operation is remote, transform undo/redo stacks
*/
this.#undoRedoManager.transformStacks(operation);

return;
}

const onBatchTermination = (batch: OperationsBatch, lastOp?: Operation): void => {
const effectiveOp = batch.getEffectiveOperation();
if (!this.#shouldHandleEvents) {
return;
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#shouldHandleEvents is checked only after sending local operations to the OT server. During undo()/redo() you set #shouldHandleEvents = false and then call applyOperation(), which triggers model events; with the current flow those events will still be sent to the server, potentially causing remote echo/loops and incorrect collaboration history. Consider moving the if (!this.#shouldHandleEvents) return; guard to the beginning of #handleEvent() (or at least before #client.send).

Copilot uses AI. Check for mistakes.
Comment on lines +216 to +217
console.log('transforming against text insert operation');

Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a console.log(...) left in #transformAgainstTextInsert. This will spam logs in production and may leak document content. Please remove it or gate it behind a debug flag/logger abstraction.

Suggested change
console.log('transforming against text insert operation');

Copilot uses AI. Check for mistakes.
case (RangeIntersectionType.Includes):
newIndexBuilder.addTextRange([index.textRange![0], index.textRange![1] - deletedAmount]);
newPayload = typeof newPayload === 'string'
? newPayload.slice(0, againstTextRange[0]) + newPayload.slice(againstTextRange[1])
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the RangeIntersectionType.Includes case, payload slicing uses absolute document offsets (slice(0, againstTextRange[0]), slice(againstTextRange[1])) instead of offsets relative to the operation's own textRange. This will remove the wrong characters whenever textRange[0] !== 0. The slice indices should be computed relative to textRange[0] (similar to the Left/Right cases).

Suggested change
? newPayload.slice(0, againstTextRange[0]) + newPayload.slice(againstTextRange[1])
? newPayload.slice(0, againstTextRange[0] - textRange[0]) + newPayload.slice(againstTextRange[1] - textRange[0])

Copilot uses AI. Check for mistakes.
Left = 'left',
Right = 'right',
Includes = 'includes',
Included = 'included',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Included = 'included',
IncludedBy = 'included',

@e11sy e11sy enabled auto-merge April 15, 2026 19:24
@e11sy e11sy added this pull request to the merge queue Apr 15, 2026
Merged via the queue into main with commit a47fd09 Apr 15, 2026
24 checks passed
@e11sy e11sy deleted the format-stacks-on-remote-op branch April 15, 2026 20:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants