docs/solutions/ui-bugs/2026-05-07-slate-react-chrome-composition-fallback-must-clean-unmanaged-projection-dom-text.md
Chrome composition fallback inserted the committed IME text into the Slate model, but a composition spanning decorated DOM nodes could leave the browser's raw composition text in the rendered DOM. The model was right and the UI was wrong, which is exactly the kind of bug a model-only assertion would miss.
alすしbeta but the visible DOM
rendered alすしすしbeta.editor.get.modelText() returned alすしbeta.editor.get.html() showed the extra すし as a raw text node between Slate
leaf spans, outside any [data-slate-string="true"] wrapper.Keep Chrome's compositionend fallback as the model writer, then remove
unmanaged composition text nodes under Slate text hosts.
The cleanup is intentionally narrow:
[data-slate-node="text"] hosts;[data-slate-string="true"], which is Slate-owned
rendered content.The regression row lives in
.tmp/slate-v2/playwright/integration/examples/highlighted-text.test.ts:
await editor.selection.selectDOM({
anchor: { path: [0, 0], offset: 2 },
focus: { path: [0, 0], offset: 6 },
});
await commitDOMComposition(editor, {
committedText: "すし",
steps: ["す", "すし"],
});
await editor.assert.text("alすしbeta");
await editor.assert.domSelection({
anchorNodeText: "lすし",
anchorOffset: 3,
focusNodeText: "lすし",
focusOffset: 3,
});
The Chrome fallback still owns model insertion because Chrome does not provide
the insertFromComposition beforeinput shape Slate needs. The extra text is a
separate DOM artifact from the browser composition mutation. Removing only
unmanaged text outside Slate string wrappers preserves the model-owned content
and deletes the stale browser artifact.
This matters most for projection-backed or decorated text because direct DOM text sync is disabled there. Those surfaces rely on React rendering plus runtime repair, so unmanaged browser nodes must be cleaned explicitly when the model is already correct.
editor.get.html() for raw text outside [data-slate-string="true"].