Signals
Lexical Extensions uses signals as the reactive interface that you can use to communicate with and between extensions.
Motivation
At the lowest level, communicating between parts of Lexical is done with
callbacks such as registerCommand
, registerUpdateListener
,
registerMutationListener
, and addEventListener
with the DOM.
How these callbacks behave and in some cases whether they are registered
at all depends on state local to the extension that may also be mutable,
so would require a similar callback mechanism with a naïve implementation.
With legacy React plug-ins, this mechanism is useEffect
which is very
coarse and doesn't really have a portable equivalent when using other
or no framework.
The initial prototype was largely callback based as well, but this quickly becomes very difficult to work with when you have to do work based on the "result" of multiple callbacks or configuration values. Signals are a more general solution to this problem. There's even a TC39 Signals Proposal to add infrastructure for this to the JavaScript standard.
Signals also allow for optimizations by reducing redundant callbacks with
primitives such as batch
or by allowing the current value to be inspected
with peek
without adding any subscription. Basically, the same Signal can
be used either like React State when you want to run code as the value changes
over time, or like a React Ref when you only need to know the current value
(as is typical with event handlers).
Signals also do not require you to manage a dependency array or use a compiler, the subscriptions and dependencies are inferred at runtime as they are used. This means that the set of signals subscribed to by a given effect can change without violating any rules or generating any warnings.
Implementation
Since Signals are not yet standardized, and the signal-polyfill is not ready for production use, it was decided to leverage @preact/signals-core. This implementation is dependency-free, optimized, and well tested.
For future compatibility we do not recommend that you use
@preact/signals-core
directly, and instead use the re-exports from
@lexical/extension.
As of the initial release, these re-exports are:
export {
batch,
computed,
effect,
type ReadonlySignal,
type Signal,
signal,
type SignalOptions,
untracked,
} from '@preact/signals-core';
Usage
For thorough examples on how the signal primitives work, you can read through the Preact Signals documentation.
Lexical provides the following higher-level signal functions to make it easier to build extensions:
namedSignals
namedSignals is a convenience function used to output independent signals from each property of a configuration object.
export interface TabIndentationConfig {
disabled: boolean;
maxIndent: null | number;
}
export const TabIndentationExtension = defineExtension({
build(editor, config, state) {
return namedSignals(config);
},
config: safeCast<TabIndentationConfig>({disabled: false, maxIndent: null}),
name: '@lexical/extension/TabIndentation',
register(editor, config, state) {
const {disabled, maxIndent} = state.getOutput();
return effect(() => {
if (!disabled.value) {
// This register function's implementation uses peek on the maxIndent
// signal to get its current value when needed, no need to re-register
// every time this value changes.
return registerTabIndentation(editor, maxIndent);
}
});
},
});
watchedSignal
watchedSignal is equivalent to React's useSyncExternalStore
export const EditorStateExtension = defineExtension({
build(editor) {
return watchedSignal(
() => editor.getEditorState(),
(editorStateSignal) =>
editor.registerUpdateListener((payload) => {
editorStateSignal.value = payload.editorState;
}),
);
},
name: '@lexical/extension/EditorState',
});