@lexical/eslint-plugin
This ESLint plugin enforces the Lexical $function convention.
ESLint Compatibility: This plugin supports ESLint 7, 8, 9, and 10+. Both legacy (.eslintrc) and flat config (eslint.config.js) formats are supported.
Installation
Assuming you already have ESLint installed, run:
npm install @lexical/eslint-plugin --save-dev
ESLint 9+ (Flat Config)
If you're using ESLint 9 or later with the new flat config format (required in ESLint 10+), add this to your eslint.config.js:
import lexical from '@lexical/eslint-plugin';
export default [
// ... other configs
lexical.configs['flat/recommended']
];
ESLint 7-8 (Legacy Config)
For ESLint 7 or 8 with the legacy .eslintrc format, extend the recommended config:
{
"extends": [
// ...
"plugin:@lexical/legacy-recommended"
]
}
Note: The
recommendedandallconfigs are currently aliases tolegacy-recommendedandlegacy-all. For compatibility, both presets enable only@lexical/rules-of-lexical;alldoes not mean every exported rule.allandrecommendedwill be migrated to flat config in a future version.
Custom Configuration
ESLint 9+ (Flat Config)
import lexical from '@lexical/eslint-plugin';
export default [
{
plugins: {
'@lexical': lexical
},
rules: {
'@lexical/rules-of-lexical': 'error'
}
}
];
ESLint 7-8 (Legacy Config)
{
"plugins": [
// ...
"@lexical"
],
"rules": {
// ...
"@lexical/rules-of-lexical": "error"
}
}
Preventing nested editor updates
@lexical/no-nested-editor-updates reports editor.update() calls that are
already inside an update context for the same editor. This includes the
callbacks passed to editor.update, editor.read, editor.registerCommand,
and editor.registerNodeTransform when the outer and inner receiver resolve to
the same identifier binding.
The rule also reports an editor.update() directly inside a $function. In
that case the diagnostic explains both valid fixes: remove the update wrapper
if the function uses the active editor context, or remove the $ prefix if the
function intentionally owns the update.
The rule is opt-in because enabling it can expose existing nested updates. Add
it to the rules object in either configuration format:
const rules = {
'@lexical/no-nested-editor-updates': 'error'
};
For example, this command listener schedules a nested update:
editor.registerCommand(
REMOVE_NODE_COMMAND,
() => {
editor.update(() => {
$getSelection().removeText();
});
return true;
},
COMMAND_PRIORITY_EDITOR,
);
The listener already has an implicit update context, so the callback should run directly:
editor.registerCommand(
REMOVE_NODE_COMMAND,
() => {
$getSelection().removeText();
return true;
},
COMMAND_PRIORITY_EDITOR,
);
An update to a different editor is valid and is not reported:
editor.registerCommand(
SYNC_CHILD_COMMAND,
() => {
childEditor.update(() => {
$getRoot().clear();
});
return true;
},
COMMAND_PRIORITY_EDITOR,
);
Some nested updates intentionally use options such as {discrete: true} to
change commit timing. Suppress those specific calls with an ESLint disable
comment rather than removing the wrapper.
To avoid matching unrelated APIs that also have an update method, editor
expressions must end in editor, ignoring case. This recognizes names such as
editor, childEditor, props.editor, and $getEditor(), but callback checks
report only when the inner expression can be tied to the outer expression.
Inside a $function, the default active-editor names are editor and
$getEditor(); differently named receivers are left alone because they may
refer to another editor. Additional names or patterns can be configured with
isEditor. The isDollarFunction option extends the default /^\$[a-z_]/
function-name matcher:
const rules = {
'@lexical/no-nested-editor-updates': [
'error',
{
isDollarFunction: '^INTERNAL_\\$',
isEditor: '^lexicalInstance$',
},
],
};
The analysis is deliberately local. It does not follow callbacks passed by
reference, aliases between differently named editor variables, or updates
inside another nested callback such as setTimeout(() => editor.update(...)).
These limits avoid suggestions that could change behavior when the editor or
execution context cannot be proven statically.
Advanced configuration
Most of the heuristics in @lexical/rules-of-lexical can be extended with
additional terms or patterns.
The code example below is shown using the default implementations for each option. When you configure these they are combined with the default implementations using "OR", the default implementations can not be overridden. These terms and patterns are only shown for reference and pasting this example into your project is not useful.
If the string begins with a "^" or "(" then it is treated as a RegExp,
otherwise it will be an exact match. A string may also be used instead
of an array of strings.
ESLint 9+ (Flat Config)
import lexical from '@lexical/eslint-plugin';
export default [
{
plugins: {
'@lexical': lexical
},
rules: {
'@lexical/rules-of-lexical': [
'error',
{
isDollarFunction: ['^\\$[a-z_]'],
isIgnoredFunction: [],
isLexicalProvider: [
'parseEditorState',
'read',
'registerCommand',
'registerNodeTransform',
'update'
],
isSafeDollarFunction: ['^\\$is']
}
]
}
}
];
ESLint 7-8 (Legacy Config)
{
"plugins": [
// ...
"@lexical"
],
"rules": {
// ...
"@lexical/rules-of-lexical": [
"error",
{
"isDollarFunction": ["^\\$[a-z_]"],
"isIgnoredFunction": [],
"isLexicalProvider": [
"parseEditorState",
"read",
"registerCommand",
"registerNodeTransform",
"update"
],
"isSafeDollarFunction": ["^\\$is"]
}
]
}
}
isDollarFunction
Base case: /^\$[a-z_]/
This defines the $function convention, which by default is any function that
starts with a dollar sign followed by a lowercase latin letter. You may have a
secondary convention in your codebase, such as non-latin letters, or an
internal prefix that you want to consider (e.g. "^INTERNAL_\\$").
isIgnoredFunction
Base case: None
Functions that match these patterns are ignored from analysis, they may call Lexical $functions but are not considered to be a dollar function themselves.
isLexicalProvider
Base case: /^(parseEditorState|read|registerCommand|registerNodeTransform|update)$/
These are functions that allow their function argument to use Lexical $functions.
isSafeDollarFunction
Base case: /^\$is/
These $functions are considered safe to call from anywhere, generally these functions are runtime type checks that do not depend on any other state.
Testing
To verify that the plugin works with different ESLint versions, run the integration tests:
node packages/lexical-eslint-plugin/__tests__/integration-test.js
This will test:
- ✓ ESLint 8 with legacy
.eslintrc.jsonconfiguration - ✓ ESLint 10 with flat
eslint.config.jsconfiguration - ✓ Legacy config name aliases (
recommendedvslegacy-recommended)
The tests use pnpm dlx to run different ESLint versions without modifying package.json or pnpm-lock.yaml.
Valid and Invalid Examples
Valid Examples
$functions may be called by other $functions
function $namedCorrectly() {
return $getRoot();
}
$functions may be called in functions defined when calling the following methods (the heuristic only considers the method name):
editor.updateeditorState.readeditor.registerCommandeditor.registerNodeTransform
function validUsesEditorOrState(editor) {
editor.update(() => $getRoot());
editor.getLatestState().read(() => $getRoot());
}
$functions may be called from class methods
class CustomNode extends ElementNode {
appendText(string) {
this.appendChild($createTextNode(string));
}
}
Invalid Examples
Rename autofix
function invalidFunction() {
return $getRoot();
}
function $callsInvalidFunction() {
return invalidFunction();
}
Autofix: The function is renamed with a $ prefix. Any references to this name in this module are also always renamed.
function $invalidFunction() {
return $getRoot();
}
function $callsInvalidFunction() {
return $invalidFunction();
}
Rename & deprecate autofix
export function exportedInvalidFunction() {
return $getRoot();
}
Autofix: The exported function is renamed with a $ prefix. The previous name is also exported and marked deprecated, because automatic renaming of references to that name is limited to the module's scope.
export function $exportedInvalidFunction() {
return $getRoot();
}
/** @deprecated renamed to {@link $exportedInvalidFunction} by @lexical/eslint-plugin rules-of-lexical */
export const exportedInvalidFunction = $exportedInvalidFunction;
Rename scope conflict
import {$getRoot} from 'lexical';
function InvalidComponent() {
const [editor] = useLexicalComposerContext();
const getRoot = useCallback(() => $getRoot(), []);
return (<button onClick={() => editor.update(() => getRoot())} />);
}
Autofix: The function is renamed with a $ prefix and _ suffix since the suggested name was already in scope.
import {$getRoot} from 'lexical';
function InvalidComponent() {
const [editor] = useLexicalComposerContext();
const $getRoot_ = useCallback(() => $getRoot(), []);
return (<button onClick={() => editor.update(() => $getRoot_())} />);
}