docs/slate-v2/decorations-annotations-cluster.md
The old Slate issue corpus kept flattening decorations into bigger buckets like React runtime, selection, performance, and API ergonomics. That hides the real pattern.
Decorations were carrying at least four different jobs at once:
That overloading is why the same family of bugs keeps resurfacing with different symptoms.
This is not one bug. It is one bad abstraction boundary.
The cleanest example is #3383: overlapping marks or decorations with the same semantic meaning but different metadata cannot coexist once everything is flattened into leaf props.
The issue is not “highlighting is hard”. The issue is that the current leaf model only preserves orthogonal properties well. Once two overlays want the same key with different payloads, one wins and the other dies.
Related pressure:
#3383: overlapping same-semantic metadata is lossy#2564: marks vs inlines were already semantically muddy#2465: render-time mark ergonomics are brittle because the renderer works on split leaves, not a richer overlay modelTakeaway: leaf splitting is fine for basic formatting. It is weak for preserving multiple independent overlay payloads.
The second cluster is about shape, not speed.
Users want decorations that:
Related pressure:
#4392: cross-node decorate#4426: range masking#4477: selection-anchored comments for collaborative writingThese are all variants of the same problem: the public decorate(entry) contract is too text-leaf-shaped for richer overlay behavior, but too implicit to expose a real overlay/annotation model.
Takeaway: once an overlay needs to outgrow “mark this leaf fragment”, the API stops feeling honest.
This is the hottest runtime cluster.
The local issue docs already tagged it:
#4483: dynamic decorations rerender costThe important detail from #4993:
Range.intersection the bottleneck#4993 argues that top-level flattening was a regression because it destroyed the old “only redecorate the changed part of the tree” behavior.
The important detail from #4997:
decorate function itself changes oftendecorate in a fragile place: a prop whose timing must line up perfectly with Slate reconciliation and DOM selection repairTakeaway: performance pain here is not generic “decorations are slow”. It is invalidation-model pain.
This is where decoration debt stops being annoying and starts breaking editing.
Related pressure:
#3309: decorated text cannot be selected#3162: decorate + IME input desync#4712: decoration range with text field interferes with selection#5987: caret jumps when async decorate updates land#4581: deleting decoration/void then typing can crash in FirefoxThis family keeps saying the same thing:
#4997 is the most useful thread here because it did not die at “perf seems better”.
It found a harder failure mode:
decorate as a prop is a delicate house of cards when decoration changes are driven externally and not synchronized with editor onChangeThat matters more than the micro-optimization details.
Takeaway: the runtime contract between decorations, selection reconciliation, and externally-driven updates is structurally fragile.
This is the part people kept circling without fully landing.
The old discussions repeatedly converge on the same idea:
#4477 asks for comment anchors. The #4993 discussion explicitly points back to older annotation concepts and says decorations are not ideal for cursors. The comments around potential APIs mention keyed overlays, range refs, and imperatively maintained decoration-like entities.
That is annotation pressure, not mere decoration pressure.
The useful distinction:
Once you force both through the same decorate funnel, you get:
Takeaway: annotations want explicit ownership and lifetime semantics. Decorations do not give that for free.
#4993 and #4997 actually taught#4993: the contract was already ambiguousThe real argument in #4993 was not just performance. It was contract ambiguity.
Two incompatible expectations existed:
decorate function reference => full redecoratedecorate function should still reflect changing external stateThat is the actual fracture line.
#4993 says forcing top-level recomputation for the whole tree is too expensive and breaks efficient local decoration propagation.
It also surfaced a fair complaint from downstream libraries: if Slate expects decorate invalidation-by-reference, that contract was not explicit enough and was hostile to frameworks that naturally keep a stable function and vary external state.
#4997: faster subscriptions do not fix the semantic mismatch#4997 tried the smart version:
That helps the pure perf story.
Then async/debounced decoration updates broke it.
That result is gold because it proves the problem is deeper than “wrong rerender primitive”.
Better subscription mechanics do not solve:
decorateSo #4997 is useful not because it landed. It is useful because it found the wall.
My read is simple:
decorate semantics casuallyThe current slate-v2 direction is closer to the truth:
decorate weirdnessThat matches the evidence better than trying to make one old abstraction satisfy every overlay use case.
slate-v2 ideas already on the tableThe good news is that slate-v2 already has most of the right instincts. They
were just spread across too many docs.
The most important local rule is already written down:
That is the right seam.
Core should own logical range meaning. React should own subscription breadth and slice delivery. The renderer should consume slices, not reinvent decorations again.
The annotation work already found the honest substrate:
That matters because comments, review anchors, persistent diagnostics, and other durable spans are not just “whatever the latest decorate function returned”.
They need:
That is bookmark/range-ref territory.
Local browser proof already killed the naive assumptions:
So the rewrite cannot stop at “React rerenders less”.
It also has to preserve:
The local huge-doc posture is already better than old Slate thinking:
The right default is:
That is the correct place to start if decorations need to survive huge docs or future virtualization.
This is the part where most editor design docs get weak. They either blindly worship ProseMirror or they cherry-pick shiny terms from five repos and call it strategy.
The honest take is narrower.
Useful things to steal:
DecorationSetUseful thing to reject:
Why reject it:
So the steal is:
Not:
Useful things to steal:
useSyncExternalStoreUseful thing to reject:
DecoratorNode is a general text-decoration answerWhy reject it:
So the steal is:
Not:
Useful things to steal:
Useful thing to reject:
Why reject it:
That is fine for their product surface. It is not the right north star for a fresh rewrite.
So the steal is:
Not:
Useful things to steal:
Useful thing to reject:
Why reject it:
So the steal is:
Not:
use-editable, rich-textarea, and edixThese repos matter mostly because they keep you honest about the lower end of the space.
What they prove:
What not to steal:
What to steal:
This is not an editor repo, which is exactly why it is useful.
Useful things to steal:
useSyncExternalStoreThis is a much smarter mental model for annotations and overlay indexes than yet another ad hoc React context pile.
Steal:
Do not steal:
This is future-platform pressure, not present-day shipping guidance.
Useful things to steal conceptually from dev-design.md:
updateSelection(...)updateLayout(...)textformatupdateThat is exactly the direction old Slate never had.
Do not steal:
Steal:
The remaining entries from editor-architecture-candidates.md still matter, just less directly for this rewrite.
Steal the service boundary:
can live outside the core editor engine and re-enter as annotation or diagnostic sources.
Do not steal:
Steal the mindset:
Good inspiration for how multiple overlay sources can compose without becoming a single monolith.
Treat this as platform pressure, not implementation guidance.
The useful signal is that the platform itself still lacks a coherent answer for richer text fields, which means any serious editor architecture still needs to be explicit about:
So the shortlist still points in one direction:
use-editable, rich-textarea, and
edixDecorationSetThe strongest bit in decoration.ts
is not that it has decorations. It is that forChild(...) hands each child only
the relevant intersecting inline decorations plus any child-owned subtree set.
That is the opposite of old Slate's worst behavior.
It means:
That specific idea is worth stealing.
In selection.ts, bookmarks are document-independent mapped selections. In history.ts, history stores bookmarks at event boundaries instead of concrete live selections.
That is the right mental model for durable anchors:
That is why annotations should ride bookmark/range-ref semantics.
The deeper Lexical read sharpened the split:
That is gold.
It says the winning architecture is not:
It is:
LexicalUpdates.ts and LexicalOnChangePlugin.ts make one thing painfully obvious:
not:
For Slate v2 overlays, that suggests:
The Tiptap docs say two interesting things at once:
That is an excellent warning.
If a system needs:
it wants annotation semantics.
If it needs:
it can often live as decoration semantics.
Trying to force both through one mechanism is where editor APIs go stupid.
The useful VS Code lesson is not “copy Monaco”.
It is this:
That means even a mature text editor does not trust one “range decoration” primitive to do all of this.
It splits:
That maps almost perfectly onto the proposed Slate v2 split:
The really good bit in Premirror is not pagination itself.
It is the insistence on:
That is exactly how a serious overlay runtime should think for huge docs:
The EditContext design docs make one future-facing point very clearly:
textformatupdate is not “comments”, not “syntax highlighting”, and not
ordinary search highlighting. It is transient input-method visual state driven
by the platform.
That implies a future-proof design should leave room for:
without pretending they are normal annotations.
React 19.2 does not magically solve editor architecture, but it does make the right shape clearer.
Official references:
useSyncExternalStore should be the overlay subscription backboneThis is the clear winner for React-facing overlay state.
Why:
Important caveats from the docs:
getSnapshot must return immutable cached snapshotssubscribe identity causes resubscriptionThat leads to one hard rule:
The active editing corridor cannot depend on lazy/suspending overlay store reads or unstable snapshots.
startTransition is for non-urgent overlay work onlyReact says Transition updates are non-blocking, can be interrupted, and cannot control text inputs.
That means:
must stay out of transitions.
Good uses of transitions here:
useDeferredValue is for lagging views, not editor truthUse it where stale-but-useful UI is acceptable:
Do not use it for:
useEffectEvent is perfect for bridge listeners with latest configThis is very relevant for editor runtime code.
Use it for logic that is:
Examples:
Do not misuse it as an escape hatch for real dependencies.
<Activity> is a huge-doc and sidebar tool, not an editing primitive<Activity hidden> preserves state while cleaning up Effects and deprioritizing
the hidden subtree. That is strong for:
But it also means hidden subtree subscriptions are gone.
So:
If this rewrite is actually meant to be good, the DX bar has to be brutal.
If a consumer has to learn five historical footguns before they can highlight search results, the API is bad.
Here is the harsh answer: we should stop when new passes stop changing the architecture shape and only keep restating it with different repo mascots.
That line is basically here.
Across the local slate-v2 docs and the external repos, the same structure
keeps reappearing:
That is enough to design.
Another repo pass is very unlikely to overturn:
If we keep researching without switching to design, we are probably just avoiding hard API decisions.
The remaining unknowns are design questions, not discovery questions:
decorate looks likeThose need a spec and prototypes now.
Stop broad research after this pass.
Do one final design phase with:
If those prototypes uncover a contradiction, then reopen research on that specific contradiction only.
Anything broader than that is wheel-spinning.
One callback for syntax highlighting, search hits, AI suggestions, remote cursors, comments, diagnostics, placeholder-ish UI, and review anchors is not “flexible”.
It is a garbage abstraction.
That is what old decorate became.
The fix is not a smarter callback. The fix is splitting the jobs.
Yes. Do both:
But do not do them as one API with two marketing names.
They should share projection plumbing, not ownership semantics.
Core should own:
Range meaningprojectRange(editor, range) or equivalent pure projection entrypointCore should not own:
This keeps the engine document-first.
Decorations should mean:
They should support:
They should not require object-flattened leaf props.
The logical decoration payload should preserve multiplicity. If two highlights stack on the same span, the system should hold two highlights, not flatten them into one winner.
Annotations should mean:
They should support:
This is not optional. Comments are not just decorated text with opinions.
This is where the systems meet.
The shared layer should:
So:
That is the right split.
Text slices are not enough for everything.
You also need a first-class widget/chrome layer for:
ProseMirror widget decorations, Lexical decorator portals, and Premirror page chrome all say the same thing: some UI is anchored to the document but should not be modeled as inline text styling.
So the rewrite should have at least three render layers:
Do not pass overlay arrays down the tree.
Do not invalidate giant contexts.
Do:
useSyncExternalStore or equivalent subscription semanticsThe local projection proof and Lexical’s decorator subscription model are aligned here. TanStack DB is the better mental model for the store.
The old decorate ambiguity was poison.
The new design should say, plainly:
That means external-state decorations need an explicit refresh path.
Not:
That ambiguity deserves to die.
The rewrite should be good on huge docs before any virtualization fantasy.
Default huge-doc posture:
Virtualization later:
Premirror/Pretext are relevant here because they prove layout can be a derived model. They are not the excuse to overcomplicate normal editing.
Decorations and annotations must never be allowed to rot the bridge again.
Keep the local rules:
If the rewrite gets faster but copy/select/IME become fake again, it failed.
Do not make the new public surface be decorate(entry) 2.0.
That would be cowardly.
What I would do:
decorateThe preferred v2 surface should talk in terms of:
Not one magic callback.
Editor.projectRange(editor, range)Editor.projectRanges(editor, ranges)rangeRef / bookmark APIcreateSlateProjectionStore(editor, options)useTextProjections(runtimeId, layer?)useBlockProjections(runtimeId, layer?)useAnchoredWidgets(runtimeId | blockId)refresh(sourceId, scope?)If the goal is the absolute best rewrite, the answer is:
That gives you a model that can honestly cover:
without pretending that one callback should own the whole damn thing.
If we need a concise label for future analysis, use this:
The legacy decorations system is a mixed abstraction covering render-time marks, cross-node overlays, and annotation-like anchors, and it fails along four axes: semantic loss, invalidation cost, DOM/selection timing fragility, and missing ownership semantics for persistent anchors.