docs/solutions/developer-experience/2026-05-17-slate-v2-root-normalizers-need-editor-node-lane-split.md
Slate v2 initially moved forced-layout repair from commitListeners into
normalizers.node, but root/value-level repair does not belong in a node-entry
callback. That API would force every root normalizer to branch on the editor
entry before doing useful work.
normalizers.node would need noisy guards:normalizers: {
node({ entry, next, tx }) {
const [node] = entry
if (!NodeApi.isEditor(node)) {
next()
return
}
// root/value repair
},
}
normalizers.node as the whole
normalizeNode replacement, which made root repair look like a special case
instead of a first-class extension lane.normalizers.node callback for every entry. It is close to legacy
normalizeNode, but the v2 extension API can be clearer because root repair
and node repair have different context needs.normalizers.element or normalizers.text. That looks neat but creates
extra lifecycle lanes before there is real pressure for them.commitListeners for root layout repair. That reintroduces
post-commit policy and global reentry guards in examples.Split normalization authoring into two typed lanes:
defineEditorExtension({
name: 'forced-layout',
normalizers: {
editor({ next, tx }) {
const children = tx.value.get()
// root/value-level repair
next()
},
node({ entry, next, tx }) {
// non-root node-entry repair
next()
},
},
})
Runtime rules:
normalizers.editor runs only for the editor root path [].normalizers.node skips the editor root and only receives non-root entries.tx.normalizers.editor has no entry in its public type.Forced-layout should use the root lane:
const forcedLayout = () =>
defineEditorExtension<CustomEditor>()({
name: 'forced-layout',
normalizers: {
editor({ next, tx }) {
const children = tx.value.get()
const first = children[0]
if (!first) {
tx.nodes.insert(createTitle(), { at: [0] })
return
}
next()
},
},
})
Lock the API with runtime and type tests:
expect(seen.includes('editor')).toBe(true)
expect(seen.includes('node:')).toBe(false)
expect(seen.includes('node:0')).toBe(true)
normalizers: {
editor(context) {
context.tx.value.get()
// @ts-expect-error editor normalizers do not expose node entries
context.entry
},
}
Root/value normalization and node-entry normalization are different authoring
jobs. Splitting them removes the need for NodeApi.isEditor(node) in normal
root repair code while preserving the Slate fallback mental model through
next().
Keeping only editor and node avoids overfitting the API. The editor lane
handles document-wide invariants; the node lane handles ordinary structural
repair. Element/text lanes can wait until there is enough repeated pressure.
The benchmark stays honest by comparing equivalent lanes: v2
normalizers.node for no-op node dispatch and v2 normalizers.editor for
forced-layout repair against legacy editor.normalizeNode.
NodeApi.isEditor(node) before doing its
normal work, consider a dedicated root/editor lane.normalizers.editor, node repair in normalizers.node.