Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Breaking Change][lexical] Feature: Add updateFromJSON and move more textFormat/textStyle to ElementNode #6970

Merged

Conversation

etrepum
Copy link
Collaborator

@etrepum etrepum commented Dec 16, 2024

Description

One of the biggest pain points and error-prone parts of extending lexical is the amount of boilerplate involved in subclassing nodes. In particular the worst of it is the static importJSON method that must be implemented. We can eventually make the boilerplate go away with some targeted refactoring.

The majority of this PR implements updateFromJSON which is the importJSON analog to the afterCloneFrom for clone from #6505. Basically the key concept here is to move all of the logic, other than the constructor itself (with as many defaults as possible), into an instance method, so that super can be called to inherit all of the base class logic.

More documentation has been added related to serialization best practices, including a recommendation not to use the version property as it does not compose well and is generally just ignored and/or redundant.

Before:

class BaseNode {
  static importJSON(serializedNode: SerializedBaseNode): BaseNode {
    const node = $createBaseNode();
    /* … a whole bunch of boilerplate that must be copied and pasted in subclasses */
    return node;
  }
}

class ExtendedNode extends BaseNode {
  static importJSON(serializedNode: SerializedExtendedNode): ExtendedNode {
    const node = $createExtendedNode();
    /* … a whole bunch of boilerplate that is copied from BaseClass */
    /* … some extra stuff specific to SerializedExtendedNode */
    return node;
  }
}

After:

class BaseNode {
  // We can eventually eliminate this boilerplate altogether, but makes sense in the short term
  static importJSON(serializedNode: SerializedBaseNode): BaseNode {
    return $createBaseNode().updateFromJSON(serializedNode);
  }

  updateFromJSON(serializedNode: Omit<SerializedBaseNode, 'type' | 'version'>): this {
    // for the true base cases of TextNode and ElementNode this will not have a super call
    const node = super.updateFromJSON(serializedNode);
    /* … the previous boilerplate, that can now be inherited */
    return node;
  }
}

class ExtendedNode extends BaseNode {
  // We can eventually eliminate this boilerplate altogether, but makes sense in the short term
  static importJSON(serializedNode: SerializedExtendedNode): ExtendedNode {
    return $createExtendedNode().updateFromJSON(serializedNode);
  }

  // this is now *optional* and can be implemented when extra setters are needed
  updateFromJSON(serializedNode: Omit<SerializedExtendedNode, 'type' | 'version'>): this {
    const node = super.updateFromJSON(serializedNode);
    /* … some extra stuff specific to SerializedExtendedNode */
    return node;
  }
}

The motivation for this was to move textFormat and textStyle to ElementNode, which to do properly would require also updating serialization everywhere, so it made sense to eliminate the boilerplate at the same time.

Note that the types here are not quite sound and are probably not possible to type in Flow, for the same class of reason that updateDOM and afterCloneFrom are also not sound. When a subclass implements this method with a more specific type, it's no longer sound to upcast it to the base class and call the method with the schema intended for the base class. I'm sure it would be possible to come up with a scheme that is sound, but I think it would require a large DX trade-off one way or another (such as using a new method name for each class like updateFromTextNodeJSON, moving it to a static method or exported function that must be called fully qualified, or forcing the updateFromJSON implementations to parse the JSON from something like Record<unknown, unknown> - which would be much safer but also very tedious with a runtime penalty). See #6998 for more information about this and some ideas for runtime checks.

Closes #6949
Closes #6476
Closes #5708 (although it does not implement a perfect solution to persist styles at arbitrary positions, it's better than status quo)

Might get closer to fixing #6583

Related: #3931

Upgrade Notes

This change adds optional textFormat and textStyle properties to SerializedElementNode. If you have existing classes with those properties it could create a namespace clash that you will have to resolve one way or another.

TextNode and ElementNode subclasses should be updated to call the updateFromJSON(serializedNode) method from their static importJSON methods. If they don't, they won't support this new functionality, and will have to continue copy and pasting the super implementation of importJSON for correct behavior if the base class ever changes in the future.

You should consider dropping usage of the version field.

Test plan

  • Existing unit and e2e tests all pass (serialization tests and tests of the exact instance representation were modified)
  • New e2e test for ListItemNode behavior
  • Other tests and code were refactored to use this new facility, so it's well covered by everything else too

Before

Styles were not preserved when creating a new ListItem

After

Styles are preserved when creating a new ListItem (including e2e test)

Copy link

vercel bot commented Dec 16, 2024

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
lexical ✅ Ready (Inspect) Visit Preview 💬 Add feedback Dec 26, 2024 4:54am
lexical-playground ✅ Ready (Inspect) Visit Preview 💬 Add feedback Dec 26, 2024 4:54am

@facebook-github-bot facebook-github-bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Dec 16, 2024
Copy link

github-actions bot commented Dec 16, 2024

size-limit report 📦

Path Size
lexical - cjs 31.21 KB (0%)
lexical - esm 31.1 KB (0%)
@lexical/rich-text - cjs 40.31 KB (0%)
@lexical/rich-text - esm 33.01 KB (0%)
@lexical/plain-text - cjs 38.85 KB (0%)
@lexical/plain-text - esm 30.22 KB (0%)
@lexical/react - cjs 42.13 KB (0%)
@lexical/react - esm 34.26 KB (0%)

@zurfyx
Copy link
Member

zurfyx commented Dec 17, 2024

Thanks Bob, the static import has indeed been a problem for a very long time, we had this similar issue for clone where we hardcoded some properties just to make it easier for the common cases, I believe you fixed this one.

I like the solution you've presented and how it works with types!

I'm not so sure about the moving textFormat and textStyle, is this really what need? When is format relevant for an arbitrary element?

On a separate note, and beyond this PR, I'm somewhat concerned about the growing number of methods in Nodes. While it's easy to extend and for the most part they're self-explanatory it takes a fair amount of time to configure a new Node and understand what the relevant methods are. I'd be keen take this offline and discuss potential alternatives, including higher-level abstractions and codegen.

@etrepum
Copy link
Collaborator Author

etrepum commented Dec 17, 2024

The textFormat and textStyle is only relevant to an element because we don't have another reliable way to preserve it when there isn't a TextNode present. Previously we were doing this only for ParagraphNode, but it probably makes sense for any element that can contain a TextNode (such as ListItemNode in this particular use case). It's not used directly on the element for any purpose, it's really just storage for the selection. Possibly a future direction might be to phase it out and insert some kind of format preservation node (a special empty TextNode that doesn't get normalized away or maybe even rendered, for example).

With regard to methods, the only real reason we need to add methods here is backwards compatibility. If we didn't have that constraint we could get rid of static clone and static importFromJSON and replace them with only afterCloneFrom and updateFromJSON which are both optional and only needed if you are adding state to the node. exportJSON could also be optional if we had the base classes use type: this.getType(). Implementing the property overrides for the collection of afterCloneFrom, updateFromJSON, exportJSON (and all of the property getters and setters) could be done with code gen at runtime (e.g. with something like a decorator) or compile time possibly with the assistance of a fourth method that just listed the properties that need to be carried around (but of course overriding could also customize this).

I think the next step here is to allow classes to drop static clone. The strategy here would be to deprecate the nodeKey argument and allow $setNodeKey to have some module global state for what the next nodeKey is that is set and change the implementation of $cloneWithProperties accordingly

  // before
  const constructor = latestNode.constructor;
  const mutableNode = constructor.clone(latestNode) as T;
  mutableNode.afterCloneFrom(latestNode);
  // after
  const constructor = latestNode.constructor;
  let mutableNode: T;
  if (Object.hasOwn(constructor, 'clone')) {
    // backwards compatibility
    mutableNode = constructor.clone() as T;
  } else {
    $setNextNodeKey(latestNode.getKey());
    // this does require all constructors to have a 0-arg form and a correct afterCloneFrom
    mutableNode = new constructor() as T;
    // if __DEV__ make sure the key was consumed by $setNodeKey(this, key);
  }
  mutableNode.afterCloneFrom(latestNode);

Instead of checking that these static methods exist we can instead have a lint that looks for extra properties on a node and if they exist then expect an afterCloneFrom, updateFromJSON, and exportJSON to be defined.

@etrepum
Copy link
Collaborator Author

etrepum commented Dec 23, 2024

The exportJSON refactor is implemented in #6983

@etrepum etrepum added this pull request to the merge queue Jan 1, 2025
Merged via the queue into facebook:main with commit 7c21d4f Jan 1, 2025
40 checks passed
@etrepum etrepum deleted the updateFromJSON-element-node-text-format-style branch January 5, 2025 17:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR
Projects
None yet
3 participants