Skip to content

Commit 3e75aaa

Browse files
authored
fix(core): minimize list diffs for reorders (#70)
* fix(core): minimize list diffs for reorders * test(core): add fuzz coverage for list diffs * fix(core): clarify movable list append index * test(core): expand fuzz coverage for list diffs
1 parent 3aa6c97 commit 3e75aaa

File tree

5 files changed

+675
-104
lines changed

5 files changed

+675
-104
lines changed

packages/core/src/core/diff.ts

Lines changed: 124 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -585,29 +585,71 @@ export function diffMovableList<S extends ArrayLike>(
585585
}
586586
const newCommonIds: string[] = common.map((c) => c.id);
587587
if (!deepEqual(oldCommonIds, newCommonIds)) {
588-
// Need to move
588+
// Need to move.
589+
//
590+
// Use LIS to keep the longest subsequence of items that are already in the correct
591+
// relative order, and move only the remaining items. This minimizes the number of
592+
// `move` operations for pure reorders.
593+
const oldPosById = new Map<string, number>();
594+
oldCommonIds.forEach((id, i) => oldPosById.set(id, i));
595+
const oldPositionsInNewOrder = newCommonIds.map((id) => {
596+
const pos = oldPosById.get(id);
597+
if (pos == null) {
598+
throw new Error("Invariant violation: common id missing in old state");
599+
}
600+
return pos;
601+
});
602+
const stableNewIndices = new Set(
603+
longestIncreasingSubsequence(oldPositionsInNewOrder),
604+
);
605+
589606
const order = [...oldCommonIds];
590607
const idxOf = new Map<string, number>();
591608
order.forEach((id, i) => idxOf.set(id, i));
592609

593-
for (let target = 0; target < newCommonIds.length; target++) {
594-
const id = newCommonIds[target];
610+
for (let i = newCommonIds.length - 1; i >= 0; i--) {
611+
const id = newCommonIds[i];
612+
if (stableNewIndices.has(i)) continue;
595613
const from = idxOf.get(id);
596-
if (from == null || from === target) continue;
614+
if (from == null) continue;
615+
616+
// Place `id` right before the already-processed "anchor" (the next id in new order).
617+
// This matches the standard LIS-based list diff strategy and avoids index drift when
618+
// earlier moves shift later indices.
619+
const anchorId = i + 1 < newCommonIds.length ? newCommonIds[i + 1] : undefined;
620+
const anchorIndex = anchorId ? idxOf.get(anchorId) : undefined;
621+
if (anchorId && anchorIndex == null) {
622+
throw new Error("Invariant violation: anchor id missing in current order");
623+
}
624+
625+
// `toIndex` is defined in the list *after* the removal.
626+
//
627+
// - If we have an anchor, we want to insert right before it.
628+
// - If we don't have an anchor (end of the list), treat it as an append.
629+
//
630+
// For the anchor case, when `from < anchorIndex`, removing the element shifts
631+
// the anchor left by 1, so we subtract 1.
632+
const targetBeforeRemoval = anchorIndex ?? order.length;
633+
let to = targetBeforeRemoval;
634+
if (from < targetBeforeRemoval) {
635+
to -= 1;
636+
}
637+
638+
if (from === to) continue;
597639

598640
changes.push({
599641
container: containerId,
600642
key: from,
601643
value: undefined,
602644
kind: "move",
603645
fromIndex: from,
604-
toIndex: target,
646+
toIndex: to,
605647
});
606648

607649
const [moved] = order.splice(from, 1);
608-
order.splice(target, 0, moved);
609-
const start = Math.min(from, target);
610-
const end = Math.max(from, target);
650+
order.splice(to, 0, moved);
651+
const start = Math.min(from, to);
652+
const end = Math.max(from, to);
611653
for (let i = start; i <= end; i++) idxOf.set(order[i], i);
612654
}
613655
}
@@ -694,8 +736,8 @@ export function diffListWithIdSelector<S extends ArrayLike>(
694736
}
695737

696738
const useContainer = !!(schema?.itemSchema.getContainerType() ?? true);
697-
const oldItemsById = new Map();
698-
const newItemsById = new Map();
739+
const oldItemsById = new Map<string, { item: unknown; index: number }>();
740+
const newItemsById = new Map<string, { item: unknown; newIndex: number }>();
699741

700742
for (const [index, item] of oldState.entries()) {
701743
const id = idSelector(item);
@@ -714,81 +756,93 @@ export function diffListWithIdSelector<S extends ArrayLike>(
714756
}
715757
}
716758

717-
const list = doc.getList(containerId);
718-
let newIndex = 0;
719-
let offset = 0;
720-
let index = 0;
721-
while (index < oldState.length) {
722-
if (newIndex >= newState.length) {
723-
// An old item not found in the new state, delete here
759+
// Compute the longest common subsequence (LCS) between old/new id sequences via LIS on
760+
// old indices in new order (ids are expected to be unique).
761+
const commonIdsInNewOrder: string[] = [];
762+
const oldIndexInNewOrder: number[] = [];
763+
for (const [newIndex, item] of newState.entries()) {
764+
const id = idSelector(item);
765+
if (!id) continue;
766+
const oldInfo = oldItemsById.get(id);
767+
if (!oldInfo) continue;
768+
commonIdsInNewOrder.push(id);
769+
oldIndexInNewOrder.push(oldInfo.index);
770+
}
771+
772+
const keepIdSet = new Set<string>();
773+
for (const i of longestIncreasingSubsequence(oldIndexInNewOrder)) {
774+
keepIdSet.add(commonIdsInNewOrder[i]);
775+
}
776+
777+
// 1) Deletions (from highest index to lowest).
778+
for (let i = oldState.length - 1; i >= 0; i--) {
779+
const id = idSelector(oldState[i]);
780+
if (!id || !keepIdSet.has(id)) {
724781
changes.push({
725782
container: containerId,
726-
key: index + offset,
783+
key: i,
727784
value: undefined,
728785
kind: "delete",
729786
});
730-
offset--;
731-
index++;
732-
continue;
733787
}
788+
}
734789

735-
const oldItem = oldState[index];
736-
const newItem = newState[newIndex];
737-
if (oldItem === newItem) {
738-
newIndex++;
739-
index++;
740-
continue;
790+
// 2) Insertions (items that are new or moved).
791+
for (const [newIndex, item] of newState.entries()) {
792+
const id = idSelector(item);
793+
if (!id || !keepIdSet.has(id)) {
794+
changes.push(
795+
tryUpdateToContainer(
796+
{
797+
container: containerId,
798+
key: newIndex,
799+
value: item,
800+
kind: "insert",
801+
},
802+
useContainer,
803+
schema?.itemSchema,
804+
inferOptions,
805+
),
806+
);
741807
}
808+
}
742809

743-
const oldId = idSelector(oldItem);
744-
const newId = idSelector(newItem);
745-
if (oldId === newId) {
746-
const item = list.get(index);
747-
if (isContainer(item)) {
748-
changes.push(
749-
...diffContainer(
750-
doc,
751-
oldItem,
752-
newItem,
753-
item.id,
754-
schema?.itemSchema,
755-
inferOptions,
756-
),
757-
);
758-
} else if (!deepEqual(oldItem, newItem)) {
759-
changes.push({
760-
container: containerId,
761-
key: index + offset,
762-
value: undefined,
763-
kind: "delete",
764-
});
765-
changes.push(
766-
tryUpdateToContainer(
767-
{
768-
container: containerId,
769-
key: index + offset,
770-
value: newItem,
771-
kind: "insert",
772-
},
773-
useContainer,
774-
schema?.itemSchema,
775-
inferOptions,
776-
),
777-
);
778-
}
810+
// 3) Updates for kept items (preserve nested containers when possible).
811+
const list = doc.getList(containerId);
812+
for (const id of commonIdsInNewOrder) {
813+
if (!keepIdSet.has(id)) continue;
814+
const oldInfo = oldItemsById.get(id);
815+
const newInfo = newItemsById.get(id);
816+
if (!oldInfo || !newInfo) continue;
779817

780-
index++;
781-
newIndex++;
782-
continue;
783-
}
818+
const oldItem = oldInfo.item;
819+
const newItem = newInfo.item;
820+
if (deepEqual(oldItem, newItem)) continue;
784821

785-
if (newId && !oldItemsById.has(newId)) {
786-
// A new item
822+
const itemOnLoro = list.get(oldInfo.index);
823+
if (isContainer(itemOnLoro)) {
824+
changes.push(
825+
...diffContainer(
826+
doc,
827+
oldItem,
828+
newItem,
829+
itemOnLoro.id,
830+
schema?.itemSchema,
831+
inferOptions,
832+
),
833+
);
834+
} else {
835+
changes.push({
836+
container: containerId,
837+
key: newInfo.newIndex,
838+
value: undefined,
839+
kind: "delete",
840+
});
787841
changes.push(
788842
tryUpdateToContainer(
789843
{
790844
container: containerId,
791-
key: index + offset,
845+
key: newInfo.newIndex,
792846
value: newItem,
793847
kind: "insert",
794848
},
@@ -797,39 +851,7 @@ export function diffListWithIdSelector<S extends ArrayLike>(
797851
inferOptions,
798852
),
799853
);
800-
801-
offset++;
802-
newIndex++;
803-
continue;
804854
}
805-
806-
// An old item not found in the new state, delete here
807-
changes.push({
808-
container: containerId,
809-
key: index + offset,
810-
value: undefined,
811-
kind: "delete",
812-
});
813-
offset--;
814-
index++;
815-
}
816-
817-
for (; newIndex < newState.length; newIndex++) {
818-
const newItem = newState[newIndex];
819-
changes.push(
820-
tryUpdateToContainer(
821-
{
822-
container: containerId,
823-
key: index + offset,
824-
value: newItem,
825-
kind: "insert",
826-
},
827-
useContainer,
828-
schema?.itemSchema,
829-
inferOptions,
830-
),
831-
);
832-
offset++;
833855
}
834856

835857
return changes;

0 commit comments

Comments
 (0)