docs/plans/2026-04-30-slate-v2-decoration-annotation-api-ralplan.md
Status: done
Created: 2026-04-30
Current pass: browser-and-stress-closure
Next pass: none
The document value should not be the default owner for comments.
For Google Docs-style workflows where one user edits the document and another user only comments, making both users edit the same Slate value is the wrong mental model. It muddies permissions, undo/history, conflict policy, audit events, and collaboration routing.
The stronger target is:
slate-react mirrors resolved annotations into projection stores for paint,
sidebar, and widget consumers.This is the ProseMirror lesson without copying ProseMirror's API, plus the Lexical comment-store split without forcing comment-only users to mutate the document tree.
Intent:
Desired outcome:
In scope:
Non-goals:
decorate(entry) => Range[] as the flagship model.Decision boundaries:
slate-react may own render-facing projection stores.RangeRef stays lower-level runtime machinery; it is not the public comments
story.Unresolved user-decision points:
Current live Slate v2 already has the right foundation:
SlateAnnotation is id-bearing but currently requires bookmark: Bookmark
(.tmp/slate-v2/packages/slate-react/src/annotation-store.ts:14-18)..tmp/slate-v2/packages/slate-react/src/annotation-store.ts:146-168,
:240-279)..tmp/slate-v2/packages/slate-react/src/annotation-store.ts:177-224,
:516-545)..tmp/slate-v2/packages/slate-react/src/annotation-store.ts:556-568;
.tmp/slate-v2/packages/slate-react/test/annotation-store-contract.tsx:185-260).Editor.subscribeSource, not broad
editor.subscribe (.tmp/slate-v2/packages/slate-react/src/annotation-store.ts:668-682;
.tmp/slate-v2/packages/slate-react/test/annotation-store-contract.tsx:263-308)..tmp/slate-v2/packages/slate-react/src/projection-store.ts:31-72,
:343-459, :489-503).<Slate> composes annotation store projections with decoration sources
(.tmp/slate-v2/packages/slate-react/src/components/slate.tsx:38-50,
:151-160).Bookmark is a hidden, op-rebased range anchor with resolve() and
unref() (.tmp/slate-v2/packages/slate/src/editor/bookmark.ts:42-75)..tmp/slate-v2/packages/slate/test/bookmark-contract.ts:55-160)..tmp/slate-v2/site/examples/ts/review-comments.tsx:213-235, :508-521)..tmp/slate-v2/site/examples/ts/persistent-annotation-anchors.tsx:379-405,
:499-539).Current gap:
bookmark, so it only
describes the local editor anchor case.refresh() API; refresh is currently () => void
(.tmp/slate-v2/packages/slate-react/src/annotation-store.ts:47-55).data object into
inline projection data (.tmp/slate-v2/packages/slate-react/src/annotation-store.ts:252-269).
That is too broad for comment systems where body/sidebar data changes more
often than inline paint metadata.Status: complete
Updated: 2026-04-30T15:34:16Z
Boundary decisions:
bookmark to anchor before public lock. The package is still
pre-1.0/beta (.tmp/slate-v2/README.md:42) and slate-react is published as
0.124.0 (.tmp/slate-v2/packages/slate-react/package.json:1-5), so carrying
the wrong noun forward is more expensive than a minor-version break.data is for annotation hooks, sidebars, comments, and app state.projection is the small render-facing payload copied into text projection
slices.undefined means full refresh;
[] means no-op; a non-empty list recomputes just those annotations.docs/research/decisions/slate-v2-collaborative-annotation-channels.md.Pressure test:
data spreading cannot express
that distinction cleanly.No user question needed:
Plan delta:
anchor only to anchor + data/projection + id-targeted refresh.bookmark.Legacy Slate:
Editable exposes one decorate?: (entry: NodeEntry) => Range[]
callback (../slate/docs/libraries/slate-react/editable.md:11-25).RangeRef tracks a range through operations and must be manually
released (../slate/docs/api/locations/range-ref.md:1-10).decorate function identity matters
(../slate/docs/walkthroughs/09-performance.md:35-47).ProseMirror:
DecorationSet is persistent mapped overlay data, not a render callback
(../prosemirror/view/src/decoration.ts:265-286, :332-359).forChild(...) extracts child-local decorations
(../prosemirror/view/src/decoration.ts:431-453, :499-522).SelectionBookmark maps through changes and resolves later
(../prosemirror/state/src/selection.ts:173-204, :309-317, :382-391).Lexical:
MarkNode stores inline ids inside the document tree
(../lexical/packages/lexical-mark/src/MarkNode.ts:26-38, :109-137).CommentStore owns comment/thread metadata separately and can write to a
Yjs comments array
(../lexical/packages/lexical-playground/src/commenting/index.ts:107-170,
:252-286).MarkNode
(../lexical/packages/lexical-playground/src/plugins/CommentPlugin/index.tsx:720-738,
:789-807).Tiptap:
../tiptap/packages/extension-node-range/src/helpers/getNodeRangeDecorations.ts:1-28,
../tiptap/packages/extension-node-range/src/node-range.ts:170-205).../raw/tiptap/docs/src/content/comments/getting-started/overview.mdx:17-20,
:36-46).../raw/tiptap/docs/src/content/comments/core-concepts/configure.mdx:11-12,
:74-108;
../raw/tiptap/docs/src/content/comments/core-concepts/thread-authentication.mdx:30-52,
:58-85).Compiled research:
docs/research/systems/slate-v2-overlay-architecture.md:25-35).slate-react mirrors anchors
(docs/research/decisions/slate-v2-overlay-architecture-cuts.md:29-40).docs/research/concepts/source-scoped-overlay-invalidation.md:16-43).docs/solutions/performance-issues/2026-04-30-slate-v2-source-bus-routing-must-prove-upstream-fan-in-and-runtime-bucket-locality-separately.md).Principles:
Top drivers:
Options:
| Option | Verdict | Reason |
|---|---|---|
| Store full comments in Slate value | Reject as default | Wrong owner for comment-only permissions, undo/history, audit, and collab routing. |
| Lexical-style inline ids in document plus external metadata | Keep as optional adapter strategy | Good durability when the app wants document-embedded anchors, but it still mutates content. |
| ProseMirror-style mapped external anchors plus external comment store | Choose as default | Best fit for comment-only users and source-scoped projection performance. |
| Tiptap-style product comment extension/cloud policy | Defer to product/adapters | Useful reference, but raw Slate should not own a product comments service. |
Chosen target:
SlateAnnotation should accept a generic durable anchor, not only
bookmark.Bookmark remains the first built-in anchor implementation.SlateAnnotationStore mirrors resolved
ranges and data into render-facing snapshots.Current shape:
export interface SlateAnnotation<T = unknown> {
bookmark: Bookmark;
data?: T;
id: string;
}
Target shape before public lock:
export interface SlateAnnotationAnchor {
resolve(): Range | null;
unref?(): Range | null;
}
export interface SlateAnnotation<
TData = unknown,
TProjection extends Record<string, unknown> = Record<string, unknown>,
> {
anchor: SlateAnnotationAnchor;
data?: TData;
id: string;
projection?: TProjection;
}
Adoption answer:
bookmark to anchor.Bookmark already satisfies the target shape.projection, not the whole data payload. data
remains available through useSlateAnnotation and useSlateAnnotations.External refresh target:
type SlateAnnotationRefreshOptions = {
ids?: readonly string[];
reason?: "annotation" | "external" | "refresh";
};
interface SlateAnnotationStore<T = unknown> {
refresh(options?: SlateAnnotationRefreshOptions): void;
}
ids semantics:
This is the missing performance hook for:
Projection payload target:
const annotations = comments.map((comment) => ({
anchor: comment.anchor,
data: comment,
id: comment.id,
projection: {
resolved: comment.resolved,
tone: comment.tone,
},
}));
That lets a body edit update sidebars without repainting inline text. A status or tone edit still repaints the relevant runtime buckets.
Before:
// Comment-only user must mutate the editor value to persist the comment anchor.
editor.update((tx) => {
tx.addMark("commentId", threadId);
});
After:
// Comment-only user writes to the annotation channel.
const anchor = yjsAnnotationAdapter.anchorFromSlateRange(editor, selection);
commentsMap.set(threadId, {
anchor,
body,
status: "open",
});
annotationStore.refresh({ ids: [threadId], reason: "annotation" });
Writer lane:
editor.update((tx) => {
tx.text.insert("hello", { at });
});
Adapter lane:
yjsAnnotationAdapter.observeDocumentChanges(() => {
annotationStore.refresh({ reason: "annotation" });
});
The writer mutates the document channel. The commenter mutates the annotation channel. The adapter resolves anchors against the current Slate snapshot for rendering.
Full example target:
collaborative-comments.tsx as a two-editor side-by-side example.editor.update for document
writes and must not mutate Editor.children. Annotation-channel writes and
annotationStore.refresh({ ids, reason: 'annotation' }) are allowed.Required runtime properties:
Proof rows:
| Row | Required proof |
|---|---|
| Source fan-in | Monkey-patch broad editor.subscribe to throw; annotation store still rebases. |
| Candidate ids | Typing in unrelated block resolves zero annotation anchors. |
| External id refresh | Updating one comment body wakes that annotation id and no unrelated runtime buckets. |
| Projection payload split | Updating data.body wakes annotation subscribers but not inline projection subscribers when projection is unchanged. |
| Two-pane comment-only example | Writer edits in the left editor while reviewer comments from the right read-only editor; document value changes only from the writer lane. |
| Remote rebase | Replay remote text ops and prove local/collab anchors resolve to moved text. |
| Null anchor | Deleted anchor resolves null and paints nothing without leaking subscribers. |
| Stress | 1k annotations, typing near one annotation stays local; whole-doc refresh is measurable fallback only. |
Add a final-state Slate v2 doc after the plan closes:
.tmp/slate-v2/docs/libraries/slate-react/annotations.mdMinimum content:
Example changes:
review-comments.tsx to use anchor.collaborative-comments.tsx as the side-by-side two-editor example. If
the adapter-free mock cannot honestly prove separate document/comment
channels, do not ship a weaker toggle demo as a substitute.Use vertical slices, not a giant fake-red suite.
Bookmark still works through anchor.bookmark shape is rejected or intentionally aliased by one
explicit compatibility decision.{ ids }.data vs projection: body updates wake sidebar subscribers without
repainting inline runtime buckets when projection metadata is stable.editor.update and does not change Editor.children.| Objection | Answer | Verdict |
|---|---|---|
| "Why not just store comments in Slate value?" | Because comment-only users should not receive document-write permission just to discuss text. It also pollutes undo/history and makes audit events lie. | reject |
| "Lexical stores mark ids in the tree." | Yes, and it stores comment metadata separately. Slate can support document-embedded ids as an adapter choice without making it mandatory. | keep default external |
| "External anchors can drift." | Correct. The adapter must own drift policy, quote/context recovery, null resolution, and tests. Raw Slate only promises resolution and projection once the adapter provides an anchor. | revise with proof |
"Rename bookmark to anchor feels like churn." | Churn now is cheaper than publishing a local-only noun for a collaborative API. Bookmark remains the built-in anchor. | keep |
| "Id-targeted refresh complicates the store." | It is the difference between product-scale comments and cute examples. The store already has candidate-id machinery for editor changes; external changes need the same lane. | keep |
"Why split data and projection?" | Because comment body/sidebar churn should not repaint inline text. Render payload and app metadata have different hot paths. | keep |
Trigger:
Blast radius:
packages/slate-react/src/annotation-store.tspackages/slate-react/src/hooks/use-slate-annotation-store.tsxpackages/slate-react/src/hooks/use-slate-annotations.tsxPre-mortem:
bookmark, then remote anchors need a second shape and docs
become confused.Remediation:
anchor before lock or provide a migration alias.data from render-facing projection.Status: complete
Updated: 2026-04-30T15:36:47Z
| Decision | Strongest fair objection | Best argument against it | Tradeoff tension | Rejected alternative | Why the chosen option wins | Migration/docs/proof | Verdict |
|---|---|---|---|---|---|---|---|
Hard-cut bookmark to anchor | "This is churn for a thing that already works locally." | Existing local examples are simple, and Bookmark is already a Slate noun. | Users copying current examples must rename one field. | Keep bookmark and later add remoteAnchor. | bookmark names the implementation, not the contract. A collaborative anchor can be Yjs/service-backed without being a Slate Bookmark. | Update examples to anchor; release review can add an alias only if real external adoption is found; unit test old shape rejected or explicitly aliased. | keep |
| Default comments to external annotation channels | "Lexical and many apps serialize mark ids in the document; external anchors may drift." | Serialized ids give offline durability and easy copy/paste persistence. | External channels require adapter discipline for drift, deletion, and permissions. | Force document mark ids for every comment. | Comment-only users should not need document-write permission. External default keeps permissions and audit honest while still allowing document-embedded ids as an adapter choice. | Docs show external channel first, embedded ids as opt-in; proof requires read-only comment creation and remote rebase. | keep |
Split data and projection | "Two payloads are more API than one." | One data object is easy to teach and already works in examples. | Users must decide which fields affect text paint. | Keep spreading data into projection entries. | Sidebar/comment body churn is a different hot path from inline highlight paint. The split prevents app metadata from becoming render invalidation by accident. | Test body update wakes annotation subscribers without repainting inline projection when projection is stable. | keep |
Add refresh({ ids }) | "A store refresh API with ids smells like callers managing internals." | A single refresh() is simpler and cannot go stale through wrong id lists. | Callers must pass accurate ids for maximum performance. | Keep full refresh as the only public external invalidation path. | Product-scale review docs need id-targeted external updates. Full refresh remains the safe fallback when ids are unknown. | Unit and benchmark rows cover omitted ids, empty ids, known ids, and full fallback. | keep |
Accepted revisions:
Dropped choices:
bookmark.No unresolved steelman rows remain.
Status: complete
Updated: 2026-04-30T15:36:47Z
Trigger:
Blast radius:
.tmp/slate-v2/packages/slate-react/src/annotation-store.ts.tmp/slate-v2/packages/slate-react/src/hooks/use-slate-annotation-store.tsx.tmp/slate-v2/packages/slate-react/src/hooks/use-slate-annotations.tsx.tmp/slate-v2/packages/slate-react/src/index.ts.tmp/slate-v2/site/examples/ts/review-comments.tsx.tmp/slate-v2/site/examples/ts/persistent-annotation-anchors.tsx.tmp/slate-v2/docs/libraries/slate-react/annotations.mdThree-scenario pre-mortem:
data/projection split is taught poorly, so apps put body text in
projection and recreate the same repaint problem.refresh({ ids }) gets wrong ids from an external store and stale highlights
survive until the next full refresh.Expanded proof plan:
| Lane | Required proof |
|---|---|
| Unit | Bookmark satisfies SlateAnnotationAnchor; old bookmark shape is rejected or explicitly aliased; deleted anchors resolve null. |
| React integration | Annotation body update wakes annotation subscribers, not inline projection subscribers, when projection is unchanged. |
| Collaboration | Mock Yjs/service anchor can update while editor is read-only and document value stays unchanged. |
| Browser | Side-by-side example proves writer edits in one editor while reviewer creates, resolves, and deletes comments from a read-only editor. |
| Migration/adoption | Docs show local bookmark, external channel, and document-embedded id strategies with clear ownership rules. |
| Performance | 1k annotations: local edit near one anchor and external body update stay runtime-bucket local; full refresh is measured fallback only. |
| Security/permissions | Docs state permission enforcement belongs to app/service/collab layer, not raw Slate. |
Rollback or remediation:
resolve(editor) or
an adapter object before publish.projection is confusing in docs, rename to renderData before release,
but keep the split.Verdict:
Plate:
anchor
objects as an adapter strategy.slate-yjs:
SlateAnnotationAnchor by resolving
relative positions to the current Slate range.Legacy Slate:
decorate(entry) => Range[] remains a transient rendering escape hatch, not
the durable comments architecture.RangeRef remains lower-level local runtime machinery. Bookmark and
generic anchors are the public durability story.| Lens | Applicability | Finding | Plan delta |
|---|---|---|---|
vercel-react-best-practices | applied | Render-facing data must stay in external stores and useSyncExternalStore subscriptions, not context churn. | Keep annotation hooks/store; split data from projection. |
performance-oracle | applied | Hot path risk is not anchor resolution alone; it is broad refresh and repaint from external comment churn. | Add id-targeted refresh, projection split, 1k annotation stress row. |
tdd | applied | Tests must prove public behavior: comment-only creation, external refresh, remote rebase, and render locality. | Verification plan now lists vertical public-interface rows. |
build-web-apps:shadcn | skipped | No new UI components are designed in this planning pass. | Future example UI should stay minimal and composed, but raw Slate API plan does not need shadcn work. |
react-useeffect | applied | Store lifecycle and external updates should use stable refs and external-store subscriptions, not reset-on-render effects. | Keep stable annotation arrays; add docs warning for data identity and projection payloads. |
SlateAnnotationAnchor, rename bookmark to
anchor, export the new type, update tests and examples.projection, refresh({ ids }), and id-targeted
annotation/projection rebuild semantics.docs/libraries/slate-react/annotations.md, update
review comments examples, and add collaborative-comments.tsx as a
side-by-side writer/reviewer example if it can prove separate channels
honestly.bun test packages/slate/test/bookmark-contract.ts packages/slate-react/test/annotation-store-contract.tsxbun test packages/slate-react/test/annotation-store-contract.tsx --bail 1bun run bench:react:rerender-breadth:localbookmark -> anchor.data/projection split.Added and indexed:
docs/research/decisions/slate-v2-collaborative-annotation-channels.mdThe decision accepts external annotation channels as the raw Slate default for comment-only collaboration while preserving document-embedded ids as an adapter or product choice.
Total: 0.93 done.
| Dimension | Weight | Score | Evidence |
|---|---|---|---|
| React 19.2 runtime performance | 0.20 | 0.94 | Source bus, projection store refresh reasons, runtime-id subscribers, prior source-bus solution, data/projection split, and id-targeted refresh proof rows. |
| Slate-close unopinionated DX | 0.20 | 0.93 | Bookmark stays the built-in anchor; anchor names the generic contract; raw Slate avoids product comments and keeps document-embedded ids optional. |
| Plate/slate-yjs migration backbone | 0.15 | 0.91 | Migration backbone now names Plate ownership, slate-yjs separate channels, and adapter-owned relative-position anchors without current-version adapter promises. |
| Regression-proof testing strategy | 0.20 | 0.92 | Plan names unit, React integration, browser, stress, migration, and permission-doc proof rows for every risky claim. |
| Research evidence completeness | 0.15 | 0.94 | Live Slate v2, beta/release surface, legacy Slate, ProseMirror, Lexical, Tiptap, Plate/slate-yjs pressure, compiled research, and new decision page are recorded. |
| shadcn-style composability/minimalism | 0.10 | 0.91 | Store/hook shape remains small; UI remains product-owned; projection prevents app data from bloating render payloads. |
Completion threshold is met:
0.92.0.85.| Pass | Status | Notes |
|---|---|---|
| current-state-read-and-initial-score | complete | Live source, tests, examples, compiled research, and external editor source inspected. |
| intent-boundary-and-decision-brief | complete | Hard-cut bookmark to anchor; add data/projection split; require id-targeted external refresh; research decision page needed after steelman/high-risk. |
| steelman-pass | complete | Accepted hard-cut anchor, external channel default, data/projection, and id-targeted refresh after challenge. |
| high-risk-deliberate-pass | complete | Expanded blast radius, pre-mortem, proof plan, rollback answer, and keep verdict. |
| closure-score | complete | Score 0.93; plan is ready for user review before implementation. |
| implementation-slice-api-store | complete | Started 2026-04-30T16:46:34Z after the user asked to build from the plan. Owner: .tmp/slate-v2/packages/slate-react; target: anchor, data/projection, and refresh({ ids }). Evidence: focused annotation-store Vitest, package typecheck, site typecheck, and bun lint:fix. |
| docs-examples-collaboration-proof | complete | Started 2026-04-30T16:54:03Z. Added .tmp/slate-v2/docs/libraries/slate-react/annotations.md and .tmp/slate-v2/site/examples/ts/collaborative-comments.tsx, with route registration. |
| browser-and-stress-closure | complete | Browser proof passed in collaborative-comments; screenshot saved at /Users/zbeyens/.dev-browser/tmp/slate-collaborative-comments-proof.png. bun run bench:react:rerender-breadth:local passed. bun check passed. |
Implementation is complete.
Concrete next move: