Skip to main content

@lexical/eslint-plugin

This ESLint plugin enforces the Lexical $function convention.

ESLint Compatibility: This plugin supports ESLint 9 and later with the flat config format (eslint.config.js). Support for ESLint 7-8 and the legacy .eslintrc format was removed in v0.51.0.

Installation​

Assuming you already have ESLint installed, run:

npm install @lexical/eslint-plugin --save-dev

Configuration​

Add the recommended config to your eslint.config.js:

import lexical from '@lexical/eslint-plugin';

export default [
// ... other configs
lexical.configs.recommended
];

lexical.configs['flat/recommended'] and lexical.configs['flat/all'] are aliases of recommended and all from when those names were the legacy configs. The default presets enable @lexical/rules-of-lexical and @lexical/no-nested-editor-updates as warnings. @lexical/no-document-in-dom-methods remains opt-in because it is specific to Lexical DOM method implementations.

Custom Configuration​

import lexical from '@lexical/eslint-plugin';

export default [
{
plugins: {
'@lexical': 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 callbacks passed directly to editor.update, editor.registerCommand, and editor.registerNodeTransform when the outer and inner receiver can be tied to the same expression and identifier binding.

The rule also reports a same-editor update directly inside editor.read(), including the editor.read(mode, callback) overload. A read callback is read-only, so this diagnostic does not suggest removing the wrapper. It explains that the update is deferred until the read returns and should be moved after the read callback.

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 default presets enable the rule as a warning. You can promote it to an error or turn it off in the rules object in your flat configuration:

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,
);

A nested update in a read callback must retain its wrapper, but it should run after the read:

const shouldRemove = editor.read('latest', () => {
return $getSelection()?.isCollapsed() === false;
});

if (shouldRemove) {
editor.update(() => {
$getSelection()?.removeText();
});
}

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,
);

When a nested update has a statically analyzable options object, the diagnostic also explains how to preserve options that can be applied to the current update context: use $addUpdateTag for tag, $flushSyncAfterUpdate for discrete, and $onUpdate for onUpdate, then remove the wrapper. Call $addUpdateTag once for each value when tag is an array.

skipTransforms has no in-place equivalent. The rule therefore leaves alone options objects containing skipTransforms as well as identifiers, spreads, computed properties, and unknown properties that might hide it. Options on an update inside read() are preserved unchanged when that update is moved after the read callback.

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, $getEditor(), and this.editor, but callback checks report only when the inner expression can be tied to the outer expression. this.editor is treated as the same receiver across an arrow callback, where this is lexical; it is not assumed to be the same across an ordinary function callback.

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$', '^getMyEditor$'],
},
],
};

Configured zero-argument accessors are compared consistently, so repeated getMyEditor() calls in the same direct callback are recognized as the same editor expression.

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.

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']
}
]
}
}
];

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:

pnpm run test-eslint-integration

This will test:

  • ✓ ESLint 9 with a flat eslint.config.js configuration
  • ✓ ESLint 10 with the same configuration

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.update
  • editorState.read
  • editor.registerCommand
  • editor.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_())} />);
}