Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent empty ops are applied during undo/redo #687

Merged
merged 44 commits into from
Nov 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
9d45b52
Implement undo/redo for object set, remove
chacha912 Sep 26, 2023
e2da9e0
Add object-devtool example for testing object undo/redo
chacha912 Sep 26, 2023
c64da6e
Handle cases where the reverse operation cannot be executed
chacha912 Sep 26, 2023
a9f3e21
Merge branch 'main' of https://github.com/yorkie-team/yorkie-js-sdk i…
chacha912 Oct 12, 2023
f6abc10
Merge remote-tracking branch 'origin/main' into undo-redo-object
chacha912 Oct 13, 2023
eb1bd0e
Add whiteboard example to test object undo
chacha912 Oct 16, 2023
e6b198d
Add test to verify synchronization of nested object undo
chacha912 Oct 16, 2023
576cee1
Add rectangle deletion
chacha912 Oct 17, 2023
0c15239
Refactor object devtool
chacha912 Oct 17, 2023
4b8e829
Refactor opsource code
chacha912 Oct 17, 2023
5e0256c
Add some comments
chacha912 Oct 17, 2023
01a4d2e
Add sync button in whiteboard example
chacha912 Oct 17, 2023
45e3046
Add sync button in whiteboard example
chacha912 Oct 17, 2023
c47c7ed
Replace `executedAt` with `movedAt`
chacha912 Oct 25, 2023
05cf051
Fix bug caused by shared reference when adding elements
chacha912 Oct 25, 2023
80630d6
Merge branch 'undo-redo-object' of https://github.com/yorkie-team/yor…
chacha912 Oct 25, 2023
9b2dc2b
Cleanup test code
chacha912 Oct 27, 2023
633b429
Merge branch 'main' of https://github.com/yorkie-team/yorkie-js-sdk i…
chacha912 Oct 27, 2023
1b63499
Add test case for reverse operations referencing already garbage-coll…
chacha912 Oct 27, 2023
c910f2d
Handle reverse remove operation targeting elements deleted by other p…
chacha912 Nov 6, 2023
ee3c113
Modify to raise explicit error when `executedAt` of operation is absent
chacha912 Nov 6, 2023
24111e0
Specify OpSource
chacha912 Nov 6, 2023
d44fb4a
Modify to handle cases where operation is not applied only when eleme…
chacha912 Nov 6, 2023
13668c3
Handle error in cases where executedAt is missing
chacha912 Nov 6, 2023
9ecca35
Add test to verify reverse operation of object.set and remove
chacha912 Nov 6, 2023
08efd9c
Fix test to use disableGC option
chacha912 Nov 6, 2023
3bdee75
Update whiteboard example
hackerwins Nov 7, 2023
116fac2
Rename Element.getLastExecutedAt with Element.getPositionedAt
hackerwins Nov 7, 2023
4db1fc0
Update history stack log when presence is changed in whiteboard example
chacha912 Nov 7, 2023
7e3f118
Use event delegation
chacha912 Nov 7, 2023
df8ca4f
Rename Id to ID
chacha912 Nov 7, 2023
f47a223
Specify return type for the `toJSForTest()` instead of using any
chacha912 Nov 8, 2023
f61ac95
Add comments for event handling during undo/redo
chacha912 Nov 8, 2023
b2ff8ce
Make source non-optional in operation.execute()
chacha912 Nov 8, 2023
d83fdfb
Add comments
chacha912 Nov 8, 2023
030acb8
Modify shape movement to use presence
chacha912 Nov 8, 2023
77513bf
Add `get` and `getByID` methods to the CRDTContainer
chacha912 Nov 9, 2023
ba622ce
Unify the event triggering condition to opInfos
chacha912 Nov 10, 2023
cccd592
Clear redo when a new local operation is applied
chacha912 Nov 10, 2023
3fac1ca
Update to handle not exposing deleted elements in the JSON layer
chacha912 Nov 10, 2023
001bc70
Add comments
chacha912 Nov 10, 2023
44b2ccb
Handle cases where no operations have been applied during undo/redo
chacha912 Nov 14, 2023
32b29fd
Merge remote-tracking branch 'origin' into no-ops-undo
chacha912 Nov 15, 2023
8ed6ee2
Update src/document/document.ts
hackerwins Nov 22, 2023
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
29 changes: 17 additions & 12 deletions src/document/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,7 @@ export class Document<T, P extends Indexable = Indexable> {
this.changeID = change.getID();

// 03. Publish the document change event.
// NOTE(chacha912): Check opInfos, which represent the actually executed operations.
if (opInfos.length > 0) {
this.publish({
type: DocEventType.LocalChange,
Expand Down Expand Up @@ -846,6 +847,15 @@ export class Document<T, P extends Indexable = Indexable> {
return this.checkpoint;
}

/**
* `getChangeID` returns the change id of this document.
*
* @internal
*/
public getChangeID(): ChangeID {
return this.changeID;
}

/**
* `hasLocalChanges` returns whether this document has local changes or not.
*
Expand Down Expand Up @@ -1309,18 +1319,15 @@ export class Document<T, P extends Indexable = Indexable> {
this.internalHistory.pushRedo(reverseOps);
}

// TODO(chacha912): When there is no applied operation or presence
// NOTE(chacha912): When there is no applied operation or presence
// during undo/redo, skip propagating change remotely.
// In the database, it may appear as if the client sequence is missing.
if (change.hasPresenceChange() || opInfos.length > 0) {
this.localChanges.push(change);
if (!change.hasPresenceChange() && opInfos.length === 0) {
return;
}

this.localChanges.push(change);
this.changeID = change.getID();
const actorID = this.changeID.getActorID()!;
// NOTE(chacha912): Although operations are included in the change, they
// may not be executable (e.g., when the target element has been deleted).
// So we check opInfos, which represent the actually executed operations.
if (opInfos.length > 0) {
this.publish({
type: DocEventType.LocalChange,
Expand Down Expand Up @@ -1400,15 +1407,13 @@ export class Document<T, P extends Indexable = Indexable> {

// NOTE(chacha912): When there is no applied operation or presence
// during undo/redo, skip propagating change remotely.
if (change.hasPresenceChange() || opInfos.length > 0) {
this.localChanges.push(change);
if (!change.hasPresenceChange() && opInfos.length === 0) {
return;
}

this.localChanges.push(change);
this.changeID = change.getID();
const actorID = this.changeID.getActorID()!;
// NOTE(chacha912): Although operations are included in the change, they
// may not be executable (e.g., when the target element has been deleted).
// So we check opInfos, which represent the actually executed operations.
if (opInfos.length > 0) {
this.publish({
type: DocEventType.LocalChange,
Expand Down
76 changes: 76 additions & 0 deletions test/integration/object_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,82 @@ describe('Object', function () {
await client1.sync();
});

it(`Should not propagate changes when there is no applied undo operation`, async function ({
task,
}) {
interface TestDoc {
shape?: { color: string };
}
const docKey = toDocKey(`${task.name}-${new Date().getTime()}`);
const doc1 = new Document<TestDoc>(docKey);
const doc2 = new Document<TestDoc>(docKey);

const client1 = new Client(testRPCAddr);
const client2 = new Client(testRPCAddr);
await client1.activate();
await client2.activate();

await client1.attach(doc1, { isRealtimeSync: false });
let doc1ChangeID = doc1.getChangeID();
let doc1Checkpoint = doc1.getCheckpoint();
assert.equal(doc1ChangeID.getClientSeq(), 1);
assert.equal(doc1Checkpoint.getClientSeq(), 1);
assert.equal(doc1Checkpoint.getServerSeq().toInt(), 1);

doc1.update((root) => {
root.shape = { color: 'black' };
}, 'init doc');
await client1.sync();
assert.equal(doc1.toSortedJSON(), '{"shape":{"color":"black"}}');
doc1ChangeID = doc1.getChangeID();
doc1Checkpoint = doc1.getCheckpoint();
assert.equal(doc1ChangeID.getClientSeq(), 2);
assert.equal(doc1Checkpoint.getClientSeq(), 2);
assert.equal(doc1Checkpoint.getServerSeq().toInt(), 2);

await client2.attach(doc2, { isRealtimeSync: false });
assert.equal(doc2.toSortedJSON(), '{"shape":{"color":"black"}}');

doc2.update((root) => {
delete root.shape;
}, 'delete shape');
await client2.sync();
await client1.sync();
assert.equal(doc1.toSortedJSON(), '{}');
assert.equal(doc2.toSortedJSON(), '{}');
doc1ChangeID = doc1.getChangeID();
doc1Checkpoint = doc1.getCheckpoint();
assert.equal(doc1ChangeID.getClientSeq(), 2);
assert.equal(doc1Checkpoint.getClientSeq(), 2);
assert.equal(doc1Checkpoint.getServerSeq().toInt(), 4);

// c2 deleted the shape, so the reverse operation cannot be applied
doc1.history.undo();
assert.equal(doc1.toSortedJSON(), '{}');
assert.equal(doc1.getRedoStackForTest().length, 0);
assert.equal(doc1.history.canRedo(), false);
await client1.sync();
await client2.sync();
await client1.sync();
// Since there are no applied operations, there should be no change in the sequence.
doc1ChangeID = doc1.getChangeID();
doc1Checkpoint = doc1.getCheckpoint();
assert.equal(doc1ChangeID.getClientSeq(), 2);
assert.equal(doc1Checkpoint.getClientSeq(), 2);
assert.equal(doc1Checkpoint.getServerSeq().toInt(), 4);

doc1.update((root) => {
root.shape = { color: 'red' };
});
await client1.sync();
assert.equal(doc1.toSortedJSON(), '{"shape":{"color":"red"}}');
doc1ChangeID = doc1.getChangeID();
doc1Checkpoint = doc1.getCheckpoint();
assert.equal(doc1ChangeID.getClientSeq(), 3);
assert.equal(doc1Checkpoint.getClientSeq(), 3);
assert.equal(doc1Checkpoint.getServerSeq().toInt(), 5);
});

it('Can handle concurrent undo/redo: local undo & global redo', async function ({
task,
}) {
Expand Down
Loading