lexical icon indicating copy to clipboard operation
lexical copied to clipboard

[WIP] Feature: $config protocol + NodeState registration/flattening

Open etrepum opened this issue 10 months ago • 3 comments

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 the 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,
    {
      state?:
        | (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.

etrepum avatar Feb 27 '25 18:02 etrepum

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

vercel[bot] avatar Feb 27 '25 18:02 vercel[bot]

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

GermanJablo avatar Mar 07 '25 13:03 GermanJablo

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 avatar Mar 07 '25 14:03 etrepum

@zurfyx I will write the flow types when/if there's general buy-in for this API, ideally that step would be the last iteration. Are you happy enough with how it looks for that work to start?

etrepum avatar Jun 14 '25 22:06 etrepum

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?

lilshady avatar Jun 25 '25 01:06 lilshady

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

etrepum avatar Jun 25 '25 01:06 etrepum

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)

lilshady avatar Jun 25 '25 03:06 lilshady

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

etrepum avatar Jun 25 '25 03:06 etrepum

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 avatar Jun 25 '25 03:06 lilshady

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.

lilshady avatar Jun 27 '25 03:06 lilshady

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 avatar Jun 27 '25 05:06 etrepum

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

etrepum avatar Jun 27 '25 05:06 etrepum

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

lilshady avatar Jun 30 '25 05:06 lilshady

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.

etrepum avatar Jun 30 '25 05:06 etrepum

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?

lilshady avatar Jun 30 '25 06:06 lilshady

That should work fine

etrepum avatar Jun 30 '25 06:06 etrepum

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.

lilshady avatar Jun 30 '25 07:06 lilshady

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

etrepum avatar Jun 30 '25 13:06 etrepum

@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.

zurfyx avatar Jun 30 '25 13:06 zurfyx

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 avatar Jun 30 '25 13:06 etrepum

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

etrepum avatar Jun 30 '25 16:06 etrepum

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?

vchirikov avatar Jul 04 '25 07:07 vchirikov

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.

etrepum avatar Jul 04 '25 13:07 etrepum