docs/solutions/logic-errors/2026-05-23-async-decorate-refresh-must-export-dom-selection.md
Editable.decorate can change from an async React state update after the user
types. The decoration refresh splits and wraps text DOM without creating a Slate
editor commit, so the normal commit-driven selection export does not run.
there at the end of
This is some text here about. there.41, but the browser DOM caret stayed at
offset 35.Projection refresh now reports whether rendered projection buckets changed. The editable runtime subscribes once to those refresh results and requests a render repair when a non-editor projection refresh changes rendered text:
return projectionStore.subscribeProjectionRefresh((result) => {
if (!result.requiresDOMSelectionExport) return
requestEditableRepair({
forceRender: true,
kind: 'force-render',
selectionSourceTransition: {
preferModelSelection: true,
reason: 'projection-refresh',
selectionSource: 'model-owned',
},
})
})
The regression lives in the browser suite, not a model-only unit test:
await page.keyboard.type(' there')
await expect(page.locator('[data-cy="async-decoration-highlight"]')).toHaveCount(3)
await editor.assert.selection({
anchor: { path: [0, 0], offset: 41 },
focus: { path: [0, 0], offset: 41 },
})
await expect
.poll(() => getDOMCaretOffsetInFirstText(editor.root))
.toEqual({
offset: 41,
text: 'This is some text here about. there there',
})
Text projection subscribers update when the decoration source refreshes, but no Slate commit fires because the document did not change. A projection refresh result gives the editable runtime an explicit signal that rendered text changed without an editor commit, so the DOM-selection export path can run after React commits the new highlighted text structure.
This matches the upstream root cause: decoration DOM restructuring and selection restoration must be part of the same repair window.
decorate changes from async state.