Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/emit-repair-patches-immediately.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@portabletext/editor': patch
---

fix: emit structural repair patches immediately and make engine normalization the sole repairer

Opening a document whose value contains structurally invalid content (blocks or children without a `_key`, missing or empty `children` arrays, duplicate keys) now emits the fixing patches as soon as the value settles. Previously they were held back until the first local edit. Read-only editors emit them too: individual patches relay immediately regardless of read-only state, since a host mirroring them for display has nothing to reject. Mutations, the debounced batches hosts persist, wait for the editor to become editable before delivering, because a host following the documented `onChange` contract rejects mutations against a read-only document; a held mutation that a newer snapshot supersedes is dropped instead of delivered late, and pending mutations are handed over on unmount instead of dropped, so an edit typed just before a read-only flip and unmount is never lost. A block missing its `_key` is repaired like any other mechanical defect instead of triggering the invalid-value flow, which now only fires for defects that need a human, with resolution paths anchored at the defective block's actual position.

The repair patches take the editor's own shapes: a minted `_key` is a minimal `set` on the `_key` field, an empty text block gets its placeholder span as an `insert` before `children[0]`, and all repair patches carry `origin: 'local'`. Orphaned `markDefs` are no longer pruned when a value enters the editor; they are pruned when a local edit next touches the block, as a `set` of the filtered `markDefs` array. `InvalidValueResolution.autoResolve` is deprecated and never set.
7 changes: 4 additions & 3 deletions packages/editor/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ export type EditorEvent =
* if the editor's content has since diverged from it through local
* edits.
*
* Reconciliation is not an edit: it emits no `patch` or `mutation`
* events and adds no history step. While local changes are in
* flight, it is deferred until they have flushed. `undefined` and
* Reconciliation itself is not an edit and adds no history step.
* Repairs of structurally invalid content that it triggers do emit
* `patch` and `mutation` events. While local changes are in flight,
* it is deferred until they have flushed. `undefined` and
* `[]` are the same empty snapshot: sending either when the previous
* snapshot was also empty is a no-op and never clears locally typed
* content.
Expand Down
1 change: 1 addition & 0 deletions packages/editor/src/editor/create-editor-engine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export function createEditorEngine(
editor.undoStepId = undefined

editor.isDeferringMutations = false
editor.notifyInboundStateApplied = null
editor.lastSyncedValue = undefined
editor.valueUnsetEmitted = false
editor.isPatching = true
Expand Down
27 changes: 4 additions & 23 deletions packages/editor/src/editor/create-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,6 @@ function createActors(config: {
input: {
initialValue: config.editorActor.getSnapshot().context.initialValue,
keyGenerator: config.editorActor.getSnapshot().context.keyGenerator,
readOnly: config.editorActor
.getSnapshot()
.matches({'edit mode': 'read only'}),
schema: config.editorActor.getSnapshot().context.schema,
editorEngine: config.editorEngine,
},
Expand Down Expand Up @@ -278,12 +275,10 @@ function createActors(config: {
case 'value changed':
config.relay.send(event)
break
case 'patch':
config.editorActor.send({
...event,
type: 'internal.patch',
value: config.editorEngine.snapshot.context.value,
})
case 'inbound state applied':
// The mutation batcher's held-repair cull, not forwarded to
// `editorActor` like the other cases: nothing there needs it.
config.editorEngine.notifyInboundStateApplied?.()
break

default:
Expand All @@ -296,20 +291,6 @@ function createActors(config: {
}
})

config.subscriptions.push(() => {
const subscription = config.editorActor.subscribe((snapshot) => {
if (snapshot.matches({'edit mode': 'read only'})) {
syncActor.send({type: 'update readOnly', readOnly: true})
} else {
syncActor.send({type: 'update readOnly', readOnly: false})
}
})

return () => {
subscription.unsubscribe()
}
})

config.subscriptions.push(() => {
const subscription = config.editorActor.on('*', (event) => {
switch (event.type) {
Expand Down
111 changes: 7 additions & 104 deletions packages/editor/src/editor/editor-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,12 @@ import type {
ExternalBehaviorEvent,
} from '../behaviors/behavior.types.event'
import type {Converter} from '../converters/converter.types'
import {isInNormalization} from '../engine/core/apply-context'
import {DOMEditor} from '../engine/dom/plugin/dom-editor'
import {normalize} from '../engine/editor/normalize'
import {debug} from '../internal-utils/debug'
import type {EventPosition} from '../internal-utils/event-position'
import {sortByPriority} from '../priority/priority.sort'
import type {RegistrableNode} from '../renderers/renderer.types'
import {pathContains} from '../traversal/path-contains'
import type {NamespaceEvent, OmitFromUnion} from '../type-utils'
import type {EditorSelection} from '../types/editor'
import type {PortableTextEditorEngine} from '../types/editor-engine'
Expand Down Expand Up @@ -312,30 +310,6 @@ export const editorMachine = setup({
'clear pending events': assign({
pendingEvents: [],
}),
'discard conflicting pending patches': assign({
pendingEvents: ({context, event}) => {
if (event.type !== 'patches') {
return context.pendingEvents
}

const incomingPaths = event.patches.map((patch) => patch.path)

return context.pendingEvents.filter((pendingEvent) => {
if (pendingEvent.type !== 'internal.patch') {
return true
}

return !incomingPaths.some(
(incomingPath) =>
pathContains(pendingEvent.patch.path, incomingPath) ||
pathContains(incomingPath, pendingEvent.patch.path),
)
})
},
}),
'discard all pending events': assign({
pendingEvents: [],
}),
'defer incoming patches': assign({
pendingIncomingPatchesEvents: ({context, event}) => {
return event.type === 'patches'
Expand Down Expand Up @@ -453,13 +427,6 @@ export const editorMachine = setup({

return context.editorEngine.operations.length > 0
},
'engine is normalizing node': ({context}) => {
if (!context.editorEngine) {
return false
}

return isInNormalization(context.editorEngine.applyContext)
},
},
}).createMachine({
id: 'editor',
Expand Down Expand Up @@ -717,6 +684,8 @@ export const editorMachine = setup({
'emit ready',
'emit pending incoming patches',
'clear pending incoming patches',
'emit pending events',
'clear pending events',
],
on: {
'internal.patch': {
Expand Down Expand Up @@ -788,78 +757,12 @@ export const editorMachine = setup({
},
},
'writing': {
initial: 'pristine',
states: {
pristine: {
initial: 'idle',
states: {
idle: {
entry: [
() => {
debug.state(
'entry: setup->set up->writing->pristine->idle',
)
},
],
exit: [
() => {
debug.state(
'exit: setup->set up->writing->pristine->idle',
)
},
],
on: {
'internal.patch': [
{
guard: 'engine is normalizing node',
actions: 'defer event',
},
{
actions: 'defer event',
target: '#editor.setup.set up.writing.dirty',
},
],
'mutation': [
{
guard: 'engine is normalizing node',
actions: 'defer event',
},
{
actions: 'defer event',
target: '#editor.setup.set up.writing.dirty',
},
],
'patches': {
actions: 'discard conflicting pending patches',
},
'syncing value': {
actions: 'discard all pending events',
},
},
},
},
on: {
'internal.patch': {
actions: 'emit patch event',
},
dirty: {
entry: [
() => {
debug.state('entry: setup->set up->writing->dirty')
},
'emit pending events',
'clear pending events',
],
exit: [
() => {
debug.state('exit: setup->set up->writing->dirty')
},
],
on: {
'internal.patch': {
actions: 'emit patch event',
},
'mutation': {
actions: 'emit mutation event',
},
},
'mutation': {
actions: 'emit mutation event',
},
},
},
Expand Down
15 changes: 11 additions & 4 deletions packages/editor/src/editor/mutation-batcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,22 +160,20 @@ describe('mutation batcher', () => {
expect(harness.mutationSends).toHaveLength(0)
})

test('defers patch events and mutations while read-only, flushing once editable', () => {
test('relays patch events immediately even while read-only, holding the mutation until editable', () => {
const harness = createTestHarness({readOnly: true})

harness.sendPatch(createPatch('a'), 'op-1')

expect(harness.relayedPatches).toEqual([])
expect(harness.relayedPatches).toEqual([createPatch('a')])

vi.advanceTimersByTime(FLUSH_INTERVAL * 3)

expect(harness.relayedPatches).toEqual([])
expect(harness.mutationSends).toHaveLength(0)

harness.setReadOnly(false)
vi.advanceTimersByTime(FLUSH_INTERVAL)

expect(harness.relayedPatches).toEqual([createPatch('a')])
expect(harness.mutationSends).toEqual([{patches: [createPatch('a')]}])
})

Expand All @@ -188,6 +186,15 @@ describe('mutation batcher', () => {
expect(harness.mutationSends).toEqual([{patches: [createPatch('a')]}])
})

test('flushes pending mutations on unsubscribe even while read-only', () => {
const harness = createTestHarness({readOnly: true})

harness.sendPatch(createPatch('a'), 'op-1')
harness.unsubscribe()

expect(harness.mutationSends).toEqual([{patches: [createPatch('a')]}])
})

test('defers mutations while normalization is suspended, flushing once it resumes', () => {
const harness = createTestHarness()

Expand Down
Loading
Loading