@@ -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