Skip to content

Commit 84c308c

Browse files
committed
fix: movable list diff issue
1 parent cce36e8 commit 84c308c

File tree

3 files changed

+292
-35
lines changed

3 files changed

+292
-35
lines changed

packages/core/src/core/diff.ts

Lines changed: 61 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -321,18 +321,22 @@ export function diffMovableList<S extends ArrayLike>(
321321

322322
for (const [newIndex, item] of newState.entries()) {
323323
const id = idSelector(item);
324-
if (id) {
325-
newMap.set(id, { index: newIndex, item });
326-
if (oldMap.has(id)) {
327-
const { index: oldIndex, item: oldItem } = oldMap.get(id)!;
328-
commonItems.push({
329-
id,
330-
oldIndex,
331-
newIndex,
332-
oldItem,
333-
newItem: item,
334-
});
335-
}
324+
if (!id) {
325+
throw new Error("Item ID cannot be null");
326+
}
327+
if (newMap.has(id)) {
328+
throw new Error("Duplicate item id in new state");
329+
}
330+
newMap.set(id, { index: newIndex, item });
331+
if (oldMap.has(id)) {
332+
const { index: oldIndex, item: oldItem } = oldMap.get(id)!;
333+
commonItems.push({
334+
id,
335+
oldIndex,
336+
newIndex,
337+
oldItem,
338+
newItem: item,
339+
});
336340
}
337341
}
338342

@@ -355,27 +359,51 @@ export function diffMovableList<S extends ArrayLike>(
355359
changes.push(...deletionOps);
356360

357361
// Handle moves
358-
const oldIndicesSequence = commonItems.map((info) => info.oldIndex);
359-
360-
/** LIS of the old indices that are in the common items
361-
* This is represented as the core set of indexes which remain the same
362-
* betweeen both old and new states.
363-
* All move operations should only be performed on items that are not in the LIS.
364-
* By excluding items in the LIS, we ensure that we don't perform unnecessary move operations.
365-
*/
366-
const lisIndices = longestIncreasingSubsequence(oldIndicesSequence);
367-
const lisSet = new Set<number>(lisIndices);
368-
for (const [i, info] of commonItems.entries()) {
369-
// If the common item is not in the LIS and its positions differ, mark it for move
370-
if (!lisSet.has(i) && info.oldIndex !== info.newIndex) {
371-
changes.push({
372-
container: containerId,
373-
key: info.oldIndex,
374-
value: info.newItem,
375-
kind: "move",
376-
fromIndex: info.oldIndex,
377-
toIndex: info.newIndex,
378-
});
362+
// After deletions are applied, indices shift left for items after a deleted index.
363+
// Compute move operations relative to the post-deletion list to avoid index mismatch.
364+
// Build the order of common item IDs in old and new states.
365+
const oldCommonIds: string[] = [];
366+
for (const item of oldState) {
367+
const id = idSelector(item);
368+
if (id && newMap.has(id)) {
369+
oldCommonIds.push(id);
370+
}
371+
}
372+
const newCommonIds: string[] = commonItems.map((info) => info.id);
373+
374+
// Simulate moves on an array of IDs to compute correct from/to indices
375+
const currentOrder = [...oldCommonIds];
376+
const currentIndexMap = new Map<string, number>();
377+
currentOrder.forEach((id, idx) => currentIndexMap.set(id, idx));
378+
379+
for (
380+
let targetIndex = 0;
381+
targetIndex < newCommonIds.length;
382+
targetIndex++
383+
) {
384+
const id = newCommonIds[targetIndex];
385+
const currentIndex = currentIndexMap.get(id);
386+
if (currentIndex === undefined) continue; // safety guard
387+
if (currentIndex === targetIndex) continue; // already in place
388+
389+
changes.push({
390+
container: containerId,
391+
key: currentIndex,
392+
value: undefined,
393+
kind: "move",
394+
fromIndex: currentIndex,
395+
toIndex: targetIndex,
396+
});
397+
398+
// Update the simulated order and index map after the move
399+
const [moved] = currentOrder.splice(currentIndex, 1);
400+
currentOrder.splice(targetIndex, 0, moved);
401+
402+
// Update indices for the affected range
403+
const start = Math.min(currentIndex, targetIndex);
404+
const end = Math.max(currentIndex, targetIndex);
405+
for (let i = start; i <= end; i++) {
406+
currentIndexMap.set(currentOrder[i], i);
379407
}
380408
}
381409

packages/core/tests/core/mirror-movable-list.test.ts

Lines changed: 231 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ const waitForSync = async () => {
1212
};
1313

1414
describe("MovableList", () => {
15-
1615
async function initTestMirror() {
1716
const doc = new LoroDoc();
1817
doc.setPeerId(1);
@@ -349,4 +348,235 @@ describe("MovableList", () => {
349348

350349
expect(mirror.getState()).toEqual(desiredState);
351350
});
351+
352+
it("movable list handles basic moves", async () => {
353+
const doc = new LoroDoc();
354+
doc.setPeerId(1);
355+
const schema_ = schema({
356+
list: schema.LoroMovableList(
357+
schema.LoroMap({
358+
id: schema.String(),
359+
text: schema.LoroText(),
360+
}),
361+
(item) => item.id,
362+
),
363+
});
364+
365+
const mirror = new Mirror({
366+
doc,
367+
schema: schema_,
368+
});
369+
370+
mirror.setState({
371+
list: [
372+
{ id: "0", text: "" },
373+
{ id: "1", text: "" },
374+
{ id: "2", text: "" },
375+
{ id: "3", text: "" },
376+
],
377+
});
378+
expect(doc.frontiers()[0].counter).toBe(11);
379+
mirror.setState({
380+
list: [
381+
{ id: "1", text: "" },
382+
{ id: "0", text: "" },
383+
{ id: "2", text: "" },
384+
{ id: "3", text: "" },
385+
],
386+
});
387+
expect(doc.frontiers()[0].counter).toBe(12);
388+
mirror.setState({
389+
list: [
390+
{ id: "0", text: "" },
391+
{ id: "1", text: "" },
392+
{ id: "3", text: "" },
393+
{ id: "2", text: "" },
394+
],
395+
});
396+
expect(doc.frontiers()[0].counter).toBe(14);
397+
});
398+
399+
it("movable list handles delete + reorder without index errors", async () => {
400+
const { mirror, doc } = await initTestMirror();
401+
402+
// Set to four items first
403+
mirror.setState({
404+
list: [
405+
{ id: "A", text: "tA" },
406+
{ id: "B", text: "tB" },
407+
{ id: "C", text: "tC" },
408+
{ id: "D", text: "tD" },
409+
],
410+
});
411+
await waitForSync();
412+
413+
const initial = doc.getDeepValueWithID();
414+
const idToCid = new Map(
415+
initial.list.value.map((x: any) => [x.value.id, x.cid]),
416+
);
417+
418+
const desired = {
419+
list: [
420+
{ id: "D", text: "tD" },
421+
{ id: "C", text: "tC" },
422+
{ id: "B", text: "tB" },
423+
],
424+
};
425+
mirror.setState(desired);
426+
await waitForSync();
427+
428+
const after = doc.getDeepValueWithID();
429+
expect(after.list.value.map((x: any) => x.value.id)).toEqual([
430+
"D",
431+
"C",
432+
"B",
433+
]);
434+
// Container IDs preserved for remaining items
435+
expect(after.list.value[0].cid).toBe(idToCid.get("D"));
436+
expect(after.list.value[1].cid).toBe(idToCid.get("C"));
437+
expect(after.list.value[2].cid).toBe(idToCid.get("B"));
438+
439+
// Ensure state mirrors correctly
440+
expect(mirror.getState()).toEqual(desired);
441+
});
442+
443+
it("movable list handles insert + delete + reorder mix", async () => {
444+
const { mirror, doc } = await initTestMirror();
445+
446+
mirror.setState({
447+
list: [
448+
{ id: "A", text: "tA" },
449+
{ id: "B", text: "tB" },
450+
{ id: "C", text: "tC" },
451+
],
452+
});
453+
await waitForSync();
454+
455+
const initial = doc.getDeepValueWithID();
456+
const idToCid = new Map(
457+
initial.list.value.map((x: any) => [x.value.id, x.cid]),
458+
);
459+
460+
const desired = {
461+
list: [
462+
{ id: "C", text: "tc" },
463+
{ id: "E", text: "te" },
464+
{ id: "B", text: "tb" },
465+
],
466+
};
467+
mirror.setState(desired);
468+
await waitForSync();
469+
470+
const after = doc.getDeepValueWithID();
471+
expect(after.list.value.map((x: any) => x.value.id)).toEqual([
472+
"C",
473+
"E",
474+
"B",
475+
]);
476+
// C and B preserve container ids; E is new
477+
expect(after.list.value[0].cid).toBe(idToCid.get("C"));
478+
expect(after.list.value[2].cid).toBe(idToCid.get("B"));
479+
expect(after.list.value[1].cid).not.toBe(idToCid.get("A"));
480+
expect(after.list.value[1].cid).not.toBe(idToCid.get("B"));
481+
expect(after.list.value[1].cid).not.toBe(idToCid.get("C"));
482+
483+
// Texts updated accordingly
484+
expect(after.list.value[0].value.text.value).toBe("tc");
485+
expect(after.list.value[2].value.text.value).toBe("tb");
486+
expect(after.list.value[1].value.text.value).toBe("te");
487+
488+
expect(mirror.getState()).toEqual(desired);
489+
});
490+
491+
it("movable list throws on missing item id", async () => {
492+
const { mirror } = await initTestMirror();
493+
// @ts-expect-error testing runtime validation
494+
expect(() =>
495+
mirror.setState({
496+
list: [
497+
// missing id
498+
{ text: "no id" },
499+
],
500+
}),
501+
).toThrow();
502+
});
503+
504+
it("movable list throws on duplicate ids in new state", async () => {
505+
const { mirror } = await initTestMirror();
506+
expect(() =>
507+
mirror.setState({
508+
list: [
509+
{ id: "X", text: "1" },
510+
{ id: "X", text: "2" },
511+
],
512+
}),
513+
).toThrow();
514+
});
515+
516+
it("movable list fuzz: large shuffles preserve container ids and text", async () => {
517+
const { mirror, doc } = await initTestMirror();
518+
519+
// Deterministic RNG
520+
let seed = 0x12345678;
521+
const rand = () => {
522+
seed = (1664525 * seed + 1013904223) >>> 0;
523+
return seed / 0x100000000;
524+
};
525+
const shuffle = <T>(arr: T[]): T[] => {
526+
const a = arr.slice();
527+
for (let i = a.length - 1; i > 0; i--) {
528+
const j = Math.floor(rand() * (i + 1));
529+
[a[i], a[j]] = [a[j], a[i]];
530+
}
531+
return a;
532+
};
533+
534+
const N = 80;
535+
const ROUNDS = 25;
536+
537+
const makeItem = (i: number) => ({
538+
id: String(i + 1),
539+
text: `T${i + 1}`,
540+
});
541+
let current = Array.from({ length: N }, (_, i) => makeItem(i));
542+
543+
mirror.setState({ list: current });
544+
await waitForSync();
545+
546+
const initial = doc.getDeepValueWithID();
547+
const initialCidById = new Map(
548+
initial.list.value.map((x: any) => [x.value.id, x.cid]),
549+
);
550+
551+
for (let r = 0; r < ROUNDS; r++) {
552+
// Shuffle order
553+
let next = shuffle(current);
554+
555+
// Randomly update a few items' text to exercise nested updates
556+
const updates = Math.floor(rand() * 5);
557+
for (let k = 0; k < updates; k++) {
558+
const idx = Math.floor(rand() * next.length);
559+
const id = next[idx].id;
560+
next[idx] = { id, text: `T${id}-r${r}` } as any;
561+
}
562+
563+
mirror.setState({ list: next });
564+
await waitForSync();
565+
566+
const after = doc.getDeepValueWithID();
567+
// Verify order and IDs
568+
const ids = after.list.value.map((x: any) => x.value.id);
569+
expect(ids).toEqual(next.map((x) => x.id));
570+
// Verify container IDs preserved
571+
after.list.value.forEach((x: any, i: number) => {
572+
expect(x.cid).toBe(initialCidById.get(next[i].id));
573+
expect(x.value.text.value).toBe(next[i].text);
574+
});
575+
576+
// Mirror state reflects next
577+
expect(mirror.getState()).toEqual({ list: next });
578+
579+
current = next;
580+
}
581+
});
352582
});

packages/react/tsconfig.tsbuildinfo

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)