fix(block-editor): preserve unknown marks instead of blanking the field (#37175) - #37313
fix(block-editor): preserve unknown marks instead of blanking the field (#37175)#37313adrianjm-dotCMS wants to merge 2 commits into
Conversation
…ld (#37175) QA marked #37175 PARTIAL: the reported regression is fixed, but AC5 was never delivered and AC3 was only half closed. This closes both. AC5 — an unknown MARK still aborted `Node.fromJSON` for the whole document. #37205 registered `link`, `emoji` and `youtube`, which fixed the two known offenders (`link`, `highlight`) but not the failure mode: any mark the schema does not declare throws `RangeError: There is no mark type X in this schema` mid-recursion. TipTap catches it, logs `[tiptap warn]: Invalid content`, and boots an EMPTY document — so the field looks emptied while the stored JSON is intact, and the next save makes that loss real. Verified on a real build: 995 bytes -> 203 bytes after typing one word and publishing. Unknown NODES were already preserved via `dotUnsupportedBlock`; marks had no equivalent. This adds the mark-side twin, `dotUnsupportedMark`: visually neutral (the text it decorates stays ordinary editable text) and carrying the original mark payload so it round-trips back on save rather than being dropped. Preserving rather than stripping is deliberate. The absence of a mark is usually transient — a `customBlocks` remote extension whose CDN is unreachable (`remote-extensions.loader.ts` drops failed loads and boots anyway, correctly), a migration written through the API, a version rollback. Stripping would turn a 20-minute outage into permanent loss for every author who saved during it. Ordering invariant, documented at all three sites: nodes first, then marks. An unknown node is swallowed whole into the placeholder's `originalNode` attr, and that payload is inert data rather than part of the document tree — running the mark pass first would rewrite the very content the placeholder exists to preserve. `restoreUnknownBlockNodes` handles both halves, so both editors' single emit path picks it up with no new call sites. AC3 — `linkOnPaste: false` did not close the link-on-paste path. TipTap's Link returns its URL paste rule from `addPasteRules()` with no option guard, so pasting text containing a URL still created a link mark on a restricted field; `linkOnPaste` only suppresses wrapping a selection. `DotLink` now overrides `addPasteRules`, mirroring how `@tiptap/extension-youtube` guards its own paste handler. Both editors are wired the same way and derive their known sets from the live schema, so a newly registered extension needs no bookkeeping. Verified in Chromium against a local instance with `allowedBlocks` set to the issue's exact list, in BOTH editors: document loads (157 chars, 3 paragraphs, link and bold intact, zero console errors), and a save round-trips the unknown mark back (995 -> 1151 bytes, `dotUnsupportedMark` never persisted). Out of scope, confirmed by the issue's own scoping: the legacy editor still auto-links pasted URLs, since it uses its own `Link.extend` and never supported restricting links. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @adrianjm-dotCMS's task in 3m 20s —— View job Review: preserve unknown marks (#37175)
I traced the full load → edit → save round-trip in both editors, the ordering invariant, the paste-rule gate, and idempotency. The change is symmetric with the existing node path and the tests pin the behaviors that matter. New IssuesNo issues found. Notes (non-blocking)Round-trip is sound. The paste-rule gate is correct. On the design question you raised (registering Deferred limitation is acknowledged and correctly scoped. "Clear formatting" ( Consistency checks that passed: signature change to Solid, well-tested change. No blocking concerns. · branch |
rjvelazco
left a comment
There was a problem hiding this comment.
Approving with comments — nothing here should block
I reviewed this against a checkout of the branch rather than by reading, because I've spent the last while inside this exact load path on #36985 and wanted to check the edge cases rather than trust the diff. Every load-bearing claim in the PR description holds up. Four non-blocking comments inline, plus one correction to the description below.
What I verified on the branch
Real schema built from createEditorExtensions(...), real Editor instances, real transactions:
| Probe | Result |
|---|---|
Issue's exact allowedBlocks (bulletList,orderedList,codeBlock,table) → link and dotUnsupportedMark both in schema.marks |
✅ |
Document with a link and an unknown legalCitation mark loads under that restriction, text intact ("a link and a citation") |
✅ |
| Two different unknown marks on the same text — both preserved, both restored, and they survive an edit elsewhere in the doc | ✅ |
inclusive: false — typing at the boundary does not inherit the placeholder |
✅ |
| Applying bold over unknown-marked text keeps both marks | ✅ |
Ordering invariant — an unknown mark inside a dotUnsupportedBlock payload is left byte-for-byte |
✅ |
| Idempotence — load → emit → re-load → emit is stable | ✅ |
Paste rules: [RULE] unrestricted, [] when link isn't allowed |
✅ |
Legacy emit path restores marks (dot-block-editor.component.ts:313, via the shared restoreUnknownBlockNodes) — no new call site needed, as claimed |
✅ |
I went in expecting to find a problem with two same-type marks collapsing, since ProseMirror mark sets are normally exclusive per type. They don't — Mark.setFrom doesn't dedupe, and I confirmed both survive a subsequent transaction. Worth recording because it's the non-obvious part of the design and it works.
I also checked the initialisation ordering in the legacy editor, since an empty #knownEditorMarkNames at load would turn every mark into a placeholder. It's populated synchronously between new Editor(...) and subscribeToEditorEvents(), and writeValue guards on !this.editor, so there's no window. Safe.
One correction to the description
|
dotUnsupportedMarkpersisted to storage | — | no |
Not quite — it can be, by design. When the payload doesn't survive parsing, restoreUnknownMark keeps the placeholder so originalMarkRaw still round-trips. Measured:
in : marks:[{type:'dotUnsupportedMark', attrs:{originalMark:null, originalMarkRaw:'not json'}}]
out: marks:[{type:'dotUnsupportedMark', attrs:{originalMark:null, originalMarkRaw:'not json'}}] ← persisted
That's correct behaviour and it mirrors the node path exactly — but the table reads as an absolute guarantee, and a reviewer or QA taking it literally would treat a dotUnsupportedMark in stored JSON as a bug. Worth a footnote.
On the decision you flagged
Decision worth a reviewer's opinion: this registers a new schema entry,
dotUnsupportedMark… the alternative is stripping unknown marks instead of preserving them.
Preserve. Two reasons, and I'd hold this position under push-back:
- Your transience argument is the right one and it's stronger than the PR states. A CDN blip on a
customBlocksremote extension is not a hypothetical —remote-extensions.loader.tsdrops failed loads and boots anyway, correctly. Under the stripping design, a 20-minute outage silently converts to permanent, unrecoverable loss for every author who saved during it, with no error at any point. That's the same shape as the bug being fixed, just slower. - The immutability commitment is real but small. You're pinning one name forever. Weigh that against a defect class that has now surfaced three times (#37145
highlight, #37175link, and this) — every recurrence being some schema entry the editor didn't know about. A permanent name is a cheap price for closing the class rather than the instance.
The ~200-vs-~20 line difference is not the deciding factor: most of those 200 lines are the symmetric twin of code that already exists and is already tested, which is about the cheapest kind of 200 lines there is.
Scope
I agree with both exclusions. The legacy auto-link behaviour is explicitly out of scope per the issue's own scoping, and QA's mergeCells finding is a genuinely separate pre-existing defect — worth its own issue so it doesn't get lost.
The one thing I'd want before merge
Nothing in the code. But the unsetAllMarks behaviour in my first inline comment is a real, measured way to lose the payload this PR protects, and it isn't covered by a test or a comment. A docblock line would satisfy me.
🤖 Reviewed by Claude on behalf of @rjvelazco
…older Review on #37313. isJsonMark: `isJsonContent` was validating mark payloads in `renderUnknownBlockOriginalMark` and `restoreUnknownMark`. It works — a mark JSON shares the "object with a non-empty string type" shape — but the coupling is invisible, and if `isJsonContent` is ever tightened around node-specific rules mark restoration breaks SILENTLY: the symptom is a `dotUnsupportedMark` written to storage, not an exception. Adds a delegating `isJsonMark` so the intent is pinned by a name rather than by a coincidence of shape. DotLink.addPasteRules: gated on `autolink || linkOnPaste` instead of `linkOnPaste` alone. The rule being suppressed is the auto-link-on-paste rule, which sits closer to `autolink`; `createEditorExtensions()` derives both from the same `has('link')` so they cannot disagree today, but `linkOnPaste: false` + `autolink: true` is a coherent configuration ("don't wrap my selection, but do linkify what I paste") and the old gate would have silently killed the linkifying. Pinned by a new test that configures the extension directly, since the assembly path cannot produce that combination and nothing else in the suite would have caught a regression. Clear-formatting limitation, documented in `createUnsupportedBlockMark` and in `new-block-editor/CLAUDE.md`: the toolbar's clear-formatting action runs `unsetAllMarks().clearNodes()`, which strips this placeholder along with every real mark — and `clearNodes()` does the same to `dotUnsupportedBlock`. Neither placeholder renders visible formatting, so the author cannot tell anything was discarded and there is no way back. Still strictly better than the pre-fix behaviour, where the document did not load at all, and unlike that it is user-initiated. Both notes say the fix belongs to the clear-formatting command in each editor rather than to the placeholders, so the follow-up does not get built in the wrong place. Not changed, deliberately: memoizing the known-node/known-mark Sets. The cost is real (the guard runs per node selection via `markViewDirty`, not per value push), but `editorContentMatchesParsed` is the one function this PR and #36985 both touch, so memoizing it here would conflict with that rewrite. It belongs there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks @rjvelazco — the two-same-type-marks probe and the legacy init-ordering check are both things I hadn't verified, and Your description correction is the most useful comment here. The table read as an absolute guarantee and it isn't: the raw-string fallback keeps the placeholder on purpose so Decision: preserve. Your second argument is the one I'll reuse — a permanent name is cheap against a defect class that has surfaced three times (#37145 One thing neither review covered, since half the diff lives there: the legacy editor was verified at runtime, not just read. Pushed in 6eff8fc: |
Closes the two acceptance criteria QA found short on #37175 — see the QA verification comment. The originally reported regression was already fixed by #37205 and stays fixed; this PR is the hardening half.
Proposed Changes
link,emojiandyoutube, which fixed the two known offenders (link,highlight) but not the failure mode. Any mark the schema does not declare throwsRangeError: There is no mark type X in this schemamid-recursion inNode.fromJSON. TipTap catches it, logs[tiptap warn]: Invalid content, and boots an empty document — so the field looks emptied while the stored JSON is intact, and the next save makes that loss real. Unknown nodes were already preserved asdotUnsupportedBlock; marks had no equivalent. Adds the mark-side twindotUnsupportedMark(libs/dotcms-models/src/lib/unknown-block.util.ts), registered in both editors.customBlocksremote extension whose CDN is unreachable (remote-extensions.loader.tsdrops failed loads and boots anyway — correctly), content written through the API or migrated from another CMS (textStyle/color/fontFamilyare standard TipTap marks we don't register), or a version rollback. Stripping would turn a 20-minute outage into permanent loss for every author who saved during it.linkOnPaste: falsedid not close the link-on-paste path. TipTap's Link returns its URL paste rule fromaddPasteRules()with no option guard, so pasting text containing a URL still created a link mark on a field wherelinkisn't allowed;linkOnPasteonly suppresses wrapping a selection.DotLinknow overridesaddPasteRules, mirroring how@tiptap/extension-youtubeguards its own paste handler.originalNodeattr, and that payload is inert data rather than part of the document tree — running the mark pass first would rewrite the very content the placeholder exists to preserve. There's a test pinning it.restoreUnknownBlockNodesnow restores both halves, so each editor's single emit path picks it up with no new call sites on the save path.libs/new-block-editor/CLAUDE.md: new "Unknown nodes and marks (load-path invariant)" section + the mark inventory row.Checklist
JSON.parseof thedata-original-markattribute is already try/caught with a raw-string fallback, same as the node path.Additional Info
Verified in Chromium against a local instance, with
allowedBlocksset to the issue's exact list (bulletList,orderedList,codeBlock,table) and a fixture carrying an undeclaredlegalCitationmark, in both editors:main)headingRangeError: There is no mark type legalCitation in this schema[]['legalCitation','bold','link']dotUnsupportedMarkpersisted to storage<a>created<a>Legacy editor confirmed by
dot-old-block-editorin the DOM (feature flag forced via network interception, container config untouched). 203 bytes matches QA's reported 202 — same mechanism.Decision flagged for reviewers — resolved: preserve. Both reviewers landed on preserving rather than stripping, so no change. Recorded for anyone reading later: this registers a new schema entry,
dotUnsupportedMark. Perlibs/new-block-editor/CLAUDE.md→ "TipTap Node Names Are Immutable", that name can never be changed once content exists. If that's too much of a commitment, the alternative is stripping unknown marks instead of preserving them — it also satisfies AC5 as written ("the affected text survives a save"), is ~20 lines instead of ~200, and the traversal and call sites are already in place.Out of scope, confirmed by the issue's own scoping ("Scope: new Block Editor only… the legacy editor registers
Linkunconditionally", "The legacy editor never supported restricting links"): the legacy editor still auto-links pasted URLs, since it uses its ownLink.extendingetEditorMarks(). Verified1 → 2anchors there. Closing that would change behaviour the ticket explicitly excludes.Review feedback addressed (second commit):
isJsonMark—isJsonContentwas validating mark payloads. It works, but a tightenedisJsonContentwould break mark restoration silently, with adotUnsupportedMarkin storage as the only symptom.DotLink.addPasteRulesnow gates onautolink || linkOnPaste. The rule being suppressed is the auto-link-on-paste rule, closer toautolink;linkOnPaste: false+autolink: trueis coherent and the old gate would have killed the linkifying. Pinned by a new test that configures the extension directly, since the assembly path cannot produce that combination.createUnsupportedBlockMarkandnew-block-editor/CLAUDE.md: the toolbar runsunsetAllMarks().clearNodes(), which strips this placeholder along with every real mark — andclearNodes()does the same todotUnsupportedBlock. Neither renders visible formatting, so the loss is silent and irreversible. Still strictly better than the document not loading, and unlike that, user-initiated. Both notes say the fix belongs to the clear-formatting command in each editor, not to the placeholders.Sets. The cost is real (the guard runs per node selection viamarkViewDirty), buteditorContentMatchesParsedis the one function this PR and Block Editor: embedded contentlet cannot be selected when text or other content precedes it #36985 both touch, so it belongs in that rewrite.Also not addressed here, both flagged by QA as separate concerns:
TypeError: o.can(...).mergeCells is not a functionwhentableisn't inallowedBlocks. Pre-existing, untouched by fix(block-editor): register link, emoji and youtube regardless of Allowed Blocks (#37175) #37205 and by this PR; worth its own issue.libs/dotcms-modelshas no.spec.tsof its own, so the shared util is exercised from its consumers (new-block-editor,block-editor). No E2E added toapps/dotcms-ui-e2e.Tests:
nx test new-block-editor137/137 ·nx test block-editor --testPathPatterns=unknown-block5/5 ·tsc --noEmitclean on all three libs ·nx lint new-block-editor/dotcms-modelspass.nx test block-editorfull suite has 10 pre-existing failures (this.editor.setEditable is not a function, an incomplete spec mock) — identical on cleanmain, verified by stashing.This PR fixes: #37175