Repository: facebook/lexical (TypeScript)

Issue:
Bug: When setting styles on a collapsed selection, the styles object is not copied.
Lexical version: 59990233d496f50a365e68008df4bba2ade6aaf4 (0.8.0 branch)

## Steps To Reproduce

```ts
$getSelectionStyleValueForProperty($getSelection(), 'color', '');

$patchStyleText($getSelection(), {color: 'red'});

$getSelection().setStyle('');

$getSelectionStyleValueForProperty($getSelection(), 'color', '');
```

## The current behavior

The final call to `$getSelectionStyleValueForProperty` returns `'red'`.

## The expected behavior

It should return an empty string.

## Analysis

The problem is that the initial call to `$getSelectionStyleValueForProperty` will populate the CSS style cache (`CSS_TO_STYLES`) with an empty string pointing to an empty object then the subsequent call to `$patchStyleText`, when it operates on a collapsed selection, will directly modify that object without first cloning it.


Discussion:

Patch:
diff --git a/packages/lexical-selection/src/lexical-node.ts b/packages/lexical-selection/src/lexical-node.ts
index 9838c9627e6..074ca949153 100644
--- a/packages/lexical-selection/src/lexical-node.ts
+++ b/packages/lexical-selection/src/lexical-node.ts
@@ -247,11 +247,13 @@ export function $addNodeStyle(node: TextNode): void {
   CSS_TO_STYLES.set(CSSText, styles);
 }
 
-function $patchNodeStyle(
-  node: TextNode,
+function $patchStyle(
+  target: TextNode | RangeSelection,
   patch: Record<string, string | null>,
 ): void {
-  const prevStyles = getStyleObjectFromCSS(node.getStyle());
+  const prevStyles = getStyleObjectFromCSS(
+    'getStyle' in target ? target.getStyle() : target.style,
+  );
   const newStyles = Object.entries(patch).reduce<Record<string, string>>(
     (styles, [key, value]) => {
       if (value === null) {
@@ -264,7 +266,7 @@ function $patchNodeStyle(
     {...prevStyles} || {},
   );
   const newCSSText = getCSSFromStyleObject(newStyles);
-  node.setStyle(newCSSText);
+  target.setStyle(newCSSText);
   CSS_TO_STYLES.set(newCSSText, newStyles);
 }
 
@@ -279,18 +281,7 @@ export function $patchStyleText(
   let lastNode = selectedNodes[lastIndex];
 
   if (selection.isCollapsed()) {
-    const styles = getStyleObjectFromCSS(selection.style);
-    Object.entries(patch).forEach(([key, value]) => {
-      if (value === null) {
-        delete styles[key];
-      } else {
-        styles[key] = value;
-      }
-      return styles;
-    });
-    const style = getCSSFromStyleObject(styles);
-    selection.setStyle(style);
-    CSS_TO_STYLES.set(style, styles);
+    $patchStyle(selection, patch);
     return;
   }
 
@@ -343,14 +334,14 @@ export function $patchStyleText(
 
       // The entire node is selected, so just format it
       if (startOffset === 0 && endOffset === firstNodeTextLength) {
-        $patchNodeStyle(firstNode, patch);
+        $patchStyle(firstNode, patch);
         firstNode.select(startOffset, endOffset);
       } else {
         // The node is partially selected, so split it into two nodes
         // and style the selected one.
         const splitNodes = firstNode.splitText(startOffset, endOffset);
         const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
-        $patchNodeStyle(replacement, patch);
+        $patchStyle(replacement, patch);
         replacement.select(0, endOffset - startOffset);
       }
     } // multiple nodes selected.
@@ -365,7 +356,7 @@ export function $patchStyleText(
         startOffset = 0;
       }
 
-      $patchNodeStyle(firstNode as TextNode, patch);
+      $patchStyle(firstNode as TextNode, patch);
     }
 
     if ($isTextNode(lastNode)) {
@@ -386,7 +377,7 @@ export function $patchStyleText(
       }
 
       if (endOffset !== 0) {
-        $patchNodeStyle(lastNode as TextNode, patch);
+        $patchStyle(lastNode as TextNode, patch);
       }
     }
 
@@ -401,7 +392,7 @@ export function $patchStyleText(
         selectedNodeKey !== lastNode.getKey() &&
         !selectedNode.isToken()
       ) {
-        $patchNodeStyle(selectedNode, patch);
+        $patchStyle(selectedNode, patch);
       }
     }
   }
diff --git a/packages/lexical-selection/src/range-selection.ts b/packages/lexical-selection/src/range-selection.ts
index cf2e97f0e53..256e07cc717 100644
--- a/packages/lexical-selection/src/range-selection.ts
+++ b/packages/lexical-selection/src/range-selection.ts
@@ -446,7 +446,7 @@ export function $getSelectionStyleValueForProperty(
   styleProperty: string,
   defaultValue = '',
 ): string {
-  let styleValue = null;
+  let styleValue: string | null = null;
   const nodes = selection.getNodes();
   const anchor = selection.anchor;
   const focus = selection.focus;
@@ -457,20 +457,9 @@ export function $getSelectionStyleValueForProperty(
   if (selection.style !== '') {
     const css = selection.style;
     const styleObject = getStyleObjectFromCSS(css);
-    let nodeStyleValue;
 
-    if (styleObject !== null) {
-      nodeStyleValue = styleObject[styleProperty] || defaultValue;
-    } else {
-      nodeStyleValue = defaultValue;
-    }
-
-    if (styleValue === null) {
-      styleValue = nodeStyleValue;
-    } else if (styleValue !== nodeStyleValue) {
-      // multiple text nodes are in the selection and they don't all
-      // have the same font size.
-      styleValue = '';
+    if (styleObject !== null && styleProperty in styleObject) {
+      return styleObject[styleProperty];
     }
   }
 
@@ -495,7 +484,7 @@ export function $getSelectionStyleValueForProperty(
         styleValue = nodeStyleValue;
       } else if (styleValue !== nodeStyleValue) {
         // multiple text nodes are in the selection and they don't all
-        // have the same font size.
+        // have the same style.
         styleValue = '';
         break;
       }


Test patch:
diff --git a/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts b/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts
index f2204281933..102e68b3c60 100644
--- a/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts
+++ b/packages/lexical-selection/src/__tests__/unit/LexicalSelectionHelpers.test.ts
@@ -2911,4 +2911,50 @@ describe('$patchStyleText', () => {
       ).toEqual('filled');
     });
   });
+
+  test('updates cached styles when setting on a collapsed selection', async () => {
+    const editor = createTestEditor();
+    const element = document.createElement('div');
+    editor.setRootElement(element);
+
+    await editor.update(() => {
+      const root = $getRoot();
+
+      const paragraph = $createParagraphNode();
+      root.append(paragraph);
+
+      const text = $createTextNode('text');
+      paragraph.append(text);
+
+      setAnchorPoint({
+        key: text.getKey(),
+        offset: 0,
+        type: 'text',
+      });
+      setFocusPoint({
+        key: text.getKey(),
+        offset: 0,
+        type: 'text',
+      });
+
+      // First fetch the initial style -- this will cause the CSS cache to be
+      // populated with an empty string pointing to an empty style object.
+      const selection = $getSelection() as RangeSelection;
+      $getSelectionStyleValueForProperty(selection, 'color', '');
+
+      // Now when we set the style, we should _not_ touch the previously created
+      // empty style object, but create a new one instead.
+      $patchStyleText(selection, {color: 'red'});
+
+      // We can check that result by clearing the style and re-querying it.
+      ($getSelection() as RangeSelection).setStyle('');
+
+      const color = $getSelectionStyleValueForProperty(
+        $getSelection() as RangeSelection,
+        'color',
+        '',
+      );
+      expect(color).toEqual('');
+    });
+  });
 });