Skip to content

Commit cf75357

Browse files
authored
fix(content-controls): mutate locked SDTs via AttrSteps and inner-range writes (SD-3123) (#3287)
* fix(content-controls): mutate locked SDTs via AttrSteps and inner-range writes (SD-3123) Per ECMA-376, sdtLocked protects the SDT wrapper from removal but the content stays editable. Before this fix, programmatic mutations flowed through editor.commands.updateStructuredContentById, which dispatches tr.replaceWith(pos, pos + node.nodeSize, ...) spanning the entire SDT. The structured-content lock plugin's filterTransaction reads that as wrapper damage and silently filters the transaction on sdtLocked controls - producing false-success no-ops on operations the API allows (text.setValue, replaceContent, appendContent, prependContent, setLockMode). Two engine changes: - applyAttrsUpdate (sdt-properties-write.ts) - switch from editor.commands.updateStructuredContentById to per-key tr.setNodeAttribute. AttrSteps have no from/to range and are explicitly skipped by the lock plugin's filterTransaction. This is the path that patch, setLockMode, patchRawProperties, setType, and the date/binding/multiline family use. - replaceSdtTextContent (content-controls-wrappers.ts) - replace the SDT's inner content range (pos+1 to pos+nodeSize-1) instead of rewriting the whole wrapper. The lock plugin classifies inner-range steps as wouldModifyContent: allowed on sdtLocked, still blocked on contentLocked / sdtContentLocked (both per spec). The search in applyAttrsUpdate uses SDT_NODE_NAMES (imported from target-resolution.ts) so it resolves the same nodes the upstream resolveSdtByTarget would resolve. documentSection / documentPartObject are intentionally excluded - they have their own write paths and could collide on id. No changes to API-level lock guards (assertNotSdtLocked on patch, setType, setBinding, etc.) - relaxing those is a separate behavior decision and out of scope here. Tests: - 4 new regression tests for sdtLocked (setLockMode, text.setValue, text.clearValue, replaceContent) that assert the actual inner-range positions (pos+1, pos+nodeSize-1), not just call counts. - 11 existing tests migrated from "expect updateStructuredContentById called" to "expect tr.setNodeAttribute / tr.replaceWith / tr.delete". - 19 metadata-mutation operations added to CC_DIRECT_DISPATCH_OPS in conformance (no synthetic-failure mode after this refactor, matching the existing precedent for wrap/unwrap/etc.). All pass: 46 wrappers + 1170 conformance + 104 SDT-adjacent + 1398 document-api. Zero new TypeScript errors. Related: SD-3131, SD-3139, IT-1046. * test(content-controls): add clearContent sdtLocked regression test (SD-3123)
1 parent f390043 commit cf75357

4 files changed

Lines changed: 339 additions & 89 deletions

File tree

packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,7 @@ function makeTextEditor(
627627
addMark: vi.fn(),
628628
removeMark: vi.fn(),
629629
replaceWith: vi.fn(),
630+
setNodeAttribute: vi.fn().mockReturnThis(),
630631
insert: vi.fn(),
631632
setMeta: vi.fn(),
632633
mapping: { map: (pos: number) => pos },
@@ -1294,6 +1295,7 @@ function makeTableEditor(
12941295
delete: vi.fn().mockReturnThis(),
12951296
setNodeMarkup: vi.fn().mockReturnThis(),
12961297
replaceWith: vi.fn().mockReturnThis(),
1298+
setNodeAttribute: vi.fn().mockReturnThis(),
12971299
insert: vi.fn().mockReturnThis(),
12981300
setMeta: vi.fn().mockReturnThis(),
12991301
mapping: {
@@ -1638,15 +1640,26 @@ const NON_RECEIPT_MUTATION_OPS: ReadonlySet<OperationId> = new Set([
16381640
] as OperationId[]);
16391641

16401642
/**
1641-
* Content-control operations whose handlers always return `true` because they
1642-
* build and dispatch their own ProseMirror transaction directly (via
1643-
* `editor.view!.dispatch(tr)`) rather than delegating to an editor command whose
1644-
* boolean result propagates to the domain-command executor.
1643+
* Content-control operations excluded from the structured-failure conformance
1644+
* check because they have no synthetic-failure path that
1645+
* `makeNoOpSdtEditor` can simulate.
16451646
*
1646-
* Because the handler always returns `true`, the `domain.command` executor marks
1647-
* the step effect as `'changed'` and `executeSdtMutation` returns success.
1648-
* There is no code path that produces the `NO_OP` structured failure for these
1649-
* operations, so they are excluded from the failureCase conformance check.
1647+
* The originals (wrap, unwrap, copy, move, insertBefore, insertAfter, group
1648+
* wrap/ungroup, repeatingSection insertItem/cloneItem/deleteItem) build and
1649+
* dispatch their own PM transaction directly via `editor.view!.dispatch(tr)`
1650+
* rather than delegating to an editor command whose boolean result propagates
1651+
* back through the executor. The SD-3123 additions (patch, setLockMode,
1652+
* setType, setBinding, clearBinding, patchRawProperties, text.setMultiline,
1653+
* the date family, the checkbox family, the choiceList family, and
1654+
* repeatingSection.setAllowInsertDelete) no longer route through
1655+
* `editor.commands.updateStructuredContentById`; the synthetic
1656+
* `updateStructuredContentById = vi.fn(() => false)` mock that previously
1657+
* drove the failure case has no effect on the AttrStep / inner-range write
1658+
* path.
1659+
*
1660+
* In both groups, the operations can still fail in production (missing target,
1661+
* lock violation, schema invalidation in PM dispatch). They just don't have a
1662+
* clean synthetic failure mode reachable from the mock editor.
16501663
*/
16511664
const CC_DIRECT_DISPATCH_OPS: ReadonlySet<OperationId> = new Set([
16521665
'contentControls.wrap',
@@ -1661,6 +1674,28 @@ const CC_DIRECT_DISPATCH_OPS: ReadonlySet<OperationId> = new Set([
16611674
'contentControls.repeatingSection.insertItemAfter',
16621675
'contentControls.repeatingSection.cloneItem',
16631676
'contentControls.repeatingSection.deleteItem',
1677+
// SD-3123: synthetic noop-mock failure (updateStructuredContentById=false)
1678+
// no longer applies — these now write via tr.setNodeAttribute (metadata)
1679+
// or tr.replaceWith on the SDT inner range (content).
1680+
'contentControls.patch',
1681+
'contentControls.patchRawProperties',
1682+
'contentControls.setLockMode',
1683+
'contentControls.setType',
1684+
'contentControls.setBinding',
1685+
'contentControls.clearBinding',
1686+
'contentControls.text.setMultiline',
1687+
'contentControls.date.setValue',
1688+
'contentControls.date.clearValue',
1689+
'contentControls.date.setDisplayFormat',
1690+
'contentControls.date.setDisplayLocale',
1691+
'contentControls.date.setStorageFormat',
1692+
'contentControls.date.setCalendar',
1693+
'contentControls.checkbox.setState',
1694+
'contentControls.checkbox.toggle',
1695+
'contentControls.checkbox.setSymbolPair',
1696+
'contentControls.choiceList.setItems',
1697+
'contentControls.choiceList.setSelected',
1698+
'contentControls.repeatingSection.setAllowInsertDelete',
16641699
] as OperationId[]);
16651700

16661701
const HAS_STRUCTURED_FAILURE_RESULT = (operationId: OperationId): boolean =>
@@ -2367,6 +2402,7 @@ function makeTocEditor(commandOverrides: Record<string, unknown> = {}): Editor {
23672402
insert: vi.fn().mockReturnThis(),
23682403
setNodeMarkup: vi.fn().mockReturnThis(),
23692404
replaceWith: vi.fn().mockReturnThis(),
2405+
setNodeAttribute: vi.fn().mockReturnThis(),
23702406
setMeta: vi.fn().mockReturnThis(),
23712407
mapping: { map: (pos: number) => pos },
23722408
docChanged: true,
@@ -2439,6 +2475,7 @@ function makeImageEditor(): Editor {
24392475
insert: vi.fn().mockReturnThis(),
24402476
setNodeMarkup: vi.fn().mockReturnThis(),
24412477
replaceWith: vi.fn().mockReturnThis(),
2478+
setNodeAttribute: vi.fn().mockReturnThis(),
24422479
setMeta: vi.fn().mockReturnThis(),
24432480
mapping: { map: (pos: number) => pos },
24442481
docChanged: true,
@@ -2513,6 +2550,7 @@ function makeMultiBlockImageEditor(): Editor {
25132550
insert: vi.fn().mockReturnThis(),
25142551
setNodeMarkup: vi.fn().mockReturnThis(),
25152552
replaceWith: vi.fn().mockReturnThis(),
2553+
setNodeAttribute: vi.fn().mockReturnThis(),
25162554
setMeta: vi.fn().mockReturnThis(),
25172555
mapping: { map: (pos: number) => pos },
25182556
docChanged: true,
@@ -2675,6 +2713,7 @@ function makeSdtEditor(overrideAttrs: Record<string, unknown> = {}, textContent
26752713
addMark: vi.fn().mockReturnThis(),
26762714
removeMark: vi.fn().mockReturnThis(),
26772715
replaceWith: vi.fn().mockReturnThis(),
2716+
setNodeAttribute: vi.fn().mockReturnThis(),
26782717
insert: vi.fn().mockReturnThis(),
26792718
setMeta: vi.fn().mockReturnThis(),
26802719
mapping: { map: (pos: number) => pos },
@@ -2769,6 +2808,7 @@ function makeSdtEditorWithRepeatingSectionItems(): Editor {
27692808
addMark: vi.fn().mockReturnThis(),
27702809
removeMark: vi.fn().mockReturnThis(),
27712810
replaceWith: vi.fn().mockReturnThis(),
2811+
setNodeAttribute: vi.fn().mockReturnThis(),
27722812
insert: vi.fn().mockReturnThis(),
27732813
setMeta: vi.fn().mockReturnThis(),
27742814
mapping: { map: (pos: number) => pos },
@@ -2888,6 +2928,7 @@ function makeCaptionImageEditor(
28882928
insert: vi.fn().mockReturnThis(),
28892929
delete: vi.fn().mockReturnThis(),
28902930
replaceWith: vi.fn().mockReturnThis(),
2931+
setNodeAttribute: vi.fn().mockReturnThis(),
28912932
setNodeMarkup: vi.fn().mockReturnThis(),
28922933
setMeta: vi.fn().mockReturnThis(),
28932934
mapping: { map: (pos: number) => pos },
@@ -2953,6 +2994,7 @@ function makeRefEditor(
29532994
addMark: vi.fn().mockReturnThis(),
29542995
removeMark: vi.fn().mockReturnThis(),
29552996
replaceWith: vi.fn().mockReturnThis(),
2997+
setNodeAttribute: vi.fn().mockReturnThis(),
29562998
insert: vi.fn().mockReturnThis(),
29572999
setMeta: vi.fn().mockReturnThis(),
29583000
setNodeMarkup: vi.fn().mockReturnThis(),
@@ -11984,6 +12026,7 @@ describe('document-api adapter conformance', () => {
1198412026
insert: vi.fn().mockReturnThis(),
1198512027
setNodeMarkup: vi.fn().mockReturnThis(),
1198612028
replaceWith: vi.fn().mockReturnThis(),
12029+
setNodeAttribute: vi.fn().mockReturnThis(),
1198712030
setMeta: vi.fn().mockReturnThis(),
1198812031
mapping: { map: (pos: number) => pos },
1198912032
docChanged: true,

packages/super-editor/src/editors/v1/document-api-adapters/helpers/content-controls/sdt-properties-write.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import type { Editor } from '../../../core/Editor.js';
1313
import type { ContentControlTarget } from '@superdoc/document-api';
14-
import { resolveSdtByTarget } from './target-resolution.js';
14+
import { resolveSdtByTarget, SDT_NODE_NAMES } from './target-resolution.js';
1515

1616
// ---------------------------------------------------------------------------
1717
// XML element helpers for sdtPr.elements
@@ -68,13 +68,64 @@ function removeSdtPrChild(sdtPr: SdtPrElement, childName: string): SdtPrElement
6868
// ---------------------------------------------------------------------------
6969

7070
/**
71-
* Apply an attribute patch to an SDT node via updateStructuredContentById.
72-
* Returns true if the command executed successfully.
71+
* PM node-type names that carry a content-control SDT identity. Imported from
72+
* `target-resolution.ts` so the search here resolves the same nodes the
73+
* upstream `resolveSdtByTarget` would resolve. `documentSection` and
74+
* `documentPartObject` are intentionally not in `SDT_NODE_NAMES` — they have
75+
* their own write paths and could otherwise collide on `id` and cause this
76+
* loop to find and mutate the wrong node.
77+
*/
78+
const SDT_NODE_TYPES = new Set<string>(SDT_NODE_NAMES);
79+
80+
/**
81+
* Apply an attribute patch to an SDT node.
82+
*
83+
* Uses `tr.setNodeAttribute` per key, which emits PM AttrSteps. AttrSteps
84+
* have no `from`/`to` range and are explicitly skipped by the structured-
85+
* content lock plugin's `filterTransaction`, so this path can mutate
86+
* metadata (id, tag, alias, lockMode, controlType, sdtPr, appearance, ...)
87+
* on `sdtLocked` and `sdtContentLocked` controls without tripping the
88+
* wrapper-damage check.
89+
*
90+
* The previous implementation delegated to `editor.commands.updateStructuredContentById`,
91+
* which dispatches `tr.replaceWith(pos, pos + node.nodeSize, ...)`. That
92+
* step's range covered the entire SDT, which the lock plugin read as
93+
* wrapper damage and silently filtered for locked controls — producing
94+
* false-success mutations.
95+
*
96+
* Returns true if a matching SDT was found and the transaction dispatched,
97+
* false if no SDT matched the given id or the editor cannot dispatch.
7398
*/
7499
export function applyAttrsUpdate(editor: Editor, nodeId: string, attrsPatch: Record<string, unknown>): boolean {
75-
const updateCmd = editor.commands?.updateStructuredContentById;
76-
if (typeof updateCmd !== 'function') return false;
77-
return Boolean(updateCmd(nodeId, { attrs: attrsPatch }));
100+
if (!editor?.state) return false;
101+
102+
let foundPos: number | null = null;
103+
editor.state.doc.descendants((node, pos) => {
104+
if (foundPos !== null) return false;
105+
if (SDT_NODE_TYPES.has(node.type.name) && String(node.attrs.id) === String(nodeId)) {
106+
foundPos = pos;
107+
return false;
108+
}
109+
return true;
110+
});
111+
112+
if (foundPos === null) return false;
113+
114+
const tr = editor.state.tr;
115+
for (const [key, value] of Object.entries(attrsPatch)) {
116+
tr.setNodeAttribute(foundPos, key, value);
117+
}
118+
119+
if (tr.steps.length === 0) return true;
120+
121+
if (editor.view?.dispatch) {
122+
editor.view.dispatch(tr);
123+
} else if (typeof editor.dispatch === 'function') {
124+
editor.dispatch(tr);
125+
} else {
126+
return false;
127+
}
128+
return true;
78129
}
79130

80131
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)