Small and moderately sized editors usually do not need special performance work; large editors do. Measure the slow path first, then change normalization, React subscriptions, DOM mounting, or painting policy based on that evidence. The Huge Document example is the local playground for document size, rendering mode, selection, typing, and rendering cost.
This guide mostly cares about Interaction to Next Paint (INP) while typing. web.dev rates an INP of 200 ms or less as good and above 500 ms as poor.
Also measure click latency, selection latency, paste, Select All, undo/redo, and scroll stability for very large documents. Typing is not the only hot path that users feel.
INP is easiest to measure in Chrome using the Performance panel in DevTools. In Firefox, use the to inspect event and paint timelines.
Before you start optimizing, find which of these areas is most responsible for the slowness. Use your browser's profiler (see the example for Firefox above). These rules of thumb are not Plite measurements; use them only to pick what to profile first:
Usually, if the core Plite logic is causing a noticeable delay, it's because of normalizing. If custom normalization logic is causing slowness in your app, consider whether the logic can be made more efficient.
Use plugin corrections with an event of children, content, or properties and a narrow query. The correction worklist selects matching entries from changed paths and ancestors; a correction can run again when repairs create more work.
Keep each correction focused on its entry and direct children. Use query: "root" for document-root rules, such as enforcing a final paragraph.
Plite can synchronize changed text directly to the DOM. Custom subscriptions and unstable props can still cause extra element renders, so profile the components that render.
Prefer stable renderer functions passed through Editable props. Define
renderElement, renderLeaf, and renderText at module scope, or memoize them
once when they need editor-local data. Avoid creating renderer functions inside
the editor component during render.
If unmodified elements are being re-rendered, check to see if they are subscribing to any contexts or hooks that are causing unnecessary re-renders. You can also apply these techniques to any toolbars or other non-element React components that may be re-rendering in response to changes in the editor.
Hot editor UI should subscribe to the narrowest source it needs. Use
useEditorContext() to access the provider editor, useEditorSelection() for
selection-aware toolbar UI, and useEditorSelector() for derived editor-level
toolbar state. Inside rendered editor content, prefer target-scoped hooks such
as useElementSelected(), useNodeSelector(), and useTextSelector(). Use
useAnnotation(store, id) for one targeted external annotation.
If your components depend on custom React contexts containing non-primitive values (such as objects or arrays), ensure that these values are properly memoized so that components only re-render when these values change. In some circumstances, you may instead want to consider passing a ref object or an unchanging getter function to retrieve the latest value.
// Provider
const myDataRef = useRef(myData);
myDataRef.current = myData;
return <MyContext.Provider value={myDataRef}>{children}</MyContext.Provider>;
// Consumer
// Does not re-render when `myData` changes
const myDataRef = useContext(MyContext);
const onClick = () => {
console.log(myDataRef.current);
};// Provider
const myDataRef = useRef(myData);
myDataRef.current = myData;
return <MyContext.Provider value={myDataRef}>{children}</MyContext.Provider>;
// Consumer
// Does not re-render when `myData` changes
const myDataRef = useContext(MyContext);
const onClick = () => {
console.log(myDataRef.current);
};Editable mounts the complete document. Start there and measure the actual
interaction and memory budget. When DOM size is the proven limit, use
VirtualizedEditable
from plitejs/react/virtualized to opt into top-level viewport mounting.
Virtualization removes offscreen content from native browser find, accessibility traversal, print output, and third-party DOM integrations. Keep a complete surface when those jobs matter more than the DOM budget.
For paginated documents, set virtualize on
PagedEditable. The paged view owns
measurement, element placement, and omission at the page boundary while
keeping one canvas coordinate system. The complete paged DOM remains the
default.
Use stable DecorationSource objects for inline paint. Plite subscribes
once per source, rereads only affected input node keys after document edits, and
wakes only text nodes whose merged attributes changed. Use observe for
targeted refreshes from external stores.
Optimize painting when the profile shows that painting, not Plite or React, takes most of the interaction time. Profile each browser you support.
The content-visibility CSS property can reduce paint work for large documents. When set to auto, it lets the browser skip layout and painting for off-screen content while that content stays in the DOM. Before you apply content-visibility: auto to every Plite element, measure each browser you support, Safari included, with and without it.
For aggressive mounting control, use explicit virtualized rendering only when a pathological document needs a degraded viewport-mounted surface.
Use a CSS rule like this to apply spacing between top-level blocks:
[data-editor] > * + * {
margin-top: 1em;
}[data-editor] > * + * {
margin-top: 1em;
}Keep spacing rules attached to the current editor DOM, not to internal runtime structures.
Also bear in mind this warning about content-visibility: auto from MDN:
Since styles for off-screen content are not rendered, elements intentionally hidden with
display: noneorvisibility: hiddenwill still appear in the accessibility tree. If you don't want an element to appear in the accessibility tree, usearia-hidden="true".