docs/solutions/developer-experience/2026-05-19-slate-v2-derived-lint-decorations-need-snapshot-sources-and-panel-subscriptions.md
The Linting example looked clean after moving to
useSlateRangeDecorationSource, but it still stored computed lint ranges in
React state. That teaches the wrong model for derived diagnostics: text edits
after Run linter can move the document while stored ranges keep old offsets.
Run linter, then type at the start of the document before the word
obviously; the warning can highlight the wrong text if ranges are stored.Apply first fix can apply the first fix using stale coordinates unless it
reads the latest editor snapshot.readonly LintIssueDecoration[] in React state and refreshing only on
deps: [diagnostics]. That updates on button clicks, but text edits do not
recompute derived diagnostics.read({ snapshot }) while leaving the
status panel outside the editor subscription path. Highlights update, but the
visible diagnostics can lag behind.Store lint mode/configuration, not lint ranges. Let the source derive ranges from the snapshot it is given, and let the visible panel subscribe to editor state.
Bad:
const [diagnostics, setDiagnostics] =
useState<readonly LintIssueDecoration[]>([])
const lintingSource = useSlateRangeDecorationSource<LintIssue>(editor, {
deps: [diagnostics],
id: 'linting',
dirtiness: 'external',
read: () => diagnostics,
})
Good:
const [lintMode, setLintMode] = useState<LintMode>('off')
const lintingSource = useSlateRangeDecorationSource<LintIssue>(editor, {
deps: [lintMode],
id: 'linting',
dirtiness: ['text', 'external'],
read: ({ snapshot }) =>
lintMode === 'off'
? []
: collectLintIssues(snapshot, {
includeServerDiagnostics: lintMode === 'server',
}),
})
Then render the visible diagnostics from an editor subscription, not from stale parent render state:
const diagnostics = useEditorState(
(state) =>
lintMode === 'off'
? NO_LINT_ISSUES
: collectLintIssues(state.runtime.snapshot(), {
includeServerDiagnostics: lintMode === 'server',
}),
{ deps: [lintMode] }
)
Apply first fix should also compute its target from the latest snapshot:
const fix = collectFromEditor(lintMode).find(
(diagnostic) => diagnostic.data.fixText
)
useSlateRangeDecorationSource already has the right contract for derived
overlays: the read callback receives the current snapshot whenever the source
is dirty. Marking both text and external as dirty means document edits and
mode/server changes both recompute the same derived lint ranges.
The panel needs its own subscription because the source projection store updates
the overlay rendering, not arbitrary React UI outside the subscribed editor
state. useEditorState keeps the count and issue list tied to the same current
snapshot as the highlights.
read({ snapshot }).<Slate> and derive it with useEditorState.