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

Feature: Add runtime invariants for methods with unsound types: updateDOM, updateFromJSON, afterCloneFrom #6998

Open
etrepum opened this issue Dec 26, 2024 · 0 comments
Labels
enhancement Improvement over existing feature

Comments

@etrepum
Copy link
Collaborator

etrepum commented Dec 26, 2024

Description

Follow-up to #6970 and #6992. There's basically a variance violation in these methods, they are only correct when this is not up-casted to a base class (these methods violate LSP). There isn't really a straightforward typing rule we can use to prove this that has reasonable DX, but we can add a simple invariant to prove that there isn't a casting violation.

class ExtendedTextNode extends TextNode {
  afterCloneFrom(prevNode: this): void {
    super.afterCloneFrom(prevNode)
      .setNewProperty(this.getNewProperty());
  }
}

// This will throw an error about a missing `getNewProperty` method at runtime,
// but passes type checks due to the up-cast to TextNode
const extendedTextNode: TextNode = $createExtendedTextNode();
extendedTextNode.afterCloneFrom($createTextNode());

We can add an invariant to check this, it still won't get caught by the type checker, but will provide for better runtime errors:

class ExtendedTextNode extends TextNode {
  afterCloneFrom(prevNode: this): void {
    invariant(prevNode instanceof this.constructor, 'prevNode must be an instance of ExtendedTextNode');
    super.afterCloneFrom(prevNode)
      .setNewProperty(this.getNewProperty());
  }
}

Affected methods with this soundness violation:

  • afterCloneFrom
  • updateDOM

updateFromJSON has a similar variance violation, but it can't really be detected so simply, since the serializedNode argument is not an instance at all. We could provide a function to check the constructor type chain though.

  • updateFromJSON
function $checkSerializedType(node: LexicalNode, serializedNode: SerializedLexicalNode) {
  const nodeKlass = node.constructor;
  const registeredNode = $getEditor()._nodes.get(serializedNode.type);
  // the JSON can be from the exact type, or a more specific type
  invariant(
    registeredNode !== undefined && (
      registeredNode.klass === nodeKlass ||
      nodeKlass.isPrototypeOf(registeredNode.klass)
    ),
    '$checkSerializedType node of type %s can not be deserialized from JSON of type %s',
    nodeKlass.getType(),
    serializedNode.type,
  );
}

Alternative

An alternative might be to change the protocols entirely, maybe with some sort of static method called for each class when it's added to the editor and to plumb these calls through the editor in a provably type-safe way, but that's not backwards compatible. Something like this might work:

export interface UpdateDOMFunc<T extends LexicalNode> {
  (node: T, prevNode: T, dom: ReturnType<T['createDOM']>, config: EditorConfig, next: () => boolean): boolean;
}
export interface UpdateFromJSONFunc<T extends LexicalNode> {
  // None of this is truly type safe, these are just casts,
  // the correct solution is to parse
  (node: T, json: ReturnType<T['exportJSON']>, next: (json?: ReturnType<T['exportJSON']>) => T): T;
}
export interface AfterCloneFromFunc<T extends LexicalNode> {
  // No need for middleware here, we can just force all of them to be called in order
  (node: T, prevNode: T): void;
}

class NodeRegistration<T extends LexicalNode> {
  // This could be used instead of static clone and static importJSON
  registerCreateNode($createNode: () => T): void;
  registerAfterCloneFrom($afterCloneFrom: AfterCloneFromFunc<T>): void;
  registerUpdateDOM($updateDOM: UpdateDOMFunc<T>): void;
  registerUpdateFromJSON($updateFromJSON: UpdateFromJSONFunc<T>): void;
}

class TextNode extends LexicalNode {
  static registerNode(reg: NodeRegistration<TextNode>): void {
    reg.registerCreateNode($createTextNode);
    reg.registerUpdateDOM((node, prevNode, dom, config, next) => {
      // this is the equivalent of the super call
      // depending on the node this might be called before, after,
      // or not at all based on the requirements of this subclass
      if (next()) {
        return true;
      }
      // … some logic specific to this node
      return false;
    });
  }
}

I'm sure we could come up with a backwards compatible transition from one to the other, if we leave the methods somewhere on the base classes for a transitional period so that super calls work for nodes that do not support the static registration protocol.

Impact

This will improve error messages for incorrect usage of the library in places where the type checker can't help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement Improvement over existing feature
Projects
None yet
Development

No branches or pull requests

1 participant