@@ -184,8 +184,22 @@ const OnChangeOnBlurPlugin = ({
184184} ) => {
185185 const [ editor ] = useLexicalComposerContext ( ) ;
186186 const handleChange = useEffectEvent ( onChange ) ;
187+
187188 useEffect (
188189 ( ) => ( ) => {
190+ // The issue is related to React’s development mode.
191+ // When we set the initial selection in the Editor, we disable Lexical’s internal
192+ // scrolling using the update operation tag tag: "skip-scroll-into-view".
193+ // The problem is that a read operation forces all pending update operations to commit,
194+ // and for some reason, this forced commit does not respect tags.
195+ // In React’s development mode, useEffect runs twice, which causes scrollIntoView
196+ // to be called during the first read.
197+ // To prevent this, we disconnect the editor from the DOM
198+ // by setting editor._rootElement = null;.
199+ // This makes Lexical assume it’s in headless mode,
200+ // preventing it from executing DOM operations.
201+ editor . _rootElement = null ;
202+
189203 // Safari and FF support as no blur event is triggered in some cases
190204 editor . read ( ( ) => {
191205 handleChange ( editor . getEditorState ( ) , "unmount" ) ;
@@ -559,192 +573,200 @@ const InitCursorPlugin = () => {
559573 return ;
560574 }
561575
562- editor . update ( ( ) => {
563- const textEditingInstanceSelector = $textEditingInstanceSelector . get ( ) ;
564- if ( textEditingInstanceSelector === undefined ) {
565- return ;
566- }
576+ editor . update (
577+ ( ) => {
578+ const textEditingInstanceSelector = $textEditingInstanceSelector . get ( ) ;
579+ if ( textEditingInstanceSelector === undefined ) {
580+ return ;
581+ }
567582
568- const { reason } = textEditingInstanceSelector ;
583+ const { reason } = textEditingInstanceSelector ;
569584
570- if ( reason === undefined ) {
571- return ;
572- }
585+ if ( reason === undefined ) {
586+ return ;
587+ }
573588
574- if ( reason === "click" ) {
575- const { mouseX, mouseY } = textEditingInstanceSelector ;
589+ if ( reason === "click" ) {
590+ const { mouseX, mouseY } = textEditingInstanceSelector ;
576591
577- const eventRange = caretFromPoint ( mouseX , mouseY ) ;
592+ const eventRange = caretFromPoint ( mouseX , mouseY ) ;
578593
579- if ( eventRange !== null ) {
580- const { offset : domOffset , node : domNode } = eventRange ;
581- const node = $getNearestNodeFromDOMNode ( domNode ) ;
594+ if ( eventRange !== null ) {
595+ const { offset : domOffset , node : domNode } = eventRange ;
596+ const node = $getNearestNodeFromDOMNode ( domNode ) ;
582597
583- if ( node !== null ) {
584- const selection = $createRangeSelection ( ) ;
585- if ( $isTextNode ( node ) ) {
586- selection . anchor . set ( node . getKey ( ) , domOffset , "text" ) ;
587- selection . focus . set ( node . getKey ( ) , domOffset , "text" ) ;
588- const normalizedSelection =
589- $normalizeSelection__EXPERIMENTAL ( selection ) ;
598+ if ( node !== null ) {
599+ const selection = $createRangeSelection ( ) ;
600+ if ( $isTextNode ( node ) ) {
601+ selection . anchor . set ( node . getKey ( ) , domOffset , "text" ) ;
602+ selection . focus . set ( node . getKey ( ) , domOffset , "text" ) ;
603+ const normalizedSelection =
604+ $normalizeSelection__EXPERIMENTAL ( selection ) ;
590605
591- $setSelection ( normalizedSelection ) ;
592- return ;
606+ $setSelection ( normalizedSelection ) ;
607+ return ;
608+ }
593609 }
594- }
595610
596- if ( domNode instanceof Element ) {
597- const rect = domNode . getBoundingClientRect ( ) ;
598- if ( mouseX > rect . right ) {
599- const selection = $getRoot ( ) . selectEnd ( ) ;
600- $setSelection ( selection ) ;
601- return ;
611+ if ( domNode instanceof Element ) {
612+ const rect = domNode . getBoundingClientRect ( ) ;
613+ if ( mouseX > rect . right ) {
614+ const selection = $getRoot ( ) . selectEnd ( ) ;
615+ $setSelection ( selection ) ;
616+ return ;
617+ }
602618 }
603619 }
604620 }
605- }
606-
607- while ( reason === "down" || reason === "up" ) {
608- const { cursorX } = textEditingInstanceSelector ;
609621
610- const [ topRects , bottomRects ] = getTopBottomRects ( editor ) ;
622+ while ( reason === "down" || reason === "up" ) {
623+ const { cursorX } = textEditingInstanceSelector ;
611624
612- // Smoodge the cursor a little to the left and right to find the nearest text node
613- const smoodgeOffsets = [ 1 , 2 , 4 ] ;
614- const maxOffset = Math . max ( ...smoodgeOffsets ) ;
625+ const [ topRects , bottomRects ] = getTopBottomRects ( editor ) ;
615626
616- const rects = reason === "down" ? topRects : bottomRects ;
627+ // Smoodge the cursor a little to the left and right to find the nearest text node
628+ const smoodgeOffsets = [ 1 , 2 , 4 ] ;
629+ const maxOffset = Math . max ( ...smoodgeOffsets ) ;
617630
618- rects . sort ( ( a , b ) => a . left - b . left ) ;
631+ const rects = reason === "down" ? topRects : bottomRects ;
619632
620- const rectWithText = rects . find (
621- ( rect , index ) =>
622- rect . left - ( index === 0 ? maxOffset : 0 ) <= cursorX &&
623- cursorX <= rect . right + ( index === rects . length - 1 ? maxOffset : 0 )
624- ) ;
633+ rects . sort ( ( a , b ) => a . left - b . left ) ;
625634
626- if ( rectWithText === undefined ) {
627- break ;
628- }
635+ const rectWithText = rects . find (
636+ ( rect , index ) =>
637+ rect . left - ( index === 0 ? maxOffset : 0 ) <= cursorX &&
638+ cursorX <=
639+ rect . right + ( index === rects . length - 1 ? maxOffset : 0 )
640+ ) ;
629641
630- const newCursorY = rectWithText . top + rectWithText . height / 2 ;
642+ if ( rectWithText === undefined ) {
643+ break ;
644+ }
631645
632- const eventRanges = [ caretFromPoint ( cursorX , newCursorY ) ] ;
633- for ( const offset of smoodgeOffsets ) {
634- eventRanges . push ( caretFromPoint ( cursorX - offset , newCursorY ) ) ;
635- eventRanges . push ( caretFromPoint ( cursorX + offset , newCursorY ) ) ;
636- }
646+ const newCursorY = rectWithText . top + rectWithText . height / 2 ;
637647
638- for ( const eventRange of eventRanges ) {
639- if ( eventRange === null ) {
640- continue ;
648+ const eventRanges = [ caretFromPoint ( cursorX , newCursorY ) ] ;
649+ for ( const offset of smoodgeOffsets ) {
650+ eventRanges . push ( caretFromPoint ( cursorX - offset , newCursorY ) ) ;
651+ eventRanges . push ( caretFromPoint ( cursorX + offset , newCursorY ) ) ;
641652 }
642653
643- const { offset : domOffset , node : domNode } = eventRange ;
644- const node = $getNearestNodeFromDOMNode ( domNode ) ;
645-
646- if ( node !== null && $isTextNode ( node ) ) {
647- const selection = $createRangeSelection ( ) ;
648- selection . anchor . set ( node . getKey ( ) , domOffset , "text" ) ;
649- selection . focus . set ( node . getKey ( ) , domOffset , "text" ) ;
650- const normalizedSelection =
651- $normalizeSelection__EXPERIMENTAL ( selection ) ;
652- $setSelection ( normalizedSelection ) ;
654+ for ( const eventRange of eventRanges ) {
655+ if ( eventRange === null ) {
656+ continue ;
657+ }
653658
654- return ;
655- }
656- }
659+ const { offset : domOffset , node : domNode } = eventRange ;
660+ const node = $getNearestNodeFromDOMNode ( domNode ) ;
657661
658- break ;
659- }
662+ if ( node !== null && $isTextNode ( node ) ) {
663+ const selection = $createRangeSelection ( ) ;
664+ selection . anchor . set ( node . getKey ( ) , domOffset , "text" ) ;
665+ selection . focus . set ( node . getKey ( ) , domOffset , "text" ) ;
666+ const normalizedSelection =
667+ $normalizeSelection__EXPERIMENTAL ( selection ) ;
668+ $setSelection ( normalizedSelection ) ;
660669
661- if (
662- reason === "down" ||
663- reason === "right" ||
664- reason === "enter" ||
665- reason === "click"
666- ) {
667- const firstNode = $getRoot ( ) . getFirstDescendant ( ) ;
670+ return ;
671+ }
672+ }
668673
669- if ( firstNode === null ) {
670- return ;
674+ break ;
671675 }
672676
673- if ( $isTextNode ( firstNode ) ) {
674- const selection = $createRangeSelection ( ) ;
675- selection . anchor . set ( firstNode . getKey ( ) , 0 , "text" ) ;
676- selection . focus . set ( firstNode . getKey ( ) , 0 , "text" ) ;
677- $setSelection ( selection ) ;
678- }
677+ if (
678+ reason === "down" ||
679+ reason === "right" ||
680+ reason === "enter" ||
681+ reason === "click"
682+ ) {
683+ const firstNode = $getRoot ( ) . getFirstDescendant ( ) ;
679684
680- if ( $isElementNode ( firstNode ) ) {
681- // e.g. Box is empty
682- const selection = $createRangeSelection ( ) ;
683- selection . anchor . set ( firstNode . getKey ( ) , 0 , "element" ) ;
684- selection . focus . set ( firstNode . getKey ( ) , 0 , "element" ) ;
685- $setSelection ( selection ) ;
686- }
685+ if ( firstNode === null ) {
686+ return ;
687+ }
687688
688- if ( $isLineBreakNode ( firstNode ) ) {
689- // e.g. Box contains 2+ empty lines
690- const selection = $createRangeSelection ( ) ;
691- $setSelection ( selection ) ;
692- }
689+ if ( $isTextNode ( firstNode ) ) {
690+ const selection = $createRangeSelection ( ) ;
691+ selection . anchor . set ( firstNode . getKey ( ) , 0 , "text" ) ;
692+ selection . focus . set ( firstNode . getKey ( ) , 0 , "text" ) ;
693+ $setSelection ( selection ) ;
694+ }
693695
694- return ;
695- }
696+ if ( $isElementNode ( firstNode ) ) {
697+ // e.g. Box is empty
698+ const selection = $createRangeSelection ( ) ;
699+ selection . anchor . set ( firstNode . getKey ( ) , 0 , "element" ) ;
700+ selection . focus . set ( firstNode . getKey ( ) , 0 , "element" ) ;
701+ $setSelection ( selection ) ;
702+ }
696703
697- if ( reason === "up" || reason === "left" ) {
698- const selection = $createRangeSelection ( ) ;
699- const lastNode = $getRoot ( ) . getLastDescendant ( ) ;
704+ if ( $isLineBreakNode ( firstNode ) ) {
705+ // e.g. Box contains 2+ empty lines
706+ const selection = $createRangeSelection ( ) ;
707+ $setSelection ( selection ) ;
708+ }
700709
701- if ( lastNode === null ) {
702710 return ;
703711 }
704712
705- if ( $isTextNode ( lastNode ) ) {
706- const contentSize = lastNode . getTextContentSize ( ) ;
707- selection . anchor . set ( lastNode . getKey ( ) , contentSize , "text" ) ;
708- selection . focus . set ( lastNode . getKey ( ) , contentSize , "text" ) ;
709- $setSelection ( selection ) ;
710- }
711-
712- if ( $isElementNode ( lastNode ) ) {
713- // e.g. Box is empty
713+ if ( reason === "up" || reason === "left" ) {
714714 const selection = $createRangeSelection ( ) ;
715- selection . anchor . set ( lastNode . getKey ( ) , 0 , "element" ) ;
716- selection . focus . set ( lastNode . getKey ( ) , 0 , "element" ) ;
717- $setSelection ( selection ) ;
718- }
715+ const lastNode = $getRoot ( ) . getLastDescendant ( ) ;
719716
720- if ( $isLineBreakNode ( lastNode ) ) {
721- // e.g. Box contains 2+ empty lines
722- const parent = lastNode . getParent ( ) ;
723- if ( $isElementNode ( parent ) ) {
717+ if ( lastNode === null ) {
718+ return ;
719+ }
720+
721+ if ( $isTextNode ( lastNode ) ) {
722+ const contentSize = lastNode . getTextContentSize ( ) ;
723+ selection . anchor . set ( lastNode . getKey ( ) , contentSize , "text" ) ;
724+ selection . focus . set ( lastNode . getKey ( ) , contentSize , "text" ) ;
725+ $setSelection ( selection ) ;
726+ }
727+
728+ if ( $isElementNode ( lastNode ) ) {
729+ // e.g. Box is empty
724730 const selection = $createRangeSelection ( ) ;
725- selection . anchor . set (
726- parent . getKey ( ) ,
727- parent . getChildrenSize ( ) ,
728- "element"
729- ) ;
730- selection . focus . set (
731- parent . getKey ( ) ,
732- parent . getChildrenSize ( ) ,
733- "element"
734- ) ;
731+ selection . anchor . set ( lastNode . getKey ( ) , 0 , "element" ) ;
732+ selection . focus . set ( lastNode . getKey ( ) , 0 , "element" ) ;
735733 $setSelection ( selection ) ;
736734 }
735+
736+ if ( $isLineBreakNode ( lastNode ) ) {
737+ // e.g. Box contains 2+ empty lines
738+ const parent = lastNode . getParent ( ) ;
739+ if ( $isElementNode ( parent ) ) {
740+ const selection = $createRangeSelection ( ) ;
741+ selection . anchor . set (
742+ parent . getKey ( ) ,
743+ parent . getChildrenSize ( ) ,
744+ "element"
745+ ) ;
746+ selection . focus . set (
747+ parent . getKey ( ) ,
748+ parent . getChildrenSize ( ) ,
749+ "element"
750+ ) ;
751+ $setSelection ( selection ) ;
752+ }
753+ }
754+
755+ return ;
756+ }
757+ if ( reason === "new" ) {
758+ $selectAll ( ) ;
759+ return ;
737760 }
738761
739- return ;
740- }
741- if ( reason === "new" ) {
742- $selectAll ( ) ;
743- return ;
762+ reason satisfies never ;
763+ } ,
764+ {
765+ // We are controlling scroll ourself in instance-selected.ts see updateScroll.
766+ // Without skipping we are getting side effects of composition in scrollBy, scrollIntoView calls
767+ tag : "skip-scroll-into-view" ,
744768 }
745-
746- reason satisfies never ;
747- } ) ;
769+ ) ;
748770 } , [ editor ] ) ;
749771
750772 return null ;
0 commit comments