Skip to content

Commit 867129c

Browse files
committed
fix(plugin-sdk-value): guard keyed markDefs unsets against store-referenced definitions
An upcoming editor change emits normalization's unused-definition prunes as item-keyed `unset` patches instead of whole-array sets. `toMergeableMarkDefsPatches` only decomposes whole-array sets, so those unsets would pass through without the referenced-keys guard: a diverged client (its span `marks` overwritten by the other client's flush, its own definition now locally unused) would prune a definition the store's spans still reference, orphaning the other client's annotation at the server. Keyed `markDefs` unsets now go through the same store-side referenced-keys check as decomposed sets and are dropped while the store's spans reference the definition. A dropped prune leaves an unused definition behind at worst; normalization in a later converged session removes it for real. Against the current editor's emission (whole-array sets only) the guard is inert; it ships ahead so a consumer upgrading the editor without upgrading this plugin is never exposed.
1 parent 04b7973 commit 867129c

3 files changed

Lines changed: 85 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@portabletext/plugin-sdk-value': patch
3+
---
4+
5+
fix: guard outgoing keyed `markDefs` unsets against store-referenced definitions
6+
7+
An upcoming `@portabletext/editor` release prunes unused annotation definitions as item-keyed `unset` patches instead of whole-array sets. A client that has diverged from the store can prune a definition the store's spans still reference, which would orphan another client's annotation. Outgoing keyed `markDefs` unsets are now dropped while the store's spans reference the definition, mirroring the existing guard on decomposed whole-array sets; at worst an unused definition lingers until a later converged session prunes it. Against current editor releases the guard is inert.

packages/plugin-sdk-value/src/plugin.sdk-value.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,39 @@ describe(toMergeableMarkDefsPatches.name, () => {
326326
},
327327
] as unknown as PortableTextBlock[]
328328

329+
test('keyed unsets of store-referenced definitions are dropped', () => {
330+
expect(
331+
toMergeableMarkDefsPatches(
332+
[
333+
{
334+
type: 'unset',
335+
path: [{_key: 'b1'}, 'markDefs', {_key: 'm1'}],
336+
},
337+
],
338+
() => storeValue,
339+
),
340+
).toEqual([])
341+
})
342+
343+
test('keyed unsets of unreferenced definitions pass through', () => {
344+
expect(
345+
toMergeableMarkDefsPatches(
346+
[
347+
{
348+
type: 'unset',
349+
path: [{_key: 'b1'}, 'markDefs', {_key: 'm2'}],
350+
},
351+
],
352+
() => storeValue,
353+
),
354+
).toEqual([
355+
{
356+
type: 'unset',
357+
path: [{_key: 'b1'}, 'markDefs', {_key: 'm2'}],
358+
},
359+
])
360+
})
361+
329362
test('new definitions become inserts', () => {
330363
expect(
331364
toMergeableMarkDefsPatches(

packages/plugin-sdk-value/src/plugin.sdk-value.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,51 @@ export function toMergeableMarkDefsPatches(
399399
getCurrentValue: () => PortableTextBlock[] | null | undefined,
400400
): PtePatch[] {
401401
return patches.flatMap((patch): PtePatch[] => {
402+
if (
403+
patch.type === 'unset' &&
404+
patch.path.length >= 2 &&
405+
patch.path.at(-2) === 'markDefs'
406+
) {
407+
// The editor emits item-keyed `markDefs` unsets (normalization
408+
// pruning definitions its spans no longer reference). The same
409+
// referenced-keys guard as below applies: a diverged client prunes
410+
// definitions the store's spans still reference, which would
411+
// orphan the other client's marks. Dropping the unset leaves an
412+
// unused definition behind at worst; normalization on a later
413+
// converged session prunes it for real.
414+
const keySegment = patch.path.at(-1)
415+
const definitionKey =
416+
typeof keySegment === 'object' &&
417+
keySegment !== null &&
418+
'_key' in keySegment
419+
? keySegment._key
420+
: undefined
421+
422+
if (definitionKey !== undefined) {
423+
const currentValue = getCurrentValue()
424+
const storeBlock = currentValue
425+
? getValueAtPath(
426+
currentValue as unknown as JSONValue,
427+
patch.path.slice(0, -2),
428+
)
429+
: undefined
430+
431+
if (typeof storeBlock === 'object' && storeBlock !== null) {
432+
const referencedKeys = new Set(
433+
(
434+
(storeBlock as {children?: Array<{marks?: string[]}>}).children ??
435+
[]
436+
).flatMap((child) => child.marks ?? []),
437+
)
438+
if (referencedKeys.has(definitionKey)) {
439+
return []
440+
}
441+
}
442+
}
443+
444+
return [patch]
445+
}
446+
402447
if (
403448
patch.type !== 'set' ||
404449
patch.path.at(-1) !== 'markDefs' ||

0 commit comments

Comments
 (0)