Feature: $config protocol + NodeState registration/flattening#7258
Feature: $config protocol + NodeState registration/flattening#7258etrepum merged 17 commits intofacebook:mainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for Git ↗︎
|
zurfyx
left a comment
There was a problem hiding this comment.
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.
| /** | ||
| * An alternative to the internal static transform() method | ||
| * that provides better DX | ||
| */ | ||
| readonly $transform?: (node: T) => void; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
So, if I understand correctly, "flat" would be to be able to use this API on existing properties in a backwards compatible way, right? |
|
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 |
|
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 Context is that I do see some tests are failing at meta internally, the stack trace is like: |
|
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 { } |
Thanks! Let me debug it further. |
Yes, the root cause is that |
|
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]
);
} |
|
Of course this is only a valid workaround in the limited context for which this is used, so maybe it should be renamed to |
Thank you for providing this workaround. However, after further debugging, I've found that it doesn't correctly respect the prototype chain. Specifically,
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 |
|
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. |
|
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 |
|
That should work fine |
|
Regarding the clone warning, I debugged for a while and I don't think I can change the behavior at Meta. |
|
Why not add a variable like |
|
@etrepum for reference, the root of the problem appears to be our use of 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. 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. |
|
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. |
|
I've created a PR #7659 that may address these issues |
|
this api looks like hooks in react and do more confusion vs clean class concept 😕. Also by the docs $config uses NodeState API which Will you support old |
|
The static methods will continue to be supported for the foreseeable future.
|
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 thanstringbut we could write a$getType(node)that does infer to a more exact type if we want. Same thing fornode.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
Closes #7260
How it works
The proposed protocol for declaring nodes would scale down to something like this:
The two method interface is useful for TypeScript reasons, basically. The outer
$configis the API and thethis.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 usingas constorsatisfiesin the right place):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 monkeypatchgetType,importJSON, andclonestatic 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
$createmethod in there which reduces the boilerplate and increases the efficiency there, assuming that we'd be willing to deprecatewithin the node overrides and go directly towithKlassso 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.
When specialized for a specific class you end up with intersection of any type configuration with one specific optional configuration:
For example, something like this:
When you subclass custom-text, its $config type will look something like this:
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.