Skip to content

[lexical][lexical-extension][*] Feature: Lexical Extension#7706

Merged
etrepum merged 98 commits intofacebook:mainfrom
etrepum:extension
Sep 24, 2025
Merged

[lexical][lexical-extension][*] Feature: Lexical Extension#7706
etrepum merged 98 commits intofacebook:mainfrom
etrepum:extension

Conversation

@etrepum
Copy link
Copy Markdown
Collaborator

@etrepum etrepum commented Jul 20, 2025

Breaking Changes

DecoratorNode

  • Removed type requirement & warning for DecoratorNode to implement decorate()
  • Widens type for decorate(): T to be decorate(): null | T as that's always how it worked in practice - the generic type here is unsafe and wrong anyway (e.g. $isDecoratorNode is a cast to any type T)

Description

Lexical plug-ins don't do what people want them to do. They are just an awkward convention for React components that involve a lot of boilerplate around useEffect. It encourages people to write unnecessarily React-dependent code and still manually manage any configuration. The kind of extension mechanism that Lexical needs combines both editor configuration and/or runtime behavior in a framework agnostic manner, but still allow for framework-specific behavior when useful.

This PR is based on the Lexical Builder prototype.

Requirements

  • Extensions provide merged configuration (e.g. nodes, theme properties, initial editor state, etc.)
  • Extensions can depend on other extensions (directly or as a peer)
  • Extensions can register behavior after the editor is created (transforms, command listeners, etc.)
  • Extensions can provide output to be used by the application or other extensions (commands, runtime data, etc)
  • Extensions should be able to integrate with frameworks, e.g. provide DecoratorNodes that require React
  • Extensions should be able to do what legacy React Plug-ins do, so it should be straightforward to allow an extension to be reconfigured on the fly to some extent

Status

Lexical Extension Docs

This PR is in a draft state that currently implements:

  • lexical (src/extension-core): the types and utility functions required to define extensions
  • @lexical/extension: the mechanism to build an editor from extensions, and a few extensions that are essentially only available in @lexical/react as features of LexicalComposer or separate plug-ins (AutoFocusExtension, TabExtension, etc.) - uses @preact/signals-core to make it feasible to do what's done with the react hooks outside of that lifecycle
  • @lexical/link - LinkExtension and AutoLinkExtension
  • @lexical/hashtag - HashtagExtension
  • @lexical/history - HistoryExtension and SharedHistoryExtension
  • @lexical/dragon - DragonExtension
  • @lexical/plain-text - PlainTextExtension
  • @lexical/rich-text - RichTextExtension
  • @lexical/react - ReactProviderExtension, ReactPluginHost, etc.
  • @lexical/table - TableExtension
  • @lexical/playground - A minimal migration from LexicalComposer to LexicalExtensionComposer

Examples:

Not in Scope

This PR does not implement extensions for collab, markdown, Prism code highlighting, and some other functionality available as React plug-ins.

  • Collab is getting a rewrite, not a good target for support
  • markdown is a complicated mess and making it extensible should probably also be a refactor with backwards incompatible changes
  • Shiki code highlighting is available, I think Prism code highlighting should be deprecated at some point
  • Other plug-ins can be implemented additively, and ideally without a React dependency when feasible. The goal of this PR is to make sure the API makes sense as much as possible in a first pass, and to provide the foundation for future work.

Test plan

  • The minimal playground migration shows the substitutability of LexicalExtensionComposer for LexicalComposer
  • Unit tests for @lexical/extension (minimal)

@vercel
Copy link
Copy Markdown

vercel bot commented Jul 20, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
lexical Ready Ready Preview Comment Sep 21, 2025 5:41pm
lexical-playground Ready Ready Preview Comment Sep 21, 2025 5:41pm

@meta-cla meta-cla 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 Jul 20, 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.

This is a brilliant piece of work, particularly the API design, there's been many ideas around extensions/bundling plugins but this one is certainly the most complete that appears to ticks the most boxes. Thank you for writing this comprehensive multi-page documentation!

Admittedly, there's a lot going on here and didn't read line by line, but I wanted to share some high-level feedback to understand better.

configExtension(EmojiExtension, {
  emojiClass: "emoji-node",
  emojiLoadedClass: "emoji-node-loaded",
  emojiBaseUrl: "/assets/emoji",
}),

When is the configExtension required? How do users know? TS works well either way.


From the docs: No compile-time support for dependency resolution

Can we actually do this with TypeScript? Or would it require some build script?

I don't know if it's releated, but I still foresee the possibility of conflicting dependencies, especially in the case of 2 third parties that haven't seen in other. I recall we discussed this in the past, and I'm not sure if it's worrysome or a problem worth investing in further.


How much did you get to test Flow? Is it a best effort? Since Meta is probably one of the few users, I don't think this is a blocker and we can test, and report/fix accordingly.

I'm stamping already since this is an idea that has been floating for some time and discussed in some sessions but I would value if others had a chance to read over it (at least the docs) and provide some feedback.

Comment on lines +90 to +92
// inlineImage: 'inline-editor-image',
// layoutContainer: 'PlaygroundEditorTheme__layoutContainer',
// layoutItem: 'PlaygroundEditorTheme__layoutItem',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

All of the commented, are not implemented but are listed for reference?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, those are classes that I did not implement in the tailwind theme. Most of them are playground only and don't really make sense in something designed to be used elsewhere. The goal of the tailwind extension is to have a starter theme that works straight out of the box for many people.

Comment on lines +112 to +116
declare export var AutoFocusExtension: LexicalExtension<AutoFocusConfig, "@lexical/extension/AutoFocus", NamedSignalsOutput<AutoFocusConfig>, void>;
export type ClearEditorConfig = {
$onClear: () => void;
}
declare export var ClearEditorExtension: LexicalExtension<ClearEditorConfig, "@lexical/extension/ClearEditor", NamedSignalsOutput<ClearEditorConfig>, void>;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is it intended that these load from the same module? Is the thinking process that they're small and common enough to be bundled?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Small, common enough to be bundled, and tree shaking should eliminate it if you don't use it (at least with the standard toolchains like vite/rollup/esbuild). We could move definitions of common extensions to a separate module from the extension runtime but I think that's just busywork if you assume that tree shaking works.

};
const app = useMemo(
() =>
defineExtension({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Given the popularity of the playground to copy-paste code, should we consider expanding this to have more dependencies plugins?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Absolutely, I wanted to do that separately to keep the scope down and also to show that you can migrate to lexical extension incrementally

@etrepum
Copy link
Copy Markdown
Collaborator Author

etrepum commented Sep 22, 2025

This is a brilliant piece of work, particularly the API design, there's been many ideas around extensions/bundling plugins but this one is certainly the most complete that appears to ticks the most boxes. Thank you for writing this comprehensive multi-page documentation!

Thank you! There's more to write, but I really want to get something out there sooner than later and questions will drive where effort is spend on docs.

Admittedly, there's a lot going on here and didn't read line by line, but I wanted to share some high-level feedback to understand better.

configExtension(EmojiExtension, {
  emojiClass: "emoji-node",
  emojiLoadedClass: "emoji-node-loaded",
  emojiBaseUrl: "/assets/emoji",
}),

When is the configExtension required? How do users know? TS works well either way.

configExtension is only useful when you want to provide configuration overrides for the extension you're depending on. It's a convenience function that simply returns its arguments as an array, [EmojiExtension, {emojiClass: "emoji-code", …}] in this case. It's better to use a function for type inference because it's a non-empty tuple of extension followed by zero or more configuration overrides rather than some homogenous array.

/**
 * A tuple of `[extension, ...configOverrides]`
 */
export type NormalizedLexicalExtensionArgument<
  Config extends ExtensionConfigBase,
  Name extends string,
  Output,
  Init,
> = [LexicalExtension<Config, Name, Output, Init>, ...Partial<Config>[]];export function configExtension<
  Config extends ExtensionConfigBase,
  Name extends string,
  Output,
  Init,
>(
  ...args: NormalizedLexicalExtensionArgument<Config, Name, Output, Init>
): NormalizedLexicalExtensionArgument<Config, Name, Output, Init> {
  return args;
}

From the docs: No compile-time support for dependency resolution

Can we actually do this with TypeScript? Or would it require some build script?

It's possible but not really worth it because there are already several ways to get a very early runtime error while the editor is being constructed when there are conflicting names or a name matches something in a conflictWith from another extension.

It would really just make type checking slower, more fragile, harder to maintain, etc. for very little benefit.

I don't know if it's releated, but I still foresee the possibility of conflicting dependencies, especially in the case of 2 third parties that haven't seen in other. I recall we discussed this in the past, and I'm not sure if it's worrysome or a problem worth investing in further.

The recommendation of using scoped names should mostly eliminate the third party problem, two parties shouldn't publish extensions with the same scoped name. Realistically though there are much more insidious ways for extensions to conflict that we really can't do anything about (e.g. handling the same DOM events or commands in incompatible ways, the simplest of which would be that one handles it completely and prevents the other extension from seeing the event or command it needs to see).

How much did you get to test Flow? Is it a best effort? Since Meta is probably one of the few users, I don't think this is a blocker and we can test, and report/fix accordingly.

npm run flow is the only testing I did. I used npm run lint-flow to try and catch most of the new types, although I might've missed some. I did write them pretty much by hand for better or for worse, mostly by pasting the explicit or inferred typescript types and making the superficial syntax changes for flow compatibility. Adding more flow coverage should be pretty straightforward incremental work.

I'm stamping already since this is an idea that has been floating for some time and discussed in some sessions but I would value if others had a chance to read over it (at least the docs) and provide some feedback.

Would be great to get some more feedback! I don't think we'll hear much from non-maintainers until after it's released, which would be nice to do sooner than later. It would also let me clean up some of the hacks that the examples are using to reference packages that don't exist on npm.

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.

2 participants