Skip to content

Feature: $config protocol + NodeState registration/flattening#7258

Merged
etrepum merged 17 commits intofacebook:mainfrom
etrepum:config-rfc
Jun 22, 2025
Merged

Feature: $config protocol + NodeState registration/flattening#7258
etrepum merged 17 commits intofacebook:mainfrom
etrepum:config-rfc

Conversation

@etrepum
Copy link
Copy Markdown
Collaborator

@etrepum etrepum commented Feb 27, 2025

Description

Based on the future needs of NodeState (#7117) - specifically allowing nodes to statically declare their required state - this RFC refactors LexicalNode and the associated internals to remove the need for any static methods, vastly reducing the boilerplate in order to subclass nodes. This is a squashed/rebased version of the original #7189 PR that explored this feature.

The short story is that this sort of thing will let us get better type-level information about nodes. We can't have node.getType() ever give a more specific type than string but we could write a $getType(node) that does infer to a more exact type if we want. Same thing for node.exportJSON() and similar methods.

It also facilitates scrapping all of the node subclass boilerplate, except for this one method, so long as the class has a compatible signature (trivial constructor)

Example of extension and inference capability from the tests
const numberState = createState('numberState', {
  parse: (v) => (typeof v === 'number' ? v : 0),
});
const boolState = createState('boolState', {parse: Boolean});
class StateNode extends TestNode {
  $config() {
    return this.config('state', {
      extends: TestNode,
      stateConfigs: [{flat: true, stateConfig: numberState}, boolState],
    });
  }
  getNumber() {
    return $getState(this, numberState);
  }
  setNumber(valueOrUpdater: StateValueOrUpdater<typeof numberState>): this {
    return $setState(this, numberState, valueOrUpdater);
  }
}

const extraState = createState('extra', {parse: String});
class ExtraStateNode extends StateNode {
  $config() {
    return this.config('extra-state', {
      extends: StateNode,
      stateConfigs: [{flat: true, stateConfig: extraState}],
    });
  }
}

type _TestExtraStateNodeExportJSON = Expect<
  Equal<
    ExtraStateNodeExportJSON,
    {
      $?:
        | (Record<string, unknown> & {
            boolState?: boolean | undefined;
          })
        | undefined;
      version: number;
      type: 'extra-state';
      numberState?: number | undefined;
      extra?: string | undefined;
    }
  >
>;

Closes #7260

How it works

The proposed protocol for declaring nodes would scale down to something like this:

class CustomTextNode extends TextNode {
  $config() {
    return this.config('custom-text', {extends: TextNode});
  }
}

The two method interface is useful for TypeScript reasons, basically. The outer $config is the API and the this.config(…) is a helper method that has the right generics to make it easy to return something with the right shape and type. The types are very delicate, if you don't have all of the annotations just right then the type will simplify into something without information that can be extracted. Basically it is just returning an object like this, but in a way where TypeScript doesn't collapse the type into something simpler (kind of like using as const or satisfies in the right place):

{'custom-text': {type: 'custom-text', extends: TextNode}}

On the LexicalEditor side, during createEditor, CustomTextNode.prototype.$config() is called and can grab the type and any other useful metadata (e.g. a defined transform, registered state, anything else we want to put in there). For compatibility reasons it will also monkeypatch getType, importJSON, and clone static methods onto the node class if they are not already there. It adds a module global variable to inject cloned node keys so it doesn't need to break any compatibility with multi-argument constructors, it only requires that they have a 0-argument form.

I've also put a $create method in there which reduces the boilerplate and increases the efficiency there, assuming that we'd be willing to deprecate with in the node overrides and go directly to withKlass so that we don't have to construct any intermediate garbage when that's configured.

I think this covers most of the breaking-ish changes I would want to make to the lowest level stuff, in exchange for better DX and no loss of performance (probably better, even if just code size improves).

Why it works

The reason why this works when getType(), exportJSON(), etc. don't work is that here we are returning a Record that can "technically" return configuration for any types.

export type BaseStaticNodeConfig = {
  readonly [K in string]?: StaticNodeConfigValue<LexicalNode, string>;
};

When specialized for a specific class you end up with intersection of any type configuration with one specific optional configuration:

export type StaticNodeConfig<
  T extends LexicalNode,
  Type extends string,
> = BaseStaticNodeConfig & {
  readonly [K in Type]?: StaticNodeConfigValue<T, Type>;
};

For example, something like this:

type CustomText$Config = BaseStaticNodeConfig & {
  readonly 'custom-text'?: {/* ... config */}
};

When you subclass custom-text, its $config type will look something like this:

type MoreCustomText$Config = BaseStaticNodeConfig & {
  readonly 'custom-text'?: {/* ... config */};
  readonly 'more-custom-text'?: {/* ... config */};
};

The types here don't violate any subtype constraint because it's all optional, so the subclass is allowed to return {'more-custom-text: {}} and it still satisfies the superclass type.

Future direction

I think this is where we fix the rest of the #6998 things here with some combination of middleware-style functions or automatic super calls (like $afterCloneFrom) instead of methods so that we can control things better without variance violations.

@vercel
Copy link
Copy Markdown

vercel bot commented Feb 27, 2025

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 Jun 16, 2025 4:19am
lexical-playground ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jun 16, 2025 4:19am

@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 Feb 27, 2025
Copy link
Copy Markdown
Member

@zurfyx zurfyx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I quite like this proposal this API ticks the inheritance and type-safe customizability boxes, thank you Bob as usual!

I'm not a fan of the repeated extends but The major downside is the fact that this emphasizes JSON structures as opposed to inheritance. This is a shift from our typical paradigm that relies on class based structure. While arguably this $config function is inheritable, the definition of the all properties within this $config happens declarative as part of the initialization, what would have typically been additional properties on the Node.

To put the code in perspective, this is the API proposed in this PR:

return this.config('listitem', {
      $transform: (node: ListItemNode): void => { ... },
      stateConfigs: [
         {flat: true, stateConfig: numberState},
         boolState],
      ],
});

For the sake of $create I don't think this API is needed but I can see how this help, and I would definitely be keen to move toward this direction to double down on the idea behind $applyNodeReplacement that is currently very specific to that job in particular.

Comment on lines +107 to +111
/**
* An alternative to the internal static transform() method
* that provides better DX
*/
readonly $transform?: (node: T) => void;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this is a good opportunity to do this: #5572
I don't see why transforms could be registered in the node definition but not any other type of listener or command.

Copy link
Copy Markdown
Collaborator Author

@etrepum etrepum Mar 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having transform here is mostly because some nodes won’t work correctly at all without their transform, and that there’s already a static method for it. I would prefer to revisit that after we add extensions, since they also cover that functionality.

nodes should really be minimal and only include functionality relevant to the lexical document, IMO they shouldn’t even have DOM code in them, so would be better to not add more to them today. SSR, headless, React Native, React-only rendering, etc. use cases would be easier if nodes didn’t have extra stuff.

@GermanJablo
Copy link
Copy Markdown
Contributor

So, if I understand correctly, "flat" would be to be able to use this API on existing properties in a backwards compatible way, right?

@etrepum
Copy link
Copy Markdown
Collaborator Author

etrepum commented Mar 7, 2025

Yes “flat” moves a property out of the $ key to the root of the node’s JSON, allowing for backwards/forwards compatibility of the serialization

@etrepum etrepum added this pull request to the merge queue Jun 22, 2025
Merged via the queue into facebook:main with commit 42c8372 Jun 22, 2025
39 checks passed
@lilshady
Copy link
Copy Markdown
Contributor

lilshady commented Jun 25, 2025

Hey @etrepum @zurfyx I have a question regarding this change.
Here we have an invariant that all nodes have to implement getType.
Along with this PR, do we have to change this invariant? e.g. provide a default implementation for it?

@etrepum
Copy link
Copy Markdown
Collaborator Author

etrepum commented Jun 25, 2025

The data returned by $config is used to implement a boilerplate static getType for backwards compatibility when the node is registered with an editor.

@lilshady
Copy link
Copy Markdown
Contributor

lilshady commented Jun 25, 2025

The data returned by $config is used to implement a boilerplate static getType for backwards compatibility when the node is registered with an editor.

Yes, but I see in the getStaticNodeConfig, we also call the getType?
Could it be that getType is being invoked before the injection process?
Correct me if I am wrong: My understanding is that we're using getType to retrieve the associated nodeConfig(but it seems that getType has been removed). Then injecting the getType implementation with () => ownNodeType.

Context is that I do see some tests are failing at meta internally, the stack trace is like:

 at formatDevErrorMessage (html/shared/lexical/Lexical.dev.js:32:7)
      at Function.getType (html/shared/lexical/Lexical.dev.js:3223:1)
      at getStaticNodeConfig (html/shared/lexical/Lexical.dev.js:12202:57)
      at _loop2 (html/shared/lexical/Lexical.dev.js:10008:1)
      at Object.createEditor (html/shared/lexical/Lexical.dev.js:10056:59)

@etrepum
Copy link
Copy Markdown
Collaborator Author

etrepum commented Jun 25, 2025

getType is only called there when the node class directly implements it (the hasOwn check) and it is not one of the three abstract base classes (DecoratorNode, ElementNode, and LexicalNode). I can't really debug this without seeing the full context, possibly the haste compiler at FB is doing something non-standard when defining classes in that environment? This code is assuming that classes work according to the ES specification.

class Base { static foo() { return 'base'; } }
class Child extends Base { }
> Child.foo()
'base'
> Object.hasOwn(Child, 'foo')
false
> Object.hasOwn(Base, 'foo')
true

@lilshady
Copy link
Copy Markdown
Contributor

getType is only called there when the node class directly implements it (the hasOwn check) and it is not one of the three abstract base classes (DecoratorNode, ElementNode, and LexicalNode). I can't really debug this without seeing the full context, possibly the haste compiler at FB is doing something non-standard when defining classes in that environment? This code is assuming that classes work according to the ES specification.

class Base { static foo() { return 'base'; } }
class Child extends Base { }
> Child.foo()
'base'
> Object.hasOwn(Child, 'foo')
false
> Object.hasOwn(Base, 'foo')
true

Thanks! Let me debug it further.

@lilshady
Copy link
Copy Markdown
Contributor

getType is only called there when the node class directly implements it (the hasOwn check) and it is not one of the three abstract base classes (DecoratorNode, ElementNode, and LexicalNode). I can't really debug this without seeing the full context, possibly the haste compiler at FB is doing something non-standard when defining classes in that environment? This code is assuming that classes work according to the ES specification.

class Base { static foo() { return 'base'; } }
class Child extends Base { }
> Child.foo()
'base'
> Object.hasOwn(Child, 'foo')
false
> Object.hasOwn(Base, 'foo')
true

Yes, the root cause is that hasOwn returns True instead of False in this case. Still figuring it out how we can address this issue at meta.

@etrepum
Copy link
Copy Markdown
Collaborator Author

etrepum commented Jun 27, 2025

Hard to say what the right workaround would be without knowing exactly how far Meta's implementation strays from the standard but if it respects the prototype chain correctly then something like this would likely work:

export function hasOwn(o: object, k: string): boolean {
  return (
    Object.prototype.hasOwnProperty.call(o, k) &&
    // workaround for meta
    o[k as keyof typeof o] !== Object.getPrototypeOf(o)[k]
  );
}

@etrepum
Copy link
Copy Markdown
Collaborator Author

etrepum commented Jun 27, 2025

Of course this is only a valid workaround in the limited context for which this is used, so maybe it should be renamed to hasOwnMethodOverride or something like that so that it's clear that it's not a general ponyfill for Object.hasOwn

@lilshady
Copy link
Copy Markdown
Contributor

Hard to say what the right workaround would be without knowing exactly how far Meta's implementation strays from the standard but if it respects the prototype chain correctly then something like this would likely work:

export function hasOwn(o: object, k: string): boolean {
  return (
    Object.prototype.hasOwnProperty.call(o, k) &&
    // workaround for meta
    o[k as keyof typeof o] !== Object.getPrototypeOf(o)[k]
  );
}

Thank you for providing this workaround. However, after further debugging, I've found that it doesn't correctly respect the prototype chain. Specifically, Object.getPrototypeOf(o)[k] returns undefined at meta(I guess some internal optimizations involved here ). I'm considering an alternative approach.

export function hasOwnMethodOverride(o: object, k: string): boolean {
  return (
    Object.prototype.hasOwnProperty.call(o, k) &&
    // workaround for meta
   && o[k as keyof typeof o] !== LexicalNode[k]
  );
}

And another issue is

 if (__DEV__) {
        invariant(
          klass.length === 0,
          '%s (type %s) must implement a static clone method since its constructor has %s required arguments (expecting 0). Use an explicit default in the first argument of your constructor(prop: T=X, nodeKey?: NodeKey).',
          klass.name,
          ownNodeType,
          String(klass.length),
        );
      }

At meta, it doesn't ignore default values and optional arguments, resulting in incorrect counts (e.g., ListNode returns 3 instead of 0). I'm considering downgrading this to a warning, although it's not ideal as it deviates from the fail fast principle.
If you have any suggestions or alternative approaches, please let me know. Thank you!
cc @zurfyx

@etrepum
Copy link
Copy Markdown
Collaborator Author

etrepum commented Jun 30, 2025

Your implementation of hasOwnMethod only works for static methods. The implementation uses hasOwn for both instance and static methods. Would need a separate workaround for instance methods.

I don't know if there's a good solution for the clone warning. If I couldn't fix meta's compilers/optimizations to work according to spec I would just turn the warning off for meta and do that check at lint time rather than runtime. Should be straightforward to implement a lint to check that all constructor arguments are optional or have defaults. The warning is useful for open source because not everyone uses the lint and it saves us from getting bug reports about user error.

@lilshady
Copy link
Copy Markdown
Contributor

How about we have two separate methods, (For instance method, I believe meta behaves the same):

function hasOwnStaticMethod(o, k) {
  return (Object.prototype.hasOwnProperty.call(o, k) && o[k as keyof typeof o] !== LexicalNode[k]);
}

function hasOwnInstanceMethod(o, k) {
  return Object.prototype.hasOwnProperty.call(o, k);
}

only for decorate, we will use hasOwnInstanceMethod, for other places we will use hasOwnStaticMethod?

@etrepum
Copy link
Copy Markdown
Collaborator Author

etrepum commented Jun 30, 2025

That should work fine

@lilshady
Copy link
Copy Markdown
Contributor

Regarding the clone warning, I debugged for a while and I don't think I can change the behavior at Meta.
Since it's an invariant in the dev environment, it will cause the entire page to break instead of displaying a warning or alert that can be turned off.
I know changing it to console.warn instead of an invariant seems a bad idea, but can't figure out another solution at this moment.

@etrepum
Copy link
Copy Markdown
Collaborator Author

etrepum commented Jun 30, 2025

Why not add a variable like __META_ONLY__ that you can use to isolate workarounds for the bugs in Meta's tool chain?

@zurfyx
Copy link
Copy Markdown
Member

zurfyx commented Jun 30, 2025

@etrepum for reference, the root of the problem appears to be our use of babel/helpers/inheritLoose (which is applied automatically).

This function is, as the name suggests, is intentionally loose and bubbles up properties that otherwise would be discovered via the prototype chain.

i.e.

class Base { static foo() { return 'base'; } }
class Child extends Base { }
Base=function(){function Base(){}Base.
foo=function foo(){
return'base';
};return Base;}();var Child=function(_Base){babelHelpers.inheritsLoose(Child,_Base);function Child(){return _Base.apply(this,arguments)||this;}return Child;}(Base);

A flag can work, at the end of the day we already have our own build script. I wonder if there's an alternative besides having to fork and maintain two versions of the logic.

@etrepum
Copy link
Copy Markdown
Collaborator Author

etrepum commented Jun 30, 2025

The inheritance chain issue is sufficiently solved by checking value equality with the LexicalNode or LexicalNode.prototype directly. It won't handle warning in all of the edge cases where you're subclassing a subclass that isn't compliant with the newer protocols but that's not really worse than today.

The clone invariant is something that can probably only be handled with a variable injected by the build. There's some chance that it can be detected at runtime by including a sample of a constructor with default args and measuring its length but that will only work when lexical and the code using lexical are compiled in the same way.

@etrepum
Copy link
Copy Markdown
Collaborator Author

etrepum commented Jun 30, 2025

I've created a PR #7659 that may address these issues

@vchirikov
Copy link
Copy Markdown

this api looks like hooks in react and do more confusion vs clean class concept 😕. Also by the docs $config uses NodeState API which This API is experimental according to the docs.

Will you support old Legacy static methods and properties way in the future or it will be completely removed in next versions?

@etrepum
Copy link
Copy Markdown
Collaborator Author

etrepum commented Jul 4, 2025

The static methods will continue to be supported for the foreseeable future.

$config does not depend on NodeState, it provides extra functionality for NodeState that is not possible with the static methods.

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

Development

Successfully merging this pull request may close these issues.

Feature: Pre-registration/optional flattening of required NodeState configurations

6 participants