Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/remote-patches-cosmetic-normalization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@portabletext/editor': patch
---

fix: skip cosmetic normalization while applying remote patches

Adjacent same-mark spans and empty sibling spans arriving through remote patches are now kept as-is instead of being merged away by the receiving editor. Those merges were emitted and pushed back at the originator, so two people editing the same block concurrently could see formatting silently revert and the tail of the block duplicate. The unmerged structure renders identically and is canonicalized on the block's next local edit; `update value` still normalizes loaded documents, and structural repairs (missing keys, types, required fields) still run on every path.
31 changes: 29 additions & 2 deletions packages/editor/src/engine/core/normalize-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@ import {parentPath} from '../path/parent-path'
import {textEquals} from '../text/text-equals'
import type {WithEditorFirstArg} from '../utils/types'

/**
* Normalization rules split into two classes. Repairs fix structure the
* engine cannot represent (missing `_key`/`_type`, duplicate keys, missing
* required fields, empty blocks, unbracketed inline objects) and always
* run. Cosmetic rules canonicalize structure that is already valid
* Portable Text (merging adjacent same-mark spans, dropping empty sibling
* spans) and must not run while applying remote patches: emitted text
* patches are `diffMatchPatch` against a keyed span's text, so the wire
* structure is the shared base between editor and store, and canonicalizing
* a collaborator's structure either forks that base (kept local) or makes
* every receiver a competing writer on the originator's spans (pushed).
* Non-canonical structure renders identically and re-canonicalizes on the
* block's next local edit. Value sync (`update value`) still canonicalizes
* (self-solving), but only because its normalize flush runs outside
* `withRemoteChanges`; a wrapper-nesting change in the sync machine would
* silently revoke that.
*/
function isCosmeticNormalizationSkipped(editor: Editor): boolean {
return editor.isProcessingRemoteChanges
}

export const normalizeNode: WithEditorFirstArg<Editor['normalizeNode']> = (
editor,
entry,
Expand All @@ -55,7 +76,10 @@ export const normalizeNode: WithEditorFirstArg<Editor['normalizeNode']> = (
/**
* Merge spans with same set of .marks
*/
if (isTextBlock({schema: editor.snapshot.context.schema}, node)) {
if (
!isCosmeticNormalizationSkipped(editor) &&
isTextBlock({schema: editor.snapshot.context.schema}, node)
) {
const children = getChildren(editor.snapshot, path)

for (let i = 0; i < children.length - 1; i++) {
Expand Down Expand Up @@ -562,7 +586,10 @@ export const normalizeNode: WithEditorFirstArg<Editor['normalizeNode']> = (
if (isSpan({schema: editor.snapshot.context.schema}, child)) {
if (
prev != null &&
isSpan({schema: editor.snapshot.context.schema}, prev)
isSpan({schema: editor.snapshot.context.schema}, prev) &&
// Only this merge/empty-drop arm is cosmetic; the inline-object
// bracketing below is a repair and keeps running.
!isCosmeticNormalizationSkipped(editor)
) {
// Merge adjacent text nodes that are empty or match.
if (child.text === '') {
Expand Down
21 changes: 17 additions & 4 deletions packages/editor/tests/event.patches.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1834,7 +1834,9 @@ describe('event.patches', () => {
})

// Unset _type: normalization restores it to span (parent is text block).
// Three adjacent spans with compatible marks get merged by normalization.
// The three adjacent compatible spans are NOT merged: cosmetic
// normalization is skipped while applying remote patches; only the
// repairs run.
editor.send({
type: 'patches',
patches: [
Expand All @@ -1852,7 +1854,11 @@ describe('event.patches', () => {
{
_key: blockKey,
_type: 'block',
children: [{_type: 'span', _key: span1Key, text: '', marks: []}],
children: [
{_type: 'span', _key: span1Key, text: '', marks: []},
{_type: 'span', _key: newInlineKey, text: '', marks: []},
{_type: 'span', _key: span2Key, text: '', marks: []},
],
markDefs: [],
style: 'normal',
},
Expand Down Expand Up @@ -5043,7 +5049,8 @@ describe('event.patches', () => {
snapshot: undefined,
})

// Adjacent spans with identical marks are merged by normalization
// The adjacent same-mark spans are NOT merged: cosmetic normalization
// is skipped while applying remote patches.
await vi.waitFor(() => {
expect(editor.getSnapshot().context.value).toEqual([
{
Expand All @@ -5066,7 +5073,13 @@ describe('event.patches', () => {
{
_type: 'span',
_key: contentSpanKey,
text: 'first second',
text: 'first',
marks: [],
},
{
_type: 'span',
_key: 'new-span',
text: ' second',
marks: [],
},
],
Expand Down
Loading
Loading