docs/solutions/performance-issues/2026-04-30-slate-v2-source-bus-routing-must-prove-upstream-fan-in-and-runtime-bucket-locality-separately.md
Slate v2 React stores already had source and runtime-id subscriber concepts, but their upstream editor listener was still broad. That made the architecture look source-scoped while the first notification hop still woke on every editor commit.
createSlateProjectionStore selected recompute by dirtiness, but still
subscribed through broad editor.subscribe.createSlateAnnotationStore had candidate filtering, but still received every
editor commit before filtering.read, update, broad advanced subscribe, and friend/internal routing for
React runtime owners.Add a friend/internal source bus behind Editor.subscribeSource(...), then route
projection and annotation stores through it.
Before, projection and annotation stores depended on broad editor fan-in:
const unsubscribeEditor = editor.subscribe((nextSnapshot, change) => {
recompute({
change,
reason: 'editor',
snapshot: nextSnapshot,
sourceId: options.sourceId,
})
})
After, projection stores map their dirtiness class to editor commit sources:
const unsubscribeEditorSources = getEditorSourcesForDirtiness(
options.dirtiness
).map((editorSource) =>
Editor.subscribeSource(editor, editorSource, (nextSnapshot, change) => {
recompute({
change,
reason: 'editor',
snapshot: nextSnapshot,
sourceId: options.sourceId,
})
})
)
The core bus derives sources from commit metadata:
const getSourcesForChange = (
change: SnapshotChange
): readonly EditorCommitSource[] => {
const sources: EditorCommitSource[] = ['commit']
if (
change.selectionChanged ||
(change.selectionImpactRuntimeIds?.length ?? 0) > 0
) {
sources.push('selection')
}
if (change.classes.includes('text')) {
sources.push('text')
}
if (
change.nodeImpactRuntimeIds == null ||
change.nodeImpactRuntimeIds.length > 0
) {
sources.push('node')
}
if (
change.classes.includes('text') ||
change.classes.includes('structural') ||
change.classes.includes('replace')
) {
sources.push('decoration', 'root')
}
return sources
}
Lock it with tests that fail on the exact wrong owner:
commit and selection, not
text, node, decoration, or root.editor.subscribe to throw, then prove a
text-dirty projection still updates through the source bus.editor.subscribe to throw, then prove a
bookmark-backed annotation still rebases after text insertion.The fix separates three different promises:
Those are not interchangeable. A green test for one can leave the other two quietly broken.
Keeping Editor.subscribeSource(...) on the friend/internal static API also
keeps the raw public editor shape small. App authors still see the simple
editor.subscribe escape hatch, while React runtime code gets the sharper
routing primitive it needs.
editor.subscribe to throw. Without that, broad upstream fan-in can sneak
back in while recompute tests stay green.editor.onSelection or editor.sources onto raw Slate before the internal
routing contract has failed to express a real need.