docs/slate-v2/decoration-roadmap.md
Turn the old decorate mess into an explicit, high-performance, React-native
overlay architecture that is honest enough to block or unlock True Slate RC.
This is not another research note. This is the final execution authority for this lane until implementation or new primary evidence proves one of its locked assumptions wrong.
This plan defines the architecture lock, implementation sequence, proof owners, and RC exit conditions for the overlay system.
2026-04-15 extension:
This lane was a blocker for:
True Slate RCslate-react “no regression vs legacy Slate React” claimThe first overlay architecture lane is complete.
The next active decoration work is no longer architecture naming or examples. It is source-scoped invalidation.
Remaining broader True Slate RC blockers now live outside this plan in:
Post-RC broader follow-up lives outside this plan in:
Primary rationale:
Primary roadmap / verdict owners:
Broad research on the decorations / annotations lane is closed.
This plan is the locked starting contract for execution.
Only reopen the architecture if:
Current local proof seams:
Current execution reality:
The next execution batch for decorations is no longer a tranche-5 / tranche-6 blocker. These waves now belong to the stronger post-closeout perf claim.
Perf truth note:
.tmp/slate-v2/package.json and
.tmp/slate-v2/scripts/benchmarks/**bun run bench:react:rerender-breadth:localbun run bench:react:huge-document-overlays:localRemaining broader program work lives in:
The old system failed because it used one callback to pretend it could own:
That was bullshit.
The current v2 runtime and docs now settle the overlay lane cleanly.
It proves:
And it settles:
decorateThe target system must have three first-class lanes:
They may share projection plumbing. They may not share ownership semantics.
decorate does not define the target architecture.
A clean break is preferred unless real adoption pain later justifies an
out-of-core shim.Bookmark is the preferred public durable-anchor noun.
RangeRef is lower-level runtime machinery, not an equal peer in the target
API.slate-react.RangeRef first once Bookmark exists.WidgetPlacement may exist internally before it exists publicly.
Do not export it early just because the runtime needs geometry.derive(snapshot) callback
shape.slate-react may mirror and index annotation data without owning
canonical thread/comment metadata.Before broad implementation, land these design artifacts:
Adopt a three-lane overlay architecture:
DecorationAnnotationWidgetwith one editor-scoped canonical overlay kernel in slate-react.
decorate ambiguity mixed transient overlays, durable anchors, and
anchored UI into one unstable callbackRangeRef and Bookmark as equal public anchor choicesRangeRef + Bookmark
This plan is successful only if all of these become true at the same time:
If even one of those is missing, the architecture is still not RC-grade.
The earlier planning draft overestimated how much greenfield work still remained in Waves 2 and 4.
What is already true now:
search-highlighting.tsx,
code-highlighting.tsx,
and
highlighted-text.tsx
are running on the explicit decoration-source model.persistent-annotation-anchors.tsx,
mentions.tsx,
and
hovering-toolbar.tsx
already have package/browser proof against the explicit annotation/widget
lane.slate-react already ships explicit:
The architecture direction held up. The execution reality was simply less blank than the first plan assumed.
No remaining unfinished surfaces exist inside this lane.
Cross-program follow-ups after completion:
These should stop being “open questions” unless implementation proves them wrong.
Freeze this now:
slate-reactHarsh take:
If this is not frozen now, implementation will drift into hook soup and duplicated state.
Freeze this split:
slate
slate-react
slate-dom
Harsh take:
Putting canonical annotation metadata in core is the wrong abstraction. Core should not own thread metadata, author data, hover state, or review UI.
Freeze the recommendation:
Bookmark handlesslate-reactReason:
Freeze the recommendation:
WidgetAnchor is logical and stableWidgetPlacement is ephemeral, DOM-facing, and viewport-relativeWidgetPlacement does not need to be an early public API if internal
placement plumbing is enough for the first honest examplesReason:
Freeze the recommendation:
derive(snapshot): readonly Decoration[] callback contractReason:
Freeze the recommendation:
Minimum target:
allpathsruntimeIdsselectionsyncdeferredRefresh semantics must also freeze:
Freeze this order:
search-highlightingcode-highlightinghighlighted-textpersistent-annotation-anchorshovering-toolbarmentionshuge-documentReason:
These are the locked starting contracts for implementation.
If implementation wants to change them later, it needs:
type OverlayKernel = {
connectDecorationSource(source: DecorationSourceAdapter): () => void;
connectAnnotationStore(store: AnnotationStoreAdapter): () => void;
refresh(request: DecorationRefresh): void;
getProjectionSnapshot(): ProjectionSnapshot;
getAnnotationSnapshot(): AnnotationSnapshot;
subscribe(listener: () => void): () => void;
destroy(): void;
};
type DecorationSourceAdapter =
| {
id: string;
kind: "derived";
derive(snapshot: EditorSnapshot): readonly Decoration[];
invalidate?: DecorationInvalidationPolicy;
}
| {
id: string;
kind: "external";
getSnapshot(): readonly Decoration[];
subscribe(listener: () => void): () => void;
};
type DecorationRefresh = {
sourceId: string;
generation: number;
scope: "all" | "paths" | "runtimeIds" | "selection";
mode?: "sync" | "deferred";
paths?: Path[];
runtimeIds?: RuntimeId[];
};
type Annotation = {
id: string;
anchor: AnnotationAnchor;
kind: string;
data?: unknown;
};
type AnnotationAnchor = { type: "bookmark"; bookmark: Bookmark };
type AnnotationStoreSnapshot = {
byId: ReadonlyMap<string, Annotation>;
allIds: readonly string[];
};
type AnnotationStoreAdapter = {
getSnapshot(): AnnotationStoreSnapshot;
subscribe(listener: () => void): () => void;
};
type WidgetAnchor =
| { type: "annotation"; annotationId: string }
| { type: "node"; runtimeId: RuntimeId }
| { type: "selection" };
type WidgetPlacement = {
widgetId: string;
anchor: WidgetAnchor;
rects: readonly DOMRect[];
strategy: "inline" | "floating" | "block";
};
Minimum React bindings:
useSlateDerivedDecorations(...)useSlateDecorationSet(...)useSlateAnnotationStore(...)Public-surface rule:
useSlateWidgetPlacement(...) or a public WidgetPlacement
export in the first wave unless a real external consumer proves the internal
placement layer is insufficientuseSlateAnnotations([...]) or useSlateWidgets([...]) the
flagship API if that only recreates array-replacement churn under nicer namesEvery wave needs all three proof classes when relevant:
range-ref-contract.tsbookmark-contract.tsprojections-and-selection-contract.tsxannotation-store-contract.tsxwidget-layer-contract.tsxpersistent-annotation-anchors.test.tssearch-highlighting.test.tscode-highlighting.test.tshighlighted-text.test.tspersistent-annotation-anchors.test.tsmentions.test.tshovering-toolbar.test.tspackages/slate-dom/test/bridge.tsmark-placeholder.test.tsplaceholder-ime.test.tshuge-document runtime surface proofbench:react:rerender-breadth:localbench:react:huge-document-overlays:localThese are not optional.
Changing:
must not trigger broad unrelated subtree rerenders in the isolated runtime benchmark.
The rewrite must not reopen already-green blocker-facing lanes in:
That means, at minimum:
Every new overlay benchmark must compare:
If the lane cannot distinguish those, the benchmark is useless.
Transitions and deferred work may touch:
They may not own:
This work should be run as an architecture-first execution program, not as a string of isolated bug fixes.
Rules:
Preferred execution posture:
Do not do giant blind rewrites and “see what breaks”.
The site example program is part of the architecture, not demo fluff.
Every lane below should end with one example that is canonical for the final public story:
search-highlighting
code-highlighting
highlighted-text
persistent-annotation-anchors
hovering-toolbar
mentions
huge-document
decorate API once its replacement lane
landspersistent-annotation-anchors is the canonical durable-anchor examplesearch-highlighting
ref-based query smuggling as the primary API storycode-highlighting
Editor.replace(...) cheat for language-change invalidationhighlighted-text
persistent-annotation-anchors
hovering-toolbar
mentions
huge-document
The benchmark program is not optional. This architecture is partly about scaling and invalidation, so benchmark truth is part of the contract.
pnpm bench:react:rerender-breadth:localpnpm bench:react:huge-document-overlays:localbench:replacement:search-highlighting:local
bench:replacement:code-highlighting:local
bench:replacement:annotations:local
bench:react:overlay-subscriptions:local
bench:replacement:review-suggestions:local
/Users/zbeyens/git/slate-v2/scripts/benchmarks/browser/replacement/search-highlighting.mjs/Users/zbeyens/git/slate-v2/scripts/benchmarks/browser/replacement/code-highlighting.mjs/Users/zbeyens/git/slate-v2/scripts/benchmarks/browser/replacement/annotations.mjs/Users/zbeyens/git/slate-v2/scripts/benchmarks/browser/react/overlay-subscriptions.tsx/Users/zbeyens/git/slate-v2/scripts/benchmarks/browser/replacement/review-suggestions.mjs.tmp/ with stable namesEvery new overlay lane should write at least:
laneiterationscurrentlegacy or baselinemeanMsmedianMsmaxMsminMssamplesThese are default thresholds unless a lane owner justifies stricter ones.
bench:react:overlay-subscriptions:local
bench:replacement:search-highlighting:local
bench:replacement:code-highlighting:local
bench:replacement:annotations:local
bench:replacement:review-suggestions:local
If a lane cannot be judged against one of:
then the lane is underspecified and should not be used in verdict language.
Status:
Goal:
Files:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/index.tsRequired decisions:
decorationannotationwidgetprojection storesourcerefreshRangeRef once Bookmark
owns durable anchorsslate-react store with core anchor handlesExit:
Bookmark is frozen as the durable public anchor nounRangeRef is frozen as lower-level runtime machinery, not the preferred
public storyslate-react/src/index.ts match the chosen nounsStatus:
Goal:
Bookmark the first-class public durable-anchor noun instead of
exposing raw live-ref semantics as the public answerPrimary files:
/Users/zbeyens/git/slate-v2/packages/slate/src/interfaces/range-ref.ts/Users/zbeyens/git/slate-v2/packages/slate/src/core/range-ref.ts/Users/zbeyens/git/slate-v2/packages/slate/src/range-ref-transform.ts/Users/zbeyens/git/slate-v2/packages/slate/src/range-projection.ts/Users/zbeyens/git/slate-v2/packages/slate/src/editor/range-ref.ts/Users/zbeyens/git/slate-v2/packages/slate/src/editor/range-refs.ts/Users/zbeyens/git/slate-v2/packages/slate/src/interfaces/bookmark.ts/Users/zbeyens/git/slate-v2/packages/slate/src/editor/bookmark.tsTests:
/Users/zbeyens/git/slate-v2/packages/slate/test/bookmark-contract.tsRequired scenarios:
Bookmark-first, not RangeRef-firstExit:
Bookmark contract is explicit and browser-independentRangeRef remains lower-level runtime machinery instead of becoming the main
public annotation anchor storyStatus:
Goal:
Primary files:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/projection-store.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/projection-context.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/use-slate-projections.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/use-slate-range-ref-projection-store.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/annotation-store.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/widget-store.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/use-slate-annotations.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/use-slate-widgets.tsxTests:
/Users/zbeyens/git/slate-v2/packages/slate-react/test/annotation-store-contract.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/test/widget-layer-contract.tsxRequired scenarios:
useSyncExternalStore snapshots are cached and immutableExit:
Status:
Goal:
Primary files:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/projection-store.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/decoration-sources.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/use-slate-decoration-sources.tsxExample owners:
/Users/zbeyens/git/slate-v2/site/examples/ts/search-highlighting.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/code-highlighting.tsx/Users/zbeyens/git/slate-v2/site/examples/ts/highlighted-text.tsxTests:
Required scenarios:
Exit:
decorate is no longer the preferred mental modelsearch-highlighting, code-highlighting, and highlighted-text are all
running on the final source modelStatus:
Goal:
Primary files:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/annotation-store.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/widget-store.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/slate-widget-layer.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/slate-annotation-layer.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/test/annotation-store-contract.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/test/widget-layer-contract.tsxBrowser/example proof:
/Users/zbeyens/git/slate-v2/playwright/integration/examples/comment-thread-anchors.test.ts/Users/zbeyens/git/slate-v2/playwright/integration/examples/review-suggestions.test.tsRequired scenarios:
Exit:
persistent-annotation-anchors, mentions, and hovering-toolbar are all
reconciled against the final architectureStatus:
Goal:
Primary files:
/Users/zbeyens/git/slate-v2/packages/slate-dom/src/bridge.ts/Users/zbeyens/git/slate-v2/packages/slate-dom/src/clipboard.ts/Users/zbeyens/git/slate-v2/packages/slate-dom/test/bridge.tsTests:
Required scenarios:
Exit:
Goal:
Primary files:
/Users/zbeyens/git/slate-v2/scripts/benchmarks/browser/react/rerender-breadth.tsx/Users/zbeyens/git/slate-v2/scripts/benchmarks/browser/react/huge-document-overlays.tsx/Users/zbeyens/git/slate-v2/scripts/benchmarks/shared/react-benchmark.tsx/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/editable-text-blocks.tsxReact runtime rules to enforce:
Activity only for hidden panes/page surfaces, never as a substitute for
the live editing corridorRequired scenarios:
Exit:
Goal:
Primary files:
/Users/zbeyens/git/slate-v2/docs/libraries/slate-react/editable.md/Users/zbeyens/git/slate-v2/docs/libraries/slate-react/slate.md/Users/zbeyens/git/slate-v2/docs/general/replacement-candidate.md/Users/zbeyens/git/plate-2/docs/slate-v2/references/pr-description.mdRequired scenarios:
decorate use cases map onto:
Bookmark-firstRangeRef to lower-level runtime machinery unless callers
explicitly need that seamRangeRef first once Bookmark existsExit:
RangeRef story fighting BookmarkWidgetPlacement API without proof it earns its keepGoal:
Files:
Exit:
decorations.spec.tsx row is gone or explicitly reclassified under
the final architectureStatus:
Goal:
Primary files:
/Users/zbeyens/git/slate-v2/packages/slate/src/interfaces/editor.ts/Users/zbeyens/git/slate-v2/packages/slate/src/core/apply.ts/Users/zbeyens/git/slate-v2/packages/slate/src/core/transaction-helpers.ts/Users/zbeyens/git/slate-v2/packages/slate/src/core/get-dirty-paths.ts/Users/zbeyens/git/slate-v2/packages/slate/src/range-projection.tsRequired model:
insert_text / remove_text fast paths publish the touched text
runtime id without rebuilding the snapshot indexRequired tests:
Editor.replace(...) publishes a replace-level broad invalidationExit:
Editor.subscribe(editor, listener) behavior remains source-compatibleStatus:
Goal:
Primary files:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/decoration-sources.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/annotation-store.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/widget-store.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/projection-store.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/hooks/use-slate-decoration-sources.tsxRequired source classes:
always
selection
text
node
annotation
external
subscribe(...),
refreshSource(...), or refreshAll()custom
API posture:
Required tests:
Exit:
Status:
Goal:
Primary files:
/Users/zbeyens/git/slate-v2/packages/slate/src/range-projection.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/projection-store.ts/Users/zbeyens/git/slate-v2/packages/slate-react/src/decoration-sources.ts/Users/zbeyens/git/slate-v2/scripts/benchmarks/browser/react/rerender-breadth.tsx/Users/zbeyens/git/slate-v2/scripts/benchmarks/browser/react/huge-document-overlays.tsxResearch pressure:
Required implementation direction:
Editor.projectRange(...) avoid collecting
every text entry when the projected range is localRequired tests:
Required benchmark additions:
pnpm bench:react:rerender-breadth:local with recompute counters:
pnpm bench:react:huge-document-overlays:local with:
Exit:
Strict order:
Reason:
Do not proceed past these gates:
Broad research is closed.
Design and prototype now.
Reopen research only if one of these fails:
If none of those happen, this plan stands and more repo archaeology is wheel-spinning.
Risk:
Mitigation:
slate-reactRisk:
Mitigation:
Risk:
Mitigation:
Risk:
Mitigation:
Risk:
decorate firstMitigation:
If a wave lands and later proves wrong:
Specific rollback triggers:
This plan is complete.
The lane now satisfies the intended acceptance bar: